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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Hand a ticket to a team of Copilot CLI agents in tmux: lead, engineer, QA and reviewer pass the baton, and you get the final handover.

You talk to one agent, the orchestrator. It reads the ticket, plans the work as features and, once you approve the plan, starts each feature in its own git worktree and its own tmux session, with one visible, interactive Copilot CLI pane per role. The `baton` binary enforces the flow between the roles: who works next, the rounds of QA and review, and when to stop and ask you. Nothing leaves your Mac until you say so: the last handover is yours, and only your word pushes the branch and opens the pull request.
You talk to one agent, the orchestrator. It reads the ticket, plans the work as features and, once you approve the plan, starts each feature in its own git worktree and its own tmux session, with one visible, interactive Copilot CLI pane per role. The `baton` binary enforces the flow between the roles: who works next, that your repo's checks pass, the rounds of QA and review, and when to stop and ask you. Nothing leaves your Mac until you say so: the last handover is yours, and only your word pushes the branch and opens the pull request.

**Status**: in development; nothing to install yet. The design and the steps are in [docs/plan.md](docs/plan.md), and the rules for working on baton in [AGENTS.md](AGENTS.md).

Expand All @@ -14,17 +14,19 @@ You talk to one agent, the orchestrator. It reads the ticket, plans the work as

## Config

baton needs no config file. To change its settings, write `config.json` in `~/Library/Application Support/baton/`. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the host and path of the repo's `origin` remote, such as `github.com/codedogapp/baton` for `git@github.com:codedogapp/baton.git`. A repo's section lies over the top, one setting at a time.
baton needs a config file, `config.json` in `~/Library/Application Support/baton/`, for one setting: the gate that checks each repo's code. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the host and path of the repo's `origin` remote, such as `github.com/codedogapp/baton` for `git@github.com:codedogapp/baton.git`. A repo's section lies over the top, one setting at a time.

- `roles`: a section per role, `orchestrator`, `lead`, `engineer`, `qa` or `reviewer`, with its `model` (by default `claude-opus-5.5`), its `reasoningEffort` (`none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`; by default copilot's own), its `maxAiCredits` (30 or more; by default no cap) and, for the orchestrator alone, its `extraTools`: your own tools that it may use, by default nudge's `nudge-ask` and `nudge-report`.
- `roundCap`: the engineer's passes per brief before the orchestrator asks you (by default 3).
- `branchPrefix`: what goes before a feature's slug in its branch's name (by default `feature/`).
- `planApproval`: whether baton asks you before each feature starts, so that you can check the orchestrator's plan first (by default `true`).
- `gate`: the command that checks the repo's code, such as `mise run check`, with no default: baton starts neither the orchestrator nor a feature in a repo without one. The engineer's handover, QA's approval and the lead's final report run it with `/bin/sh -c` in the feature's worktree, and wait until it passes. `"true"` checks nothing.

For example, QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo:
For example, one gate and QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo:

```json
{
"gate": "mise run check",
"roles": {"qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 60}},
"repos": {"github.com/codedogapp/baton": {"roundCap": 2, "branchPrefix": "feat/"}}
}
Expand Down
19 changes: 11 additions & 8 deletions docs/plan.md

Large diffs are not rendered by default.

19 changes: 12 additions & 7 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import (

// skeleton makes the skeleton test's world, and returns its folder. The folder holds a bin folder,
// first on the PATH, with baton, a link to this test binary, and copilot, a link to the stand-in;
// a home folder and a temp folder of the test's own; the repo, with a commit, which is the test's
// working folder; and the orchestrator's brief. baton's tmux finds the test's own server, as tmux's
// default server, in a TMUX_TMPDIR of the test's own.
// a home folder of the test's own, with baton's config file, which gives the gate; a temp folder of
// the test's own; the repo, with a commit, which is the test's working folder; and the
// orchestrator's brief. baton's tmux finds the test's own server, as tmux's default server, in a
// TMUX_TMPDIR of the test's own.
func skeleton(t *testing.T) string {
t.Helper()

Expand Down Expand Up @@ -66,6 +67,8 @@ func skeleton(t *testing.T) string {
t.Setenv("TMUX_TMPDIR", folder(t, "/tmp", "baton-test-skeleton-"))
t.Chdir(repo)

configure(t)

serve(t)
ran(t, exec.CommandContext(t.Context(), "git", "init", "--quiet", "--initial-branch=main"))
ran(t, exec.CommandContext(t.Context(), "git", "commit", "--quiet", "--allow-empty",
Expand Down Expand Up @@ -257,10 +260,12 @@ func wantJournal(t *testing.T, feature string, want []logged) {
}
}

got = append(got, logged{
event: fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note),
note: string(note),
})
line := fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note)
if event.Gate != "" {
line += " (gate: " + event.Gate + ")"
}

got = append(got, logged{event: line, note: string(note)})
}

if !slices.Equal(got, want) {
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/ask_turn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
// clears the question, and ask the same one in its next turn: a new question, which waits for its
// own answer.
func TestAskInTheNextTurnAsksAnew(t *testing.T) {
withGate(t, "true")

synctest.Test(t, func(t *testing.T) {
mainDir, _ := repos(t)
store := started(t, mainDir, fresh())
Expand Down
87 changes: 87 additions & 0 deletions internal/cli/gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cli

import (
"context"
"fmt"
"os"

"github.com/codedogapp/baton/internal/config"
"github.com/codedogapp/baton/internal/flow"
"github.com/codedogapp/baton/internal/gate"
"github.com/codedogapp/baton/internal/state"
)

// handing is a role's handover: the feature, in its store, the move in its flow, and the event,
// with the note, that it adds to the feature's journal.
type handing struct {
store state.Store
slug string
move flow.Handover
entry state.Entry
}

// make makes the move, once the repo's gate passes in the feature's worktree if the move waits for
// it, and returns the feature as moved. It checks the move first, so that a move that the flow
// refuses runs no gate, and the store checks it again under the feature's lock, which the gate
// doesn't hold, since it can take minutes (decision 56).
func (h handing) make(ctx context.Context, env Env) (state.Feature, error) {
feature, err := h.store.Read(h.slug)
if err == nil {
err = moved(h.move)(&feature)
}

if err == nil && feature.Flow.Gated() {
h.entry.Event.Gate, err = runGate(ctx, env, feature)
}

if err != nil {
return state.Feature{}, err
}

return h.store.Update(h.slug, h.entry, moved(h.move))
}

// runGate runs the repo's gate in the feature's worktree, with its output on baton's, and returns
// the gate once it passes.
func runGate(ctx context.Context, env Env, feature state.Feature) (string, error) {
settings, err := gatedSettings(ctx, env.Dir)
if err == nil && feature.Worktree == "" {
err = fmt.Errorf("feature %s names no worktree, where its gate runs", feature.Slug)
}

if err != nil {
return "", err
}

command := settings.Gate
_, _ = fmt.Fprintf(env.Stdout, "Running the gate in %s: %s\n", feature.Worktree, command)

if err := gate.Run(ctx, feature.Worktree, command, env.Stdout); err != nil {
return "", fmt.Errorf("gate %q failed: %w: the feature stays with you until it passes",
command, err)
}

return command, nil
}

// gatedSettings returns the repo's settings, as loadSettings does, and refuses a repo without a
// gate, which each feature's handovers run (decision 56).
func gatedSettings(ctx context.Context, dir string) (config.Settings, error) {
settings, err := loadSettings(ctx, dir)
if err == nil && settings.Gate == "" {
err = noGate()
}

return settings, err
}

// noGate returns the error for a repo without a gate, which names the config file where it goes.
func noGate() error {
file := "baton's config file"
if configDir, err := os.UserConfigDir(); err == nil {
file = config.File(configDir)
}

return fmt.Errorf(`no gate for this repo: set "gate" in %s to the command that checks `+
`the repo's code, such as "mise run check", or to "true" for none`, file)
}
110 changes: 110 additions & 0 deletions internal/cli/gate_refusals_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cli_test

