From eb905a09f69ab6acc48d1eca808724432ba1cfd8 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:06:18 +0530 Subject: [PATCH 1/6] feat: parse team.md Model column and EffectiveModel Header-aware Members/Coordinator parse, TeamMember.Model, ParseSquadModel, ReadSquadModel, and EffectiveModel. Old 4-column team.md still works. --- internal/squad/init_test.go | 65 +++++++++++ internal/squad/team.go | 210 ++++++++++++++++++++++++++++-------- internal/squad/types.go | 1 + 3 files changed, 234 insertions(+), 42 deletions(-) diff --git a/internal/squad/init_test.go b/internal/squad/init_test.go index 22c6eeb..a936f66 100644 --- a/internal/squad/init_test.go +++ b/internal/squad/init_test.go @@ -165,6 +165,71 @@ func TestParseTeamMarkdown(t *testing.T) { } } +func TestParseTeamMarkdownModelColumn(t *testing.T) { + md := ` +## Coordinator + +| Name | Role | Model | Notes | +|------|------|-------|-------| +| Squad | Coordinator | xai/grok-3 | Routes work | + +## Members + +| Name | Role | Charter | Status | Model | +|------|------|---------|--------|-------| +| Lead | Lead | ` + "`.squad/agents/lead/charter.md`" + ` | Active | anthropic/claude-sonnet-4-5 | +| Tester | Tester | ` + "`.squad/agents/tester/charter.md`" + ` | Active | | +` + members := ParseTeamMarkdown(md) + if len(members) != 2 { + t.Fatalf("want 2, got %+v", members) + } + if members[0].Model != "anthropic/claude-sonnet-4-5" { + t.Fatalf("lead model: %+v", members[0]) + } + if members[1].Model != "" { + t.Fatalf("tester must be empty inherit: %+v", members[1]) + } + if got := ParseSquadModel(md); got != "xai/grok-3" { + t.Fatalf("squad model %q", got) + } +} + +func TestParseTeamMarkdownOldFourColumn(t *testing.T) { + md := ` +## Coordinator + +| Name | Role | Notes | +|------|------|-------| +| Squad | Coordinator | Routes work | + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| Lead | Lead | x | Active | +` + members := ParseTeamMarkdown(md) + if len(members) != 1 || members[0].Name != "Lead" || members[0].Status != "Active" || members[0].Model != "" { + t.Fatalf("%+v", members) + } + if ParseSquadModel(md) != "" { + t.Fatal("missing model column must be empty") + } +} + +func TestEffectiveModel(t *testing.T) { + if EffectiveModel("anthropic/claude-sonnet-4-5", "xai/grok-3") != "anthropic/claude-sonnet-4-5" { + t.Fatal("own wins") + } + if EffectiveModel("", "xai/grok-3") != "xai/grok-3" { + t.Fatal("inherit team") + } + if EffectiveModel(" ", "") != "" { + t.Fatal("both empty") + } +} + func TestReadTeamAfterInit(t *testing.T) { root := t.TempDir() if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { diff --git a/internal/squad/team.go b/internal/squad/team.go index 15820df..6968cbb 100644 --- a/internal/squad/team.go +++ b/internal/squad/team.go @@ -9,65 +9,188 @@ import ( var ( // (?m) so ^ matches line starts (Go default is whole-string only). - reMembersHeading = regexp.MustCompile(`(?mi)^##\s+Members\b`) - reAnyHeading = regexp.MustCompile(`(?m)^##\s+`) - reTableSep = regexp.MustCompile(`(?m)^\|\s*-+`) - reNameHeader = regexp.MustCompile(`(?mi)^\|\s*Name\s*\|`) + reMembersHeading = regexp.MustCompile(`(?mi)^##\s+Members\b`) + reCoordinatorHeading = regexp.MustCompile(`(?mi)^##\s+Coordinator\b`) + reAnyHeading = regexp.MustCompile(`(?m)^##\s+`) + reTableSep = regexp.MustCompile(`(?m)^\|\s*-+`) + reNameHeader = regexp.MustCompile(`(?mi)^\|\s*Name\s*\|`) ) +type tableRow struct { + cells []string + cols map[string]int +} + // ParseTeamMarkdown parses member rows from team.md. // Prefer the "## Members" section when present. func ParseTeamMarkdown(content string) []TeamMember { - hasMembers := reMembersHeading.MatchString(content) - inMembers := !hasMembers + var section *regexp.Regexp + if reMembersHeading.MatchString(content) { + section = reMembersHeading + } var members []TeamMember + for _, row := range walkMarkdownTableRows(content, section) { + if len(row.cells) < 2 { + continue + } + name := cellNamed(row, "name", 0) + if strings.EqualFold(name, "name") { + continue + } + role := cellNamed(row, "role", 1) + status := "Active" + if s, ok := namedCell(row, "status"); ok { + if s != "" { + status = s + } + } else if row.cols == nil { + if len(row.cells) >= 4 { + status = row.cells[3] + } else if len(row.cells) >= 3 { + status = row.cells[len(row.cells)-1] + } + } + model, _ := namedCell(row, "model") + members = append(members, TeamMember{ + ID: rowMemberID(name, row), + Name: name, + Role: role, + Status: status, + Model: model, + }) + } + return members +} - for _, line := range strings.Split(content, "\n") { - trimmed := strings.TrimSpace(line) - trimmed = strings.TrimSuffix(trimmed, "\r") - - if reMembersHeading.MatchString(trimmed) { - inMembers = true +// ParseSquadModel returns the Coordinator Squad row's Model cell ("" if absent). +func ParseSquadModel(content string) string { + if !reCoordinatorHeading.MatchString(content) { + return "" + } + var rows []tableRow + for _, row := range walkMarkdownTableRows(content, reCoordinatorHeading) { + if len(row.cells) == 0 { continue } - if hasMembers && reAnyHeading.MatchString(trimmed) && !reMembersHeading.MatchString(trimmed) { - inMembers = false + if strings.EqualFold(cellNamed(row, "name", 0), "name") { continue } - if !inMembers { + rows = append(rows, row) + } + var chosen *tableRow + for i := range rows { + if strings.EqualFold(cellNamed(rows[i], "name", 0), "squad") { + chosen = &rows[i] + break + } + } + if chosen == nil && len(rows) == 1 { + chosen = &rows[0] + } + if chosen == nil { + return "" + } + model, _ := namedCell(*chosen, "model") + return model +} + +// ReadSquadModel loads the Coordinator model from .squad/team.md. +// A missing file is "", nil. +func ReadSquadModel(projectRoot string) (string, error) { + path := filepath.Join(ResolveDir(projectRoot), "team.md") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return ParseSquadModel(string(data)), nil +} + +// EffectiveModel prefers a member's own model, else the team/squad model. +func EffectiveModel(own, team string) string { + if s := strings.TrimSpace(own); s != "" { + return s + } + return strings.TrimSpace(team) +} + +func walkMarkdownTableRows(content string, section *regexp.Regexp) []tableRow { + restrict := section != nil + inSection := !restrict + var cols map[string]int + var rows []tableRow + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(strings.TrimSuffix(line, "\r")) + if section != nil && section.MatchString(trimmed) { + inSection = true + cols = nil continue } - if !strings.HasPrefix(trimmed, "|") { + if restrict && reAnyHeading.MatchString(trimmed) && !section.MatchString(trimmed) { + inSection = false + cols = nil continue } - if reTableSep.MatchString(trimmed) || reNameHeader.MatchString(trimmed) { + if !inSection || !strings.HasPrefix(trimmed, "|") { continue } - - cells := splitTableCells(trimmed) - if len(cells) < 2 { + if reTableSep.MatchString(trimmed) { continue } - name := cells[0] - role := cells[1] - if strings.EqualFold(name, "name") { + cells := splitTableCells(trimmed) + if len(cells) == 0 { continue } - status := "Active" - if len(cells) >= 4 { - status = cells[3] - } else if len(cells) >= 3 { - status = cells[len(cells)-1] + if strings.EqualFold(cells[0], "name") || reNameHeader.MatchString(trimmed) { + cols = headerColumns(cells) + continue } - id := memberIDFromRow(name, cells) - members = append(members, TeamMember{ - ID: id, - Name: name, - Role: role, - Status: status, - }) + rows = append(rows, tableRow{cells: cells, cols: cols}) } - return members + return rows +} + +func headerColumns(cells []string) map[string]int { + cols := make(map[string]int, len(cells)) + for i, c := range cells { + cols[strings.ToLower(c)] = i + } + return cols +} + +func namedCell(row tableRow, name string) (string, bool) { + if row.cols == nil { + return "", false + } + i, ok := row.cols[name] + if !ok { + return "", false + } + return cellAt(row.cells, i), true +} + +func cellNamed(row tableRow, name string, fallback int) string { + if s, ok := namedCell(row, name); ok { + return s + } + return cellAt(row.cells, fallback) +} + +func cellAt(cells []string, i int) string { + if i < 0 || i >= len(cells) { + return "" + } + return cells[i] +} + +func rowMemberID(name string, row tableRow) string { + charter := cellNamed(row, "charter", 2) + if id := idFromCharter(charter); id != "" { + return id + } + return memberID(name) } // memberIDFromRow prefers the charter path (`.squad/agents//…`) so display @@ -100,12 +223,15 @@ func idFromCharter(cell string) string { func splitTableCells(line string) []string { parts := strings.Split(line, "|") - var cells []string - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - cells = append(cells, p) - } + if len(parts) > 0 && strings.TrimSpace(parts[0]) == "" { + parts = parts[1:] + } + if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" { + parts = parts[:len(parts)-1] + } + cells := make([]string, len(parts)) + for i, p := range parts { + cells[i] = strings.TrimSpace(p) } return cells } diff --git a/internal/squad/types.go b/internal/squad/types.go index 6dd6559..ab1fb6f 100644 --- a/internal/squad/types.go +++ b/internal/squad/types.go @@ -35,6 +35,7 @@ type TeamMember struct { Name string Role string Status string + Model string // raw cell; empty means inherit } // DetectResult describes whether a project has been initialized. From 7280e0fec8497017595123c7a704b3f46c869045 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:18:10 +0530 Subject: [PATCH 2/6] feat: recast injects member model and splices squad.md --- internal/cli/cli.go | 2 +- internal/squad/recast.go | 123 +++++++++++++++++++++++++++++++--- internal/squad/recast_test.go | 115 ++++++++++++++++++++++++++++++- internal/squad/themes_test.go | 4 +- 4 files changed, 228 insertions(+), 16 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c9ad362..8a595b4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -318,7 +318,7 @@ func cmdCast(args []string) int { fmt.Printf("removed %s; recast %d agent file(s)\n", remove, res.Written) return 0 } - if err := squad.AddMember(root, add, role); err != nil { + if err := squad.AddMember(root, add, role, ""); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } diff --git a/internal/squad/recast.go b/internal/squad/recast.go index ed17fda..d6ef0de 100644 --- a/internal/squad/recast.go +++ b/internal/squad/recast.go @@ -15,12 +15,14 @@ type RecastResult struct { } // AddMember appends a row to team.md and creates charter/knowledge if missing. -func AddMember(projectRoot, name, role string) error { +// model is optional; empty keeps today's 4-column Members append. +func AddMember(projectRoot, name, role, model string) error { if !IsInitialized(projectRoot) { return fmt.Errorf("not initialized") } name = strings.TrimSpace(name) role = strings.TrimSpace(role) + model = strings.TrimSpace(model) if name == "" { return fmt.Errorf("name required") } @@ -45,7 +47,7 @@ func AddMember(projectRoot, name, role string) error { if err != nil { return err } - next, err := appendMemberRow(string(raw), name, role, id) + next, err := appendMemberRow(string(raw), name, role, id, model) if err != nil { return err } @@ -129,8 +131,11 @@ func memberID(name string) string { return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) } -func appendMemberRow(content, name, role, id string) (string, error) { +func appendMemberRow(content, name, role, id, model string) (string, error) { row := fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active |", name, role, id) + if model != "" { + row = fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active | %s |", name, role, id, model) + } lines := strings.Split(content, "\n") inMembers := false lastTable := -1 @@ -225,7 +230,8 @@ Write lasting notes to `+"`"+`.squad/agents/%s/knowledge.md`+"`"+`. `, name, role, role, id) } -// Recast writes .opencode/agents/.md from the live team. Does not touch squad.md. +// Recast writes .opencode/agents/.md from the live team and splices +// model: onto squad.md (coordinator body is otherwise left alone). func Recast(projectRoot string) (RecastResult, error) { if !IsInitialized(projectRoot) { return RecastResult{}, fmt.Errorf("not initialized") @@ -234,6 +240,10 @@ func Recast(projectRoot string) (RecastResult, error) { if err != nil { return RecastResult{}, err } + squadModel, err := ReadSquadModel(projectRoot) + if err != nil { + return RecastResult{}, err + } destDir := OpencodeAgentsDir(projectRoot) if err := os.MkdirAll(destDir, 0o755); err != nil { return RecastResult{}, err @@ -251,7 +261,7 @@ func Recast(projectRoot string) (RecastResult, error) { if m.ID == "" || m.ID == "squad" { continue } - body, err := agentMarkdown(tpl, m) + body, err := agentMarkdown(tpl, m, squadModel) if err != nil { return res, err } @@ -268,6 +278,9 @@ func Recast(projectRoot string) (RecastResult, error) { if err := pruneStaleHostAgents(destDir, liveIDs, hostIDs); err != nil { return res, err } + if err := spliceSquadModel(projectRoot, squadModel); err != nil { + return res, err + } return res, nil } @@ -318,17 +331,17 @@ func stockRoleID(id string) string { return id } -func agentMarkdown(tpl fs.FS, m TeamMember) (string, error) { +func agentMarkdown(tpl fs.FS, m TeamMember, squadModel string) (string, error) { role := stockRoleID(m.ID) + var body string data, err := fs.ReadFile(tpl, "opencode/agents/"+role+".md") if err == nil { - body := string(data) + body = string(data) if role != m.ID { body = strings.ReplaceAll(body, ".squad/agents/"+role+"/", ".squad/agents/"+m.ID+"/") } - return body, nil - } - return fmt.Sprintf(`--- + } else { + body = fmt.Sprintf(`--- description: %s — %s mode: subagent permission: @@ -347,5 +360,93 @@ You are **%s** (%s) on this project's Squad team. ## Escalate to the human Security-sensitive changes, unclear product priorities, or work outside your charter. -`, m.Name, m.Role, m.Name, m.Role, m.ID, m.ID), nil +`, m.Name, m.Role, m.Name, m.Role, m.ID, m.ID) + } + if effective := EffectiveModel(m.Model, squadModel); effective != "" { + body = spliceFrontmatterModel(body, effective) + } + return body, nil +} + +func spliceSquadModel(projectRoot, squadModel string) error { + path := filepath.Join(OpencodeAgentsDir(projectRoot), "squad.md") + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return err + } + data, err = fs.ReadFile(TemplateFS(), "opencode/agents/squad.md") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + } + next := spliceFrontmatterModel(string(data), squadModel) + return os.WriteFile(path, []byte(next), 0o644) +} + +func spliceFrontmatterModel(content, model string) string { + model = strings.TrimSpace(model) + lines := strings.Split(content, "\n") + start, end := -1, -1 + for i, line := range lines { + if strings.TrimSpace(strings.TrimSuffix(line, "\r")) != "---" { + continue + } + if start < 0 { + start = i + continue + } + end = i + break + } + if start < 0 || end < 0 { + return content + } + + fm := append([]string(nil), lines[start+1:end]...) + modelIdx, modeIdx, descIdx := -1, -1, -1 + for i, line := range fm { + trim := strings.TrimSpace(strings.TrimSuffix(line, "\r")) + switch { + case strings.HasPrefix(trim, "model:"): + modelIdx = i + case strings.HasPrefix(trim, "mode:"): + modeIdx = i + case strings.HasPrefix(trim, "description:"): + descIdx = i + } + } + + if model == "" { + if modelIdx < 0 { + return content + } + fm = append(fm[:modelIdx], fm[modelIdx+1:]...) + } else { + newLine := "model: " + model + if modelIdx >= 0 { + fm[modelIdx] = newLine + } else { + at := 0 + if modeIdx >= 0 { + at = modeIdx + 1 + } else if descIdx >= 0 { + at = descIdx + 1 + } + next := make([]string, 0, len(fm)+1) + next = append(next, fm[:at]...) + next = append(next, newLine) + next = append(next, fm[at:]...) + fm = next + } + } + + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:start+1]...) + out = append(out, fm...) + out = append(out, lines[end:]...) + return strings.Join(out, "\n") } diff --git a/internal/squad/recast_test.go b/internal/squad/recast_test.go index 5e6ad59..4bc36d4 100644 --- a/internal/squad/recast_test.go +++ b/internal/squad/recast_test.go @@ -12,7 +12,7 @@ func TestAddMemberAndRecast(t *testing.T) { if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { t.Fatal(err) } - if err := AddMember(root, "Designer", "Design"); err != nil { + if err := AddMember(root, "Designer", "Design", ""); err != nil { t.Fatal(err) } members, err := ReadTeam(root) @@ -57,7 +57,7 @@ func TestAddMemberRejectsDuplicate(t *testing.T) { if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { t.Fatal(err) } - if err := AddMember(root, "Lead", "Lead"); err == nil { + if err := AddMember(root, "Lead", "Lead", ""); err == nil { t.Fatal("expected duplicate error") } } @@ -249,3 +249,114 @@ func TestRemoveMemberRejectsMissingAndSquad(t *testing.T) { t.Fatal("squad.md must not be deleted") } } + +func TestRecastWritesMemberModelAndInheritsSquad(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + team := filepath.Join(ResolveDir(root), "team.md") + body := `# Mission Control + +## Coordinator + +| Name | Role | Model | Notes | +|------|------|-------|-------| +| Squad | Coordinator | xai/grok-3 | Routes | + +## Members + +| Name | Role | Charter | Status | Model | +|------|------|---------|--------|-------| +| Lead | Lead | ` + "`.squad/agents/lead/charter.md`" + ` | Active | anthropic/claude-sonnet-4-5 | +| Frontend | Frontend | ` + "`.squad/agents/frontend/charter.md`" + ` | Active | | +| Backend | Backend | ` + "`.squad/agents/backend/charter.md`" + ` | Active | | +| Tester | Tester | ` + "`.squad/agents/tester/charter.md`" + ` | Active | | +` + if err := os.WriteFile(team, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + marker := "CUSTOM COORDINATOR BODY LINE" + squadPath := filepath.Join(OpencodeAgentsDir(root), "squad.md") + raw, err := os.ReadFile(squadPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(squadPath, append(raw, []byte("\n"+marker+"\n")...), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + lead, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "lead.md")) + if !strings.Contains(string(lead), "model: anthropic/claude-sonnet-4-5") { + t.Fatalf("lead override:\n%s", lead) + } + front, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "frontend.md")) + if !strings.Contains(string(front), "model: xai/grok-3") { + t.Fatalf("frontend inherit:\n%s", front) + } + squad, _ := os.ReadFile(squadPath) + if !strings.Contains(string(squad), "model: xai/grok-3") { + t.Fatalf("squad splice:\n%s", squad) + } + if !strings.Contains(string(squad), marker) { + t.Fatalf("squad body lost:\n%s", squad) + } +} + +func TestRecastOmitsModelWhenBothEmpty(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + lead, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "lead.md")) + if strings.Contains(string(lead), "model:") { + t.Fatalf("unexpected model:\n%s", lead) + } + squad, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "squad.md")) + if strings.Contains(string(squad), "\nmodel:") || strings.HasPrefix(string(squad), "model:") { + t.Fatalf("squad should have no model:\n%s", squad) + } +} + +func TestUpgradeThenRecastRestoresSquadModel(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + team := filepath.Join(ResolveDir(root), "team.md") + body := `# Mission Control + +## Coordinator + +| Name | Role | Model | Notes | +|------|------|-------|-------| +| Squad | Coordinator | xai/grok-3 | Routes | + +## Members + +| Name | Role | Charter | Status | Model | +|------|------|---------|--------|-------| +| Lead | Lead | ` + "`.squad/agents/lead/charter.md`" + ` | Active | | +| Frontend | Frontend | ` + "`.squad/agents/frontend/charter.md`" + ` | Active | | +| Backend | Backend | ` + "`.squad/agents/backend/charter.md`" + ` | Active | | +| Tester | Tester | ` + "`.squad/agents/tester/charter.md`" + ` | Active | | +` + if err := os.WriteFile(team, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + if _, err := UpgradeHostFiles(UpgradeOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "squad.md")) + if !strings.Contains(string(got), "model: xai/grok-3") { + t.Fatalf("upgrade wiped orchestrator model:\n%s", got) + } +} diff --git a/internal/squad/themes_test.go b/internal/squad/themes_test.go index f411056..5911c21 100644 --- a/internal/squad/themes_test.go +++ b/internal/squad/themes_test.go @@ -60,7 +60,7 @@ func TestApplyThemeOfficeThenNone(t *testing.T) { if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { t.Fatal(err) } - if err := AddMember(root, "Designer", "Design"); err != nil { + if err := AddMember(root, "Designer", "Design", ""); err != nil { t.Fatal(err) } teamPath := filepath.Join(root, ".squad", "team.md") @@ -190,7 +190,7 @@ func TestApplyThemeNoneAfterInitOrigin(t *testing.T) { if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root, Theme: ThemeOffice}); err != nil { t.Fatal(err) } - if err := AddMember(root, "Designer", "Design"); err != nil { + if err := AddMember(root, "Designer", "Design", ""); err != nil { t.Fatal(err) } From 2d09bbf003699e336e8866b6549d9fe2bcf4dca9 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:28:56 +0530 Subject: [PATCH 3/6] feat: set member/squad model and promote Model column --- internal/squad/recast.go | 308 +++++++++++++++++++++++++++++++++- internal/squad/recast_test.go | 126 ++++++++++++++ 2 files changed, 432 insertions(+), 2 deletions(-) diff --git a/internal/squad/recast.go b/internal/squad/recast.go index d6ef0de..4ae7116 100644 --- a/internal/squad/recast.go +++ b/internal/squad/recast.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strings" ) @@ -23,6 +24,13 @@ func AddMember(projectRoot, name, role, model string) error { name = strings.TrimSpace(name) role = strings.TrimSpace(role) model = strings.TrimSpace(model) + if model != "" { + stored, err := ValidateModelID(model) + if err != nil { + return err + } + model = stored + } if name == "" { return fmt.Errorf("name required") } @@ -127,13 +135,102 @@ func RemoveMember(projectRoot, name string) error { return nil } +// ValidateModelID trims a model id. Empty or "-" store as inherit/clear. +// Any other value must contain "/" (provider/model-id). +func ValidateModelID(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" || s == "-" { + return "", nil + } + if !strings.Contains(s, "/") { + return "", fmt.Errorf("model %q must be provider/model-id", raw) + } + return s, nil +} + +// SetMemberModel writes a Members Model cell. name matches like RemoveMember. +// Empty or "-" clears the cell. Missing Model column is promoted first. +func SetMemberModel(projectRoot, name, model string) error { + if !IsInitialized(projectRoot) { + return fmt.Errorf("not initialized") + } + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("name required") + } + id := memberID(name) + if id == "squad" { + return fmt.Errorf("%q is reserved for the coordinator agent", name) + } + stored, err := ValidateModelID(model) + if err != nil { + return err + } + members, err := ReadTeam(projectRoot) + if err != nil { + return err + } + var found TeamMember + ok := false + for _, m := range members { + if m.ID == id || strings.EqualFold(m.Name, name) { + found = m + ok = true + break + } + } + if !ok { + return fmt.Errorf("member %q not found", name) + } + teamFile := filepath.Join(ResolveDir(projectRoot), "team.md") + raw, err := os.ReadFile(teamFile) + if err != nil { + return err + } + next, err := setMemberModelCell(string(raw), found, stored) + if err != nil { + return err + } + return os.WriteFile(teamFile, []byte(next), 0o644) +} + +// SetSquadModel writes the Coordinator Squad Model cell. Empty or "-" clears it. +// Missing Model column is promoted first. +func SetSquadModel(projectRoot, model string) error { + if !IsInitialized(projectRoot) { + return fmt.Errorf("not initialized") + } + stored, err := ValidateModelID(model) + if err != nil { + return err + } + teamFile := filepath.Join(ResolveDir(projectRoot), "team.md") + raw, err := os.ReadFile(teamFile) + if err != nil { + return err + } + next, err := setSquadModelCell(string(raw), stored) + if err != nil { + return err + } + return os.WriteFile(teamFile, []byte(next), 0o644) +} + func memberID(name string) string { return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) } func appendMemberRow(content, name, role, id, model string) (string, error) { - row := fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active |", name, role, id) if model != "" { + var err error + content, err = ensureSectionModelColumn(content, reMembersHeading, false) + if err != nil { + return "", err + } + } + hasModel := sectionHeaderHasModel(content, reMembersHeading, false) + row := fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active |", name, role, id) + if hasModel { row = fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active | %s |", name, role, id, model) } lines := strings.Split(content, "\n") @@ -158,7 +255,14 @@ func appendMemberRow(content, name, role, id, model string) (string, error) { if lastTable >= 0 { return insertLine(lines, lastTable+1, row), nil } - block := "\n## Members\n\n| Name | Role | Charter | Status |\n|------|------|---------|--------|\n" + row + "\n" + header := "| Name | Role | Charter | Status |" + sep := "|------|------|---------|--------|" + if model != "" { + header = "| Name | Role | Charter | Status | Model |" + sep = "|------|------|---------|--------|-------|" + row = fmt.Sprintf("| %s | %s | `.squad/agents/%s/charter.md` | Active | %s |", name, role, id, model) + } + block := "\n## Members\n\n" + header + "\n" + sep + "\n" + row + "\n" return strings.TrimRight(content, "\n") + block, nil } @@ -209,6 +313,206 @@ func removeMemberRow(content string, member TeamMember) (string, error) { return "", fmt.Errorf("member %q not found", member.Name) } +func setMemberModelCell(content string, member TeamMember, model string) (string, error) { + lines := strings.Split(content, "\n") + headerIdx, sepIdx, dataIdx, cols := locateSectionTable(lines, reMembersHeading, false) + if headerIdx < 0 { + return "", fmt.Errorf("members table not found") + } + if _, ok := cols["model"]; !ok { + if model == "" { + return content, nil + } + cols, lines = promoteModelColumn(lines, headerIdx, sepIdx, dataIdx) + } + modelIdx, ok := cols["model"] + if !ok { + return "", fmt.Errorf("model column missing") + } + for _, i := range dataIdx { + cells := splitTableCells(strings.TrimSpace(strings.TrimSuffix(lines[i], "\r"))) + if rowMatchesMember(cells, member) { + lines[i] = setTableCell(lines[i], modelIdx, model) + return strings.Join(lines, "\n"), nil + } + } + return "", fmt.Errorf("member %q not found", member.Name) +} + +func setSquadModelCell(content string, model string) (string, error) { + if !reCoordinatorHeading.MatchString(content) { + return "", fmt.Errorf("coordinator table not found") + } + lines := strings.Split(content, "\n") + headerIdx, sepIdx, dataIdx, cols := locateSectionTable(lines, reCoordinatorHeading, true) + if headerIdx < 0 { + return "", fmt.Errorf("coordinator table not found") + } + if _, ok := cols["model"]; !ok { + if model == "" { + return content, nil + } + cols, lines = promoteModelColumn(lines, headerIdx, sepIdx, dataIdx) + } + modelIdx, ok := cols["model"] + if !ok { + return "", fmt.Errorf("model column missing") + } + chosen := -1 + for _, i := range dataIdx { + cells := splitTableCells(strings.TrimSpace(strings.TrimSuffix(lines[i], "\r"))) + if len(cells) > 0 && strings.EqualFold(cells[0], "squad") { + chosen = i + break + } + } + if chosen < 0 && len(dataIdx) == 1 { + chosen = dataIdx[0] + } + if chosen < 0 { + return "", fmt.Errorf("coordinator squad row not found") + } + lines[chosen] = setTableCell(lines[chosen], modelIdx, model) + return strings.Join(lines, "\n"), nil +} + +func ensureSectionModelColumn(content string, section *regexp.Regexp, requireHeading bool) (string, error) { + lines := strings.Split(content, "\n") + headerIdx, sepIdx, dataIdx, cols := locateSectionTable(lines, section, requireHeading) + if headerIdx < 0 { + return content, nil + } + if _, ok := cols["model"]; ok { + return content, nil + } + _, lines = promoteModelColumn(lines, headerIdx, sepIdx, dataIdx) + return strings.Join(lines, "\n"), nil +} + +func sectionHeaderHasModel(content string, section *regexp.Regexp, requireHeading bool) bool { + lines := strings.Split(content, "\n") + _, _, _, cols := locateSectionTable(lines, section, requireHeading) + if cols == nil { + return false + } + _, ok := cols["model"] + return ok +} + +func locateSectionTable(lines []string, section *regexp.Regexp, requireHeading bool) (headerIdx, sepIdx int, dataIdx []int, cols map[string]int) { + headerIdx, sepIdx = -1, -1 + hasHeading := false + if section != nil { + for _, line := range lines { + if section.MatchString(strings.TrimSpace(strings.TrimSuffix(line, "\r"))) { + hasHeading = true + break + } + } + } + inSection := section == nil || (!requireHeading && !hasHeading) + for i, line := range lines { + trim := strings.TrimSpace(strings.TrimSuffix(line, "\r")) + if section != nil && section.MatchString(trim) { + inSection = true + headerIdx, sepIdx = -1, -1 + dataIdx = nil + cols = nil + continue + } + if hasHeading && reAnyHeading.MatchString(trim) && (section == nil || !section.MatchString(trim)) { + inSection = false + continue + } + if !inSection || !strings.HasPrefix(trim, "|") { + continue + } + if reTableSep.MatchString(trim) { + if headerIdx >= 0 && sepIdx < 0 { + sepIdx = i + } + continue + } + cells := splitTableCells(trim) + if len(cells) == 0 { + continue + } + if strings.EqualFold(cells[0], "name") || reNameHeader.MatchString(trim) { + headerIdx = i + cols = headerColumns(cells) + dataIdx = nil + continue + } + if headerIdx >= 0 { + dataIdx = append(dataIdx, i) + } + } + return +} + +func promoteModelColumn(lines []string, headerIdx, sepIdx int, dataIdx []int) (map[string]int, []string) { + if headerIdx >= 0 { + lines[headerIdx] = appendTableCell(lines[headerIdx], "Model", false) + } + if sepIdx >= 0 { + lines[sepIdx] = appendTableCell(lines[sepIdx], "-------", true) + } + for _, i := range dataIdx { + lines[i] = appendTableCell(lines[i], "", false) + } + var cols map[string]int + if headerIdx >= 0 { + trim := strings.TrimSpace(strings.TrimSuffix(lines[headerIdx], "\r")) + cols = headerColumns(splitTableCells(trim)) + } + return cols, lines +} + +func appendTableCell(line, cell string, sep bool) string { + eol := "" + if strings.HasSuffix(line, "\r") { + eol = "\r" + line = strings.TrimSuffix(line, "\r") + } + s := strings.TrimRight(line, " \t") + if !strings.HasSuffix(s, "|") { + s += " |" + } + if sep { + if cell == "" { + cell = "-------" + } + return s + cell + "|" + eol + } + if cell == "" { + return s + " |" + eol + } + return s + " " + cell + " |" + eol +} + +func setTableCell(line string, idx int, value string) string { + eol := "" + if strings.HasSuffix(line, "\r") { + eol = "\r" + line = strings.TrimSuffix(line, "\r") + } + cells := splitTableCells(line) + for len(cells) <= idx { + cells = append(cells, "") + } + cells[idx] = value + return "| " + strings.Join(cells, " | ") + " |" + eol +} + +func rowMatchesMember(cells []string, member TeamMember) bool { + if len(cells) == 0 { + return false + } + name := cells[0] + rowID := memberIDFromRow(name, cells) + return rowID == member.ID || strings.EqualFold(name, member.Name) +} + func defaultCharter(name, role, id string) string { return fmt.Sprintf(`# %s diff --git a/internal/squad/recast_test.go b/internal/squad/recast_test.go index 4bc36d4..5e3c620 100644 --- a/internal/squad/recast_test.go +++ b/internal/squad/recast_test.go @@ -360,3 +360,129 @@ func TestUpgradeThenRecastRestoresSquadModel(t *testing.T) { t.Fatalf("upgrade wiped orchestrator model:\n%s", got) } } + +func TestSetMemberModelPromotesColumn(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + if err := SetMemberModel(root, "Lead", "anthropic/claude-sonnet-4-5"); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(ResolveDir(root), "team.md")) + if !strings.Contains(string(raw), "Model") || !strings.Contains(string(raw), "anthropic/claude-sonnet-4-5") { + t.Fatalf("%s", raw) + } + members, err := ReadTeam(root) + if err != nil { + t.Fatal(err) + } + var lead TeamMember + for _, m := range members { + if m.ID == "lead" { + lead = m + } + } + if lead.Model != "anthropic/claude-sonnet-4-5" { + t.Fatalf("%+v", members) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "lead.md")) + if !strings.Contains(string(got), "model: anthropic/claude-sonnet-4-5") { + t.Fatal(string(got)) + } +} + +func TestSetSquadModelAndClear(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + if err := SetSquadModel(root, "xai/grok-3"); err != nil { + t.Fatal(err) + } + if ReadSquadModelMust(t, root) != "xai/grok-3" { + t.Fatal("set") + } + if err := SetSquadModel(root, ""); err != nil { + t.Fatal(err) + } + if ReadSquadModelMust(t, root) != "" { + t.Fatal("clear") + } +} + +func TestAddMemberWithModel(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + if err := AddMember(root, "Designer", "Design", "opencode/gpt-5.1-codex"); err != nil { + t.Fatal(err) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "designer.md")) + if !strings.Contains(string(got), "model: opencode/gpt-5.1-codex") { + t.Fatal(string(got)) + } +} + +func TestApplyThemeKeepsModelCell(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + if err := SetMemberModel(root, "Lead", "anthropic/claude-sonnet-4-5"); err != nil { + t.Fatal(err) + } + if err := ApplyTheme(root, "office"); err != nil { + t.Fatal(err) + } + members, _ := ReadTeam(root) + found := false + for _, m := range members { + if m.ID == "lead" && m.Model == "anthropic/claude-sonnet-4-5" { + found = true + } + } + if !found { + t.Fatalf("theme wiped model: %+v", members) + } +} + +func ReadSquadModelMust(t *testing.T, root string) string { + t.Helper() + s, err := ReadSquadModel(root) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestRecastThemedHostStillInjects(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root, Theme: "office"}); err != nil { + t.Fatal(err) + } + // birth office: memory+host id is michael. Set lead/michael model via team.md Model column after parse. + if err := SetMemberModel(root, "Michael", "anthropic/claude-sonnet-4-5"); err != nil { + t.Fatal(err) + } + if _, err := Recast(root); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(OpencodeAgentsDir(root), "michael.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "model: anthropic/claude-sonnet-4-5") { + t.Fatalf("%s", got) + } + if !strings.Contains(string(got), "You are **Lead**") { + t.Fatalf("stock prompt lost:\n%s", got) + } +} From dbc8fb7ccb6b03da68e9cdf7be2a8501c1ad5bb0 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:39:20 +0530 Subject: [PATCH 4/6] feat: status Model line and cast --model CLI Status prints Model after host/preset and effective member models. cast --add --model and cast --model name id recast after mutate. --- internal/cli/cli.go | 75 +++++++++++++++++++++++++++++++---- internal/cli/cli_test.go | 41 +++++++++++++++++++ internal/squad/status.go | 19 ++++++++- internal/squad/status_test.go | 33 +++++++++++++++ 4 files changed, 159 insertions(+), 9 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8a595b4..bdcff1c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -103,9 +103,12 @@ Commands: upgrade [--dry-run] [--force] [--global] [--self] doctor | heartbeat status | cast - cast --add [--role ] + cast --add [--role ] [--model ] cast --remove cast --theme office|none + cast --model + cast --model squad + cast --model - recast run -p | --file [--agent name] [--url] watch | triage | loop [--execute] [--interval minutes] [--once] [--health] [--url] @@ -260,7 +263,14 @@ func cmdStatus() int { } func cmdCast(args []string) int { - var add, role, remove, theme string + var add, role, remove, theme, model, modelName string + addMode := false + for _, a := range args { + if a == "--add" { + addMode = true + break + } + } for i := 0; i < len(args); i++ { a := args[i] switch { @@ -279,6 +289,24 @@ func cmdCast(args []string) int { case a == "--theme": fmt.Fprintln(os.Stderr, "cast --theme requires office or none") return 2 + case a == "--model": + if addMode { + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cast --model requires a model id") + return 2 + } + i++ + model = args[i] + } else { + if i+2 >= len(args) { + fmt.Fprintln(os.Stderr, "cast --model requires ") + return 2 + } + i++ + modelName = args[i] + i++ + model = args[i] + } case a == "--help" || a == "-h": printHelp() return 0 @@ -287,7 +315,8 @@ func cmdCast(args []string) int { return 2 } } - if theme != "" && (add != "" || remove != "") { + haveModel := model != "" || modelName != "" + if theme != "" && (add != "" || remove != "" || haveModel) { fmt.Fprintln(os.Stderr, "cast: use --theme alone") return 2 } @@ -295,12 +324,22 @@ func cmdCast(args []string) int { fmt.Fprintln(os.Stderr, "cast: use --add or --remove, not both") return 2 } + if haveModel && remove != "" { + fmt.Fprintln(os.Stderr, "cast: use --model or --remove, not both") + return 2 + } if theme != "" { return cmdCastTheme(theme) } - if add == "" && remove == "" { + if add == "" && remove == "" && !haveModel { return cmdStatus() } + if haveModel { + if _, err := squad.ValidateModelID(model); err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } + } root, code := cwd() if code != 0 { return code @@ -318,16 +357,36 @@ func cmdCast(args []string) int { fmt.Printf("removed %s; recast %d agent file(s)\n", remove, res.Written) return 0 } - if err := squad.AddMember(root, add, role, ""); err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 + if add != "" { + if err := squad.AddMember(root, add, role, model); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + res, err := squad.Recast(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Printf("added %s; recast %d agent file(s)\n", add, res.Written) + return 0 + } + if strings.EqualFold(modelName, "squad") { + if err := squad.SetSquadModel(root, model); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } else { + if err := squad.SetMemberModel(root, modelName, model); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } } res, err := squad.Recast(root) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } - fmt.Printf("added %s; recast %d agent file(s)\n", add, res.Written) + fmt.Printf("model %s; recast %d agent file(s)\n", modelName, res.Written) return 0 } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0663e0d..2b43349 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -400,6 +400,47 @@ func TestCastThemeNoneAfterInitOffice(t *testing.T) { } } +func TestCastModelFlags(t *testing.T) { + root := t.TempDir() + prev, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prev) }) + if Execute([]string{"init", "--preset", "default", "--description", "models"}) != 0 { + t.Fatal("init") + } + if Execute([]string{"cast", "--model", "squad", "xai/grok-3"}) != 0 { + t.Fatal("set squad") + } + if Execute([]string{"cast", "--add", "Designer", "--role", "Design", "--model", "opencode/gpt-5.1-codex"}) != 0 { + t.Fatal("add") + } + got, _ := os.ReadFile(filepath.Join(root, ".opencode", "agents", "designer.md")) + if !strings.Contains(string(got), "model: opencode/gpt-5.1-codex") { + t.Fatal(string(got)) + } + if Execute([]string{"cast", "--model", "Designer", "-"}) != 0 { + t.Fatal("clear") + } + got, _ = os.ReadFile(filepath.Join(root, ".opencode", "agents", "designer.md")) + if !strings.Contains(string(got), "model: xai/grok-3") { + t.Fatalf("should inherit squad after clear:\n%s", got) + } + if Execute([]string{"cast", "--model", "Lead", "nonesuch"}) != 2 { + t.Fatal("missing slash must be usage 2") + } + if Execute([]string{"cast", "--model"}) != 2 { + t.Fatal("missing args") + } + if Execute([]string{"cast", "--theme", "office", "--model", "Lead", "xai/grok-3"}) != 2 { + t.Fatal("exclusive") + } + if Execute([]string{"cast", "--remove", "Designer", "--model", "Lead", "xai/grok-3"}) != 2 { + t.Fatal("exclusive remove") + } +} + func TestRecastListsHostAgentIDs(t *testing.T) { root := t.TempDir() prev, _ := os.Getwd() diff --git a/internal/squad/status.go b/internal/squad/status.go index ebd015f..2b0d289 100644 --- a/internal/squad/status.go +++ b/internal/squad/status.go @@ -18,6 +18,10 @@ func StatusReport(root string) (string, error) { if err != nil { return "", err } + squadModel, err := ReadSquadModel(root) + if err != nil { + return "", err + } var b strings.Builder fmt.Fprintf(&b, "Squad status — %s\n", root) @@ -47,6 +51,11 @@ func StatusReport(root string) (string, error) { } } } + if squadModel != "" { + fmt.Fprintf(&b, "Model: %s\n", squadModel) + } else { + fmt.Fprintf(&b, "Model: (session)\n") + } b.WriteByte('\n') if len(members) == 0 { b.WriteString("(no members parsed from team.md)\n") @@ -59,7 +68,15 @@ func StatusReport(root string) (string, error) { } fmt.Fprintf(&b, "%-*s Role\n%s ----\n", width, "Name", strings.Repeat("-", width)) for _, m := range members { - fmt.Fprintf(&b, "%-*s %s [%s]\n", width, m.Name, m.Role, m.Status) + effective := EffectiveModel(m.Model, squadModel) + switch { + case m.Model == "" && squadModel != "": + fmt.Fprintf(&b, "%-*s %s [%s] %s (team)\n", width, m.Name, m.Role, m.Status, effective) + case m.Model == "" && squadModel == "": + fmt.Fprintf(&b, "%-*s %s [%s] (session)\n", width, m.Name, m.Role, m.Status) + default: + fmt.Fprintf(&b, "%-*s %s [%s] %s\n", width, m.Name, m.Role, m.Status, effective) + } } } diff --git a/internal/squad/status_test.go b/internal/squad/status_test.go index b841699..ee3f92a 100644 --- a/internal/squad/status_test.go +++ b/internal/squad/status_test.go @@ -137,3 +137,36 @@ func TestStatusReportRemoteLinkSHAWithoutRef(t *testing.T) { t.Fatalf("expected SHA when LinkRef is empty:\n%s", got) } } + +func TestStatusReportModels(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + out, err := StatusReport(root) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Model: (session)") { + t.Fatalf("empty orchestrator:\n%s", out) + } + if err := SetSquadModel(root, "xai/grok-3"); err != nil { + t.Fatal(err) + } + if err := SetMemberModel(root, "Lead", "anthropic/claude-sonnet-4-5"); err != nil { + t.Fatal(err) + } + out, err = StatusReport(root) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Model: xai/grok-3") { + t.Fatalf("orchestrator:\n%s", out) + } + if !strings.Contains(out, "anthropic/claude-sonnet-4-5") { + t.Fatalf("override:\n%s", out) + } + if !strings.Contains(out, "xai/grok-3 (team)") && !strings.Contains(out, "xai/grok-3 (team)") { + t.Fatalf("inherit marker:\n%s", out) + } +} From 911334b9c2044793426cd13008bf7a0ba624eee2 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:48:07 +0530 Subject: [PATCH 5/6] feat: init template Model column and docs --- README.md | 5 ++++- docs/get-started.md | 2 ++ docs/use-cases.md | 8 ++++++++ internal/squad/init_test.go | 17 +++++++++++++++++ internal/squad/templates/squad/team.md | 18 +++++++++--------- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c97c471..742d39c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,10 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in | `upgrade [--dry-run] [--force] [--global] [--self]` | Refresh host templates; `--self` replaces this binary from GitHub Releases | | `doctor` / `heartbeat` | Health checks | | `status` / `cast` | Team table | -| `cast --add [--role ]` | Add a member and regenerate `.opencode/agents` | +| `cast --add [--role ] [--model ]` | Add a member and regenerate `.opencode/agents` | +| `cast --model ` | Pin a member to an OpenCode model id | +| `cast --model squad ` | Pin the coordinator model | +| `cast --model -` | Clear a pin (inherit Squad, then session) | | `cast --remove ` | Remove a member and regenerate `.opencode/agents` | | `cast --theme office` / `none` | `init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) | | `recast` | Regenerate `.opencode/agents` from `.squad/team.md` | diff --git a/docs/get-started.md b/docs/get-started.md index be416c7..8f52b19 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -53,6 +53,8 @@ Inside OpenCode: 3. Paste your API key 4. Quit when done +Optional: pin a seat to an OpenCode id (`provider/model-id`) with `squad-oc cast --model squad xai/grok-3` or `cast --add NAME --model anthropic/claude-sonnet-4-5`. Empty cells inherit the Squad model, then the session default. This does not write `opencode.json`. + --- ## 3. Create a project diff --git a/docs/use-cases.md b/docs/use-cases.md index 537a36e..c9e3aae 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -47,6 +47,14 @@ Today’s stand-ins: | Shared team + decisions | `squad-oc link ` | still `link` (`mcp apply` reads the linked team dir) | | Memory | `.squad/decisions.md` | still files — not `squad_state` | +### OpenCode-native (not original Squad) + +Not a Copilot port and not an original-Squad ease mapping. + +| Capability | squad-oc | Status | +|------------|----------|--------| +| Per-agent model | `cast --model` / `cast --add --model`; empty `team.md` Model cells inherit Squad, then session | **shipped** | + --- ## Won’t port diff --git a/internal/squad/init_test.go b/internal/squad/init_test.go index a936f66..228165b 100644 --- a/internal/squad/init_test.go +++ b/internal/squad/init_test.go @@ -244,6 +244,23 @@ func TestReadTeamAfterInit(t *testing.T) { } } +func TestInitTeamMarkdownHasModelColumn(t *testing.T) { + root := t.TempDir() + if _, err := WriteDefaultPreset(InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(ResolveDir(root), "team.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Model") { + t.Fatalf("new team.md should include Model column:\n%s", raw) + } + if ParseSquadModel(string(raw)) != "" { + t.Fatal("default model must be empty") + } +} + func isolateHome(t *testing.T) string { t.Helper() home := t.TempDir() diff --git a/internal/squad/templates/squad/team.md b/internal/squad/templates/squad/team.md index 37118d5..edab12f 100644 --- a/internal/squad/templates/squad/team.md +++ b/internal/squad/templates/squad/team.md @@ -5,18 +5,18 @@ ## Coordinator -| Name | Role | Notes | -|------|------|-------| -| Squad | Coordinator | Routes work, enforces handoffs. Does not own domain implementation alone. | +| Name | Role | Model | Notes | +|------|------|-------|-------| +| Squad | Coordinator | | Routes work, enforces handoffs. Does not own domain implementation alone. | ## Members -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Lead | Lead | `.squad/agents/lead/charter.md` | Active | -| Frontend | Frontend | `.squad/agents/frontend/charter.md` | Active | -| Backend | Backend | `.squad/agents/backend/charter.md` | Active | -| Tester | Tester | `.squad/agents/tester/charter.md` | Active | +| Name | Role | Charter | Status | Model | +|------|------|---------|--------|-------| +| Lead | Lead | `.squad/agents/lead/charter.md` | Active | | +| Frontend | Frontend | `.squad/agents/frontend/charter.md` | Active | | +| Backend | Backend | `.squad/agents/backend/charter.md` | Active | | +| Tester | Tester | `.squad/agents/tester/charter.md` | Active | | ## How to work From 4b98b66cbc3ad43c6247841678828b7b50fa93e4 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Tue, 25 Aug 2026 19:57:30 +0530 Subject: [PATCH 6/6] feat: add live-models.ps1 dummy per-agent model serve gate --- scripts/live-models.ps1 | 292 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 scripts/live-models.ps1 diff --git a/scripts/live-models.ps1 b/scripts/live-models.ps1 new file mode 100644 index 0000000..c88ca39 --- /dev/null +++ b/scripts/live-models.ps1 @@ -0,0 +1,292 @@ +# cwd: repo root. Uses D:\xAI\squad-oc-dummy only (never this git repo). +# Builds squad-oc.exe, pins per-agent models, starts or attaches to +# opencode serve on 127.0.0.1:4096, asserts host files + GET /config. +# Leaves serve running. Does not restore dummy model pins. +# Does not change live-e2e.ps1 (PONG check stays put). + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Dummy = "D:\xAI\squad-oc-dummy" +$BaseURL = "http://127.0.0.1:4096" +$Log = Join-Path $Dummy "live-models.log" +$MainDumpDir = "D:\xAI\squad-opencode\.playwright-mcp" +$MainDump = Join-Path $MainDumpDir "dummy-per-agent-model-config.json" +$WorktreeDumpDir = Join-Path $RepoRoot ".playwright-mcp" +$WorktreeDump = Join-Path $WorktreeDumpDir "dummy-per-agent-model-config.json" + +$SquadModel = "xai/grok-3" +$LeadModel = "anthropic/claude-sonnet-4-5" + +$startedServe = $false +$serveProc = $null + +function Write-Log { + param([string]$Message) + $line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"), $Message + Write-Host $line + $parent = Split-Path -Parent $Log + if (Test-Path $parent) { + Add-Content -Path $Log -Value $line + } +} + +function Get-Port4096Listeners { + Get-NetTCPConnection -LocalPort 4096 -State Listen -ErrorAction SilentlyContinue +} + +function Assert-Port4096Safe { + $listeners = @(Get-Port4096Listeners) + if ($listeners.Count -eq 0) { + return $false + } + $ok = @("127.0.0.1", "::1", "localhost") + $bad = @($listeners | Where-Object { $ok -notcontains $_.LocalAddress }) + if ($bad.Count -gt 0) { + $addrs = ($bad | ForEach-Object { $_.LocalAddress } | Sort-Object -Unique) -join ", " + throw "port 4096 is bound on non-localhost ($addrs); refusing to steal another server" + } + return $true +} + +function Wait-ServeReady { + param([int]$Seconds = 20) + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + try { + $r = Invoke-WebRequest -Uri "$BaseURL/global/health" -UseBasicParsing -TimeoutSec 2 + if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { + return + } + } catch { + Start-Sleep -Milliseconds 250 + } + } + throw "opencode serve at $BaseURL never became ready" +} + +function Invoke-SquadOc { + param( + [string]$Exe, + [string[]]$CommandArgs + ) + Write-Log ("squad-oc " + ($CommandArgs -join " ")) + & $Exe @CommandArgs + if ($LASTEXITCODE -ne 0) { + throw "squad-oc $($CommandArgs[0]) exited $LASTEXITCODE" + } +} + +function Get-OpencodeExe { + $cmd = Get-Command opencode -ErrorAction SilentlyContinue + if (-not $cmd) { + throw "opencode not on PATH" + } + $src = $cmd.Source + if ($src -like "*.ps1") { + $exe = Join-Path (Split-Path $src) "node_modules\opencode-ai\bin\opencode.exe" + if (Test-Path $exe) { + return $exe + } + } + return $src +} + +function Resolve-HostAgentFile { + param([string]$RoleId) + $agents = Join-Path $Dummy ".opencode\agents" + $candidates = @(Join-Path $agents "$RoleId.md") + $office = @{ + lead = "michael" + frontend = "jim" + backend = "dwight" + tester = "pam" + } + if ($office.ContainsKey($RoleId)) { + $candidates += (Join-Path $agents ($office[$RoleId] + ".md")) + } + foreach ($p in $candidates) { + if (Test-Path $p) { + return $p + } + } + throw "host agent file not found for $RoleId (tried: $($candidates -join ', '))" +} + +function Assert-FileContains { + param( + [string]$Path, + [string]$Needle, + [string]$Label + ) + $text = Get-Content -Raw -Path $Path + if ($text -notlike "*$Needle*") { + throw "$Label missing '$Needle' in $Path" + } + Write-Log ("PASS file: {0} has '{1}'" -f $Label, $Needle) +} + +function Invoke-ServeGet { + param([string]$Url) + try { + $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 10 + return [ordered]@{ + url = $Url + status = [int]$r.StatusCode + body = $r.Content + } + } catch { + $status = 0 + $resp = $_.Exception.Response + if ($null -ne $resp -and $null -ne $resp.StatusCode) { + $status = [int]$resp.StatusCode + } + return [ordered]@{ + url = $Url + status = $status + body = $null + error = $_.Exception.Message + } + } +} + +try { + Set-Location $RepoRoot + + if (-not (Test-Path $Dummy)) { + throw "dummy missing: $Dummy (never use this git clone; never squad-oc-dummy-v2)" + } + if (Test-Path $Log) { + Remove-Item $Log -Force + } + Write-Log "repo=$RepoRoot dummy=$Dummy" + + Write-Log "go build -o squad-oc.exe ./cmd/squad-oc" + go build -o squad-oc.exe ./cmd/squad-oc + if ($LASTEXITCODE -ne 0) { + throw "go build failed" + } + $exe = Join-Path $RepoRoot "squad-oc.exe" + if (-not (Test-Path $exe)) { + throw "squad-oc.exe missing after build" + } + + $prev = Get-Location + Set-Location $Dummy + try { + if (-not (Test-Path (Join-Path $Dummy ".squad\config.json"))) { + Invoke-SquadOc -Exe $exe -CommandArgs @("init", "--preset", "default", "--description", "dummy") + } else { + Write-Log "already initialized" + } + + Invoke-SquadOc -Exe $exe -CommandArgs @("recast") + Invoke-SquadOc -Exe $exe -CommandArgs @("cast", "--model", "squad", $SquadModel) + Invoke-SquadOc -Exe $exe -CommandArgs @("cast", "--model", "Lead", $LeadModel) + Invoke-SquadOc -Exe $exe -CommandArgs @("cast", "--model", "Tester", "-") + Invoke-SquadOc -Exe $exe -CommandArgs @("status") + + $squadFile = Join-Path $Dummy ".opencode\agents\squad.md" + if (-not (Test-Path $squadFile)) { + throw "missing $squadFile" + } + Assert-FileContains -Path $squadFile -Needle "model: $SquadModel" -Label "squad.md" + Assert-FileContains -Path $squadFile -Needle "You are **Squad**" -Label "squad.md" + + $leadFile = Resolve-HostAgentFile -RoleId "lead" + Write-Log "Lead host file: $leadFile" + Assert-FileContains -Path $leadFile -Needle "model: $LeadModel" -Label (Split-Path $leadFile -Leaf) + + $testerFile = Resolve-HostAgentFile -RoleId "tester" + Write-Log "Tester host file: $testerFile" + Assert-FileContains -Path $testerFile -Needle "model: $SquadModel" -Label (Split-Path $testerFile -Leaf) + + $opencodeExe = Get-OpencodeExe + Write-Log "opencode=$opencodeExe" + + if (Assert-Port4096Safe) { + Write-Log "attaching to existing localhost:4096" + } else { + Write-Log "starting opencode serve --hostname 127.0.0.1 --port 4096" + $serveProc = Start-Process -FilePath $opencodeExe -ArgumentList @("serve", "--hostname", "127.0.0.1", "--port", "4096") -WorkingDirectory $Dummy -PassThru -WindowStyle Hidden + $startedServe = $true + Write-Log "started serve pid=$($serveProc.Id) (leaving it running)" + } + Wait-ServeReady + + $health = Invoke-ServeGet "$BaseURL/global/health" + Write-Log ("GET /global/health status={0}" -f $health.status) + if ($health.status -lt 200 -or $health.status -ge 500) { + throw "GET /global/health failed: $($health.error)" + } + + $dirBack = [uri]::EscapeDataString($Dummy) + $dirFwd = [uri]::EscapeDataString(($Dummy -replace '\\', '/')) + $configUrls = @( + "$BaseURL/config?directory=$dirBack", + "$BaseURL/config?directory=$dirFwd", + "$BaseURL/config", + "$BaseURL/project", + "$BaseURL/agent?directory=$dirBack", + "$BaseURL/agent?directory=$dirFwd", + "$BaseURL/agent" + ) + + $probes = @() + $chosen = $null + foreach ($url in $configUrls) { + $got = Invoke-ServeGet $url + $probes += $got + $preview = "" + if ($got.body) { + $preview = $got.body.Substring(0, [Math]::Min(80, $got.body.Length)) + } + Write-Log ("GET {0} status={1} preview={2}" -f $url, $got.status, $preview) + if ($null -eq $chosen -and $got.status -ge 200 -and $got.status -lt 300 -and $got.body) { + $chosen = $got + } + } + + $record = [ordered]@{ + dummy = $Dummy + startedServe = $startedServe + health = $health + chosenUrl = if ($chosen) { $chosen.url } else { $null } + probes = $probes + } + $json = $record | ConvertTo-Json -Depth 12 + foreach ($dir in @($MainDumpDir, $WorktreeDumpDir)) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir | Out-Null + } + } + Set-Content -Path $MainDump -Value $json -Encoding utf8 + Set-Content -Path $WorktreeDump -Value $json -Encoding utf8 + Write-Log "wrote $MainDump" + Write-Log "wrote $WorktreeDump" + + $haystack = $json + if ($haystack -notlike "*$SquadModel*") { + throw "serve JSON missing '$SquadModel' (see $MainDump)" + } + if ($haystack -notlike "*$LeadModel*") { + throw "serve JSON missing '$LeadModel' (see $MainDump)" + } + Write-Log "PASS serve API: JSON contains $SquadModel and $LeadModel" + + Write-Log "TUI/Playwright skipped (serve JSON is the required artifact)" + } finally { + Set-Location $prev + } + + if ($startedServe) { + Write-Log "leaving serve running (pid=$($serveProc.Id))" + } else { + Write-Log "left existing serve running" + } + Write-Log "live-models ok" + exit 0 +} catch { + Write-Log ("FAIL: " + $_.Exception.Message) + throw +}