Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Never report a step as done before `mise run check` has passed, and say which ga

- Repo: [`codedogapp/baton`](https://github.com/codedogapp/baton), public. Default branch `main`.
- One step, one branch (`step/<id>`, ids from plan §0) and one PR. The lead squash-merges once CI is green, with an explicit subject and body: `gh pr merge --squash --subject "…" --body "…"`.
- Renovate's pull requests: the lead squash-merges a green one once its release notes show no breaking change and its pins match their tags, and asks the user about a red one or a major update (the user's OK of 2026-11-03).
- Commit messages and PR titles follow Conventional Commits (`feat(flow): …`, `fix: …`, `build: …`, `ci: …`, `docs: …`): an imperative subject of 72 characters or fewer, and a body that says why when it isn't obvious.
- **No AI attribution.** Never add `Co-authored-by` trailers for Copilot or any other AI agent.
- Never write close, fix or resolve next to an issue or PR number unless the PR is meant to close it.
Expand Down
20 changes: 15 additions & 5 deletions docs/plan.md

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions internal/config/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package config

import (
"errors"
"fmt"
"maps"
"net/url"
"slices"
"strings"

"github.com/codedogapp/baton/internal/flow"
)

// efforts are the reasoning efforts that copilot takes (fact 31).
var efforts = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}

// minCredits is the least AI credits cap that copilot takes (fact 10).
const minCredits = 30

// check returns an error for the first setting in the file that baton can't use, with the repos'
// sections in the order of their keys.
func check(top Settings, repos map[string]Settings) error {
if err := top.check(); err != nil {
return err
}

for _, name := range slices.Sorted(maps.Keys(repos)) {
switch {
case name == "":
return errors.New("repos: an empty key: want a remote's URL")
case key(name) != name:
return fmt.Errorf("repos: %q: want %q, the URL's host and path", name, key(name))
}

if err := repos[name].check(); err != nil {
return fmt.Errorf("repos: %s: %w", name, err)
}
}

return nil
}

// check returns an error for the first setting that baton can't use, with the roles in the order
// of their names.
func (s Settings) check() error {
if s.RoundCap < 0 {
return fmt.Errorf("roundCap %d: want 1 or more", s.RoundCap)
}

for _, role := range slices.Sorted(maps.Keys(s.Roles)) {
if err := s.Roles[role].check(role); err != nil {
return fmt.Errorf("roles: %s: %w", role, err)
}
}

return nil
}

// check returns an error for the first of the role's settings that baton can't use. A value that
// starts with a dash would read as one of copilot's flags.
func (r Role) check(role flow.Role) error {
switch {
case !role.Known():
return errors.New("want lead, engineer, qa, reviewer or orchestrator")
case strings.HasPrefix(r.Model, "-"):
return fmt.Errorf("model %q: want a model's name", r.Model)
case r.ReasoningEffort != "" && !slices.Contains(efforts, r.ReasoningEffort):
return fmt.Errorf("reasoningEffort %q: want one of %s", r.ReasoningEffort,
strings.Join(efforts, ", "))
case r.MaxAICredits != 0 && r.MaxAICredits < minCredits:
return fmt.Errorf("maxAiCredits %g: want %d or more, or 0 for no cap", r.MaxAICredits,
minCredits)
}

return checkTools(role, r.ExtraTools)
}

// checkTools returns an error for extra tools that the role can't have: only the orchestrator gets
// tools of the user's (decision 10).
func checkTools(role flow.Role, tools []string) error {
if tools != nil && role != flow.RoleOrchestrator {
return errors.New("extraTools: only the orchestrator gets tools of the user's")
}

for _, tool := range tools {
if tool == "" || strings.HasPrefix(tool, "-") {
return fmt.Errorf("extraTools: %q: want a tool's name", tool)
}
}

return nil
}

// key returns a repo's key in the config file, from its remote's URL: the URL's host and path,
// without its scheme, user and port, or a last slash or .git, so that a repo's HTTPS and SSH URLs
// give the same key, such as github.com/codedogapp/baton. A local path stays a path.
func key(remote string) string {
name := remote

if parsed, err := url.Parse(remote); err == nil && strings.Contains(remote, "://") {
name = parsed.Hostname() + parsed.Path
} else if host, path, ok := strings.Cut(remote, ":"); ok && !strings.Contains(host, "/") {
// git reads [user@]host:path as an SSH URL, when no slash comes before the colon.
if _, after, found := strings.CutLast(host, "@"); found {
host = after
}

name = host + "/" + strings.TrimLeft(path, "/")
}

return strings.TrimSuffix(strings.TrimSuffix(name, "/"), ".git")
}
135 changes: 135 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Package config reads baton's config file, config.json in baton's folder in the user's config
// folder: the settings for every repo, and a section for each repo, keyed by its remote's URL, so
// that nothing goes into a work repo (decision 16).
package config