import (
"encoding/json/v2"
"maps"
"os"
"path/filepath"
"testing"

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

// TestHandoverRefusesWhereTheGateCantRun refuses the engineer's handover where the repo's gate
// can't run, and a handover that the flow refuses before its gate runs: no gate runs, and baton's
// folder stays as it was.
func TestHandoverRefusesWhereTheGateCantRun(t *testing.T) {
t.Setenv("HOME", t.TempDir())

marker := filepath.Join(t.TempDir(), "ran")
mark := "touch '" + marker + "'"

marking, err := json.Marshal(map[string]string{"gate": mark})
if err != nil {
t.Fatal(err)
}

gone := filepath.Join(t.TempDir(), "gone")

engineer := flow.State{Holder: flow.RoleEngineer, Status: flow.StatusRunning, Round: 1, Cap: 3}
question := state.Question{From: flow.RoleEngineer, Text: "Which file?"}

cases := []struct {
name string
config string // The config file's text.
feature state.Feature // The feature, with the main worktree, unless it names another.
stdout string
want string // The reason, after "baton handover: ".
}{
{"without a gate", `{"roundCap": 2}`, state.Feature{Flow: engineer}, "", noGate(t)},
{
"with a mistake in the config file", `{"gate": "true", "roundCap": -1}`,
state.Feature{Flow: engineer},
"",
"config file " + configPath(t) + ": roundCap -1: want 1 or more\n",
},
{
"without a worktree", string(marking),
state.Feature{Worktree: "-", Flow: engineer},
"", "feature demo-1 names no worktree, where its gate runs\n",
},
{
"whose worktree is gone", string(marking),
state.Feature{Worktree: gone, Flow: engineer},
ranGate(gone, mark, ""),
`gate "` + mark + `" failed: chdir ` + gone + ": no such file or directory: the " +
"feature stays with you until it passes\n",
},
{
"from a role that doesn't hold it", string(marking),
state.Feature{Flow: fresh()},
"", "lead holds the feature, not engineer\n",
},
{
"while the holder's question waits", string(marking),
state.Feature{Flow: engineer, Question: question},
"",
"the engineer's question \"Which file?\" waits for its answer: wait for it, " +
"then hand over\n",
},
}

for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
writeConfig(t, test.config)

mainDir, linked := repos(t)
store := state.Store{Dir: filepath.Join(mainDir, ".git", "baton")}

feature := test.feature
feature.Slug = slug

switch feature.Worktree {
case "-":
feature.Worktree = ""
case "":
feature.Worktree = mainDir
}

add(t, store, feature)
before := snapshot(t, store.Dir)

note := write(t, linked, "note.md", "Done.\n")
got := run(t, linked, paneOf(flow.RoleEngineer), "handover", "--note", note)

want := result{code: 1, stdout: test.stdout, stderr: "baton handover: " + test.want}
if got != want {
t.Errorf("got %+v; want %+v", got, want)
}

if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Errorf("the gate ran: %v", err)
}

if after := snapshot(t, store.Dir); !maps.Equal(after, before) {
t.Errorf("baton's folder changed to %v", after)
}
})
}
}
Loading
Loading