Skip to content
Merged
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# make dist release archives for macOS and Linux in dist/
# make clean remove build output

VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null)
LDFLAGS := -s -w -X arranger/internal/version.Version=$(VERSION) \
-X arranger/internal/version.Commit=$(COMMIT) \
Expand Down
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ One agent with one huge prompt drifts, forgets, and claims success. A team with
- **Checks decide "done", not the agent.** Arranger runs your checks itself after every attempt and sends failures back with the exact output, up to three tries.
- **Parallel and isolated.** Every agent works in its own git worktree and branch, so agents on the same level run at the same time without touching each other's files. Your branches are never changed until you merge.
- **Review before anything merges.** Managers read each report's diff and accept it or send it back with feedback.
- **You stay in control.** Live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick.
- **You stay in control.** Approve or edit a manager's plan before its team runs, live logs and status for every agent, a diff per agent, remove or promote single changes, revert any checkpoint, token limits per agent, and one-click merge into the branch you pick.

## Install

Expand Down Expand Up @@ -43,30 +43,34 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla
arranger
```

Then open <http://127.0.0.1:7777>.
Then open <http://127.0.0.1:7889>.

2. **Point it at a repo.** Click **Settings** in the header and enter the path to a git repository. Leave the base branch blank to use the current branch. The repo needs at least one commit.

3. **Arrange the team.** Drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**.
3. **Arrange the team.** Press **Teams & Tools** in the sidebar to drop in a ready team (Feature team, Bug fix, Docs) and check which agent tools are installed, or drag agent types from the left onto the canvas. Drop one agent onto another to put it under that agent. Click a box to pick its tool (runtime), model, color and instructions in the **Settings** tab. Click **Save**.

4. **Set the goal.** Select the top agent and fill in the **Goal** tab:
4. **Add goals.** Select the top agent and press **+ Add goal** in its **Goal** tab. Each goal has:
- **Goal:** the outcome, e.g. "Add rate limiting to /login".
- **Acceptance criteria:** what reviewers should hold the work to.
- **Checks:** one shell command per line. All of them must exit 0, e.g. `go test ./...`.

5. **Run.** Press **Run**. A manager plans one subgoal (with its own checks) for each report, runs them in parallel, reviews and merges their work into its own branch, then runs its own checks. Links on the canvas animate while agents hand work down and report back. The **Logs** tab streams what each agent is doing.
5. **Run.** Press **Run** and confirm the goals it will work through. They run one after another, each on the code the one before it left: a manager plans one subgoal (with its own checks) for each report, runs them in parallel, reviews and merges their work into its own branch, then runs its own checks. If a goal fails, it stops there or goes on with the next, as you choose. Links on the canvas animate while agents hand work down and report back, and the **Logs** tab shows each goal's run.

6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (**Run** plans again from the goal instead.)
Want to check the plan first? Tick **Let me approve the plan before the team runs** under the manager's goals. The manager then stops after planning and shows you each report's goal and checks: edit them, skip or add reports, send the plan back with feedback, or approve it to start the team.

6. **Iterate.** Not quite right? Select the agent and write what should change in **Request changes**. Ask the top manager and it passes each part to the agents it concerns and re-runs only them; everyone keeps their work, and the changes are reviewed, merged and checked like any run. (To redo a finished goal from scratch, use **Add again** on it and press **Run**.)

Blocked because one report failed? Fix what it needed, then press **Continue** on the manager: the reports that finished keep their work, only the others run again, and then everything is reviewed, merged and checked together, with no new plan.

7. **Review and merge.** Open the **Diff** tab to see every change, remove or promote single hunks, or revert a checkpoint. When you're happy, press **Merge…** to merge the agent's branch into `main` (or any branch, created if needed) as a merge commit, a squash, or a fast-forward.

**Stats** in the header shows cost, tokens, time and first-try success, per agent and per runtime.
**Stats** (click the token count in the header) shows tokens, time and first-try success, per agent and per runtime.

## Options

```text
arranger [flags]
-addr string listen address (default "127.0.0.1:7777")
-addr string listen address (default "127.0.0.1:7889")
-data string data directory (default "~/.arranger")
-parallel int max agent processes running at once (default 4)
-log string log level: debug, info, warn or error (default "info")
Expand Down
2 changes: 1 addition & 1 deletion cmd/arranger/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (

func main() {
home, _ := os.UserHomeDir()
addr := flag.String("addr", "127.0.0.1:7777", "listen address")
addr := flag.String("addr", "127.0.0.1:7889", "listen address")
data := flag.String("data", filepath.Join(home, ".arranger"), "data directory")
parallel := flag.Int("parallel", 4, "max agent processes running at once")
level := flag.String("log", "info", "log level: debug, info, warn or error")
Expand Down
64 changes: 60 additions & 4 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type Runtime struct {
Tail []string
PromptArg bool
Parse func(line []byte) []Event
// Allow turns shell commands the agent may run without asking into flags, for CLIs that
// otherwise refuse commands in headless mode. nil: the CLI needs nothing.
Allow func(commands []string) []string
}

// withModel returns an Args func: base flags plus flag+model when a model is set.
Expand All @@ -46,7 +49,10 @@ func withModel(flag string, base ...string) func(string) []string {
}

var Runtimes = map[string]Runtime{
"claude": {Bin: "claude", Args: withModel("--model", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"), Parse: parseClaude},
// acceptEdits allows file edits but refuses most shell commands in -p mode, so the agent's own
// check commands are allowed explicitly: it can run its tests before it says it's done.
"claude": {Bin: "claude", Args: withModel("--model", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits"), Parse: parseClaude,
Allow: func(cmds []string) []string { return claudeAllow(cmds) }},
"codex": {Bin: "codex", Args: withModel("-m", "exec", "--json", "--full-auto"), Tail: []string{"-"}, Parse: parseCodex},
"gemini": {Bin: "gemini", Args: withModel("-m", "--output-format", "stream-json", "--approval-mode", "auto_edit"), Parse: parseGemini},
"pi": {Bin: "pi", Args: withModel("--model", "-p", "--mode", "json"), Parse: parsePi},
Expand All @@ -68,9 +74,14 @@ func Installed() map[string]bool {
return m
}

// Command builds the process for a run. extra is the agent's free-form args.
func (rt Runtime) Command(ctx context.Context, model, extra, prompt string) (*exec.Cmd, error) {
bin, args := rt.Bin, append(rt.Args(model), strings.Fields(extra)...)
// Command builds the process for a run. extra is the agent's free-form args; allow lists shell
// commands the agent may run without asking (its checks).
func (rt Runtime) Command(ctx context.Context, model, extra, prompt string, allow []string) (*exec.Cmd, error) {
args := rt.Args(model)
if rt.Allow != nil && len(allow) > 0 {
args = append(args, rt.Allow(allow)...)
}
bin, args := rt.Bin, append(args, strings.Fields(extra)...)
if bin == "" {
if len(args) == 0 {
return nil, errors.New("generic runtime: put the command in the agent's extra args")
Expand Down Expand Up @@ -438,3 +449,48 @@ func Clip(s string, n int) string {
}
return s
}

// buildTools are programs a check commonly runs, allowed with any arguments so the agent can also
// run a narrower version of its check (one package, one test file). Anything else is allowed only
// exactly as the check spells it.
var buildTools = map[string]bool{
"go": true, "gofmt": true, "npm": true, "npx": true, "pnpm": true, "yarn": true, "bun": true, "node": true, "deno": true,
"tsc": true, "jest": true, "vitest": true, "eslint": true, "prettier": true, "playwright": true,
"cargo": true, "rustc": true, "make": true, "cmake": true, "ctest": true,
"python": true, "python3": true, "pytest": true, "uv": true, "poetry": true, "ruff": true, "mypy": true, "tox": true,
"mvn": true, "gradle": true, "./gradlew": true, "./mvnw": true, "dotnet": true, "swift": true, "xcodebuild": true,
"mix": true, "bundle": true, "rake": true, "rspec": true, "php": true, "composer": true, "phpunit": true,
"test": true, "grep": true, "diff": true, "cat": true, "ls": true,
}

// claudeAllow lets Claude Code run the given check commands: each check exactly as written, plus
// the build and test tools it uses with any arguments.
func claudeAllow(checks []string) []string {
seen := map[string]bool{}
var tools []string
add := func(t string) {
if !seen[t] {
seen[t] = true
tools = append(tools, t)
}
}
for _, c := range checks {
if c = strings.TrimSpace(c); c == "" {
continue
}
add("Bash(" + c + ")")
for _, part := range strings.FieldsFunc(c, func(r rune) bool { return r == '&' || r == '|' || r == ';' }) {
words := strings.Fields(part)
for len(words) > 0 && strings.Contains(words[0], "=") && !strings.HasPrefix(words[0], "-") {
words = words[1:] // FOO=bar go test: the program is go
}
if len(words) > 0 && buildTools[words[0]] {
add("Bash(" + words[0] + ":*)")
}
}
}
if len(tools) == 0 {
return nil
}
return append([]string{"--allowedTools"}, tools...)
}
26 changes: 23 additions & 3 deletions internal/agents/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,17 @@ func TestParseOthers(t *testing.T) {
}

func TestCommand(t *testing.T) {
if _, err := Runtimes["generic"].Command(context.Background(), "", "", "x"); err == nil {
if _, err := Runtimes["generic"].Command(context.Background(), "", "", "x", nil); err == nil {
t.Error("generic without a command should fail")
}
cmd, err := Runtimes["generic"].Command(context.Background(), "", "sh -c cat", "hello")
cmd, err := Runtimes["generic"].Command(context.Background(), "", "sh -c cat", "hello", nil)
if err != nil || cmd.Args[0] != "sh" {
t.Fatalf("generic: %v %v", cmd, err)
}
if out, _ := cmd.Output(); string(out) != "hello" {
t.Errorf("generic stdin: %q", out)
}
if cmd, err := Runtimes["codex"].Command(context.Background(), "o3", "--foo", "do it"); err == nil {
if cmd, err := Runtimes["codex"].Command(context.Background(), "o3", "--foo", "do it", nil); err == nil {
if got := strings.Join(cmd.Args[1:], " "); got != "exec --json --full-auto -m o3 --foo -" {
t.Errorf("codex args: %s", got)
}
Expand All @@ -123,3 +123,23 @@ func TestParseClaudeUsage(t *testing.T) {
t.Fatalf("got %+v", es)
}
}

// Claude Code refuses most shell commands in -p mode unless they're allowed; a worker's checks are.
func TestClaudeAllowsChecks(t *testing.T) {
got := strings.Join(claudeAllow([]string{"go test ./...", "CI=1 npm run lint && ./scripts/verify.sh", " "}), " ")
want := "--allowedTools Bash(go test ./...) Bash(go:*) Bash(CI=1 npm run lint && ./scripts/verify.sh) Bash(npm:*)"
if got != want {
t.Fatalf("got %s\nwant %s", got, want)
}
if claudeAllow(nil) != nil {
t.Fatal("no checks, no flag")
}
rt := Runtime{Bin: "sh", Args: withModel(""), Allow: claudeAllow}
cmd, err := rt.Command(context.Background(), "", "--extra", "p", []string{"make test"})
if err != nil {
t.Fatal(err)
}
if a := strings.Join(cmd.Args[1:], " "); a != "--allowedTools Bash(make test) Bash(make:*) --extra" {
t.Fatalf("args: %s", a)
}
}
Loading
Loading