import (
"cmp"
"encoding/json/v2"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"

"github.com/codedogapp/baton/internal/flow"
)

// The config file's folder in the user's config folder, and its name there.
const (
folder = "baton"
file = "config.json"
)

// baton's own settings, where the config file gives none (decisions 7, 13 and 41).
const (
model = "claude-opus-5.5"
roundCap = 3
branchPrefix = "feature/"
)

// Settings are baton's settings for a repo. A setting that a layer leaves out, or gives its zero
// value, keeps the one below it: the repo's section lies over the file's settings for every repo,
// and those over baton's own (decision 49).
type Settings struct {
// Roles holds each role's settings for its copilot.
Roles map[flow.Role]Role `json:"roles"`
// RoundCap is the engineer's passes that a brief gets (decision 13).
RoundCap int `json:"roundCap"`
// BranchPrefix comes before the slug in the name of a feature's branch (decision 41).
BranchPrefix string `json:"branchPrefix"`
}

// Role is a role's settings for its copilot, each one a flag of copilot's (fact 31).
type Role struct {
Model string `json:"model"`
// ReasoningEffort is empty for copilot's own.
ReasoningEffort string `json:"reasoningEffort"`
// MaxAICredits caps the AI credits that the role's session spends: 30 or more, or 0 for no
// cap (fact 10).
MaxAICredits float64 `json:"maxAiCredits"`
// ExtraTools are the orchestrator's tools besides baton's, the user's own, such as nudge's
// for when the user is away. A nil list keeps the one below it, and an empty one gives none.
ExtraTools []string `json:"extraTools"`
}

// defaults returns baton's own settings: every role on the same model, and the orchestrator with
// the user's nudge tools (decision 45).
func defaults() Settings {
roles := map[flow.Role]Role{flow.RoleOrchestrator: {
Model: model, ExtraTools: []string{"nudge-ask", "nudge-report"},
}}

for _, role := range []flow.Role{
flow.RoleLead, flow.RoleEngineer, flow.RoleQA, flow.RoleReviewer,
} {
roles[role] = Role{Model: model}
}

return Settings{Roles: roles, RoundCap: roundCap, BranchPrefix: branchPrefix}
}

// Load reads the config file in the user's config folder, and returns the settings for the repo
// whose remote has the URL. A missing file gives baton's own. It checks the whole file, the other
// repos' sections too, so that a mistake shows at once.
func Load(configDir, remote string) (Settings, error) {
path := filepath.Join(configDir, folder, file)

data, err := os.ReadFile(filepath.Clean(path))
if errors.Is(err, fs.ErrNotExist) {
return defaults(), nil
}

if err != nil {
return Settings{}, fmt.Errorf("read the config file: %w", err)
}

var content layers

err = json.Unmarshal(data, &content, json.RejectUnknownMembers(true))
if err == nil {
err = check(content.Settings, content.Repos)
}

if err != nil {
return Settings{}, fmt.Errorf("config file %s: %w", path, err)
}

return defaults().with(content.Settings).with(content.Repos[key(remote)]), nil
}

// layers are the config file's content: its settings for every repo, and each repo's section, by
// the repo's key.
type layers struct {
Settings

Repos map[string]Settings `json:"repos"`
}

// with returns the settings with the layer over them. The settings below hold every role, and the
// layer, once checked, no other.
func (s Settings) with(layer Settings) Settings {
roles := make(map[flow.Role]Role, len(s.Roles))
for role, below := range s.Roles {
roles[role] = below.with(layer.Roles[role])
}

return Settings{
Roles: roles,
RoundCap: cmp.Or(layer.RoundCap, s.RoundCap),
BranchPrefix: cmp.Or(layer.BranchPrefix, s.BranchPrefix),
}
}

// with returns the role's settings with the layer over them.
func (r Role) with(layer Role) Role {
if layer.ExtraTools != nil {
r.ExtraTools = layer.ExtraTools
}

r.Model = cmp.Or(layer.Model, r.Model)
r.ReasoningEffort = cmp.Or(layer.ReasoningEffort, r.ReasoningEffort)
r.MaxAICredits = cmp.Or(layer.MaxAICredits, r.MaxAICredits)

return r
}
Loading
Loading