Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
*.txt
/dist/
/.dev-data/
notes
demo/out/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ You also need `git`, and at least one agent CLI on your `PATH` (for example `cla
- **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** 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.
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 the goal needs (the others don't run), 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.

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.

Expand Down
59 changes: 58 additions & 1 deletion internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,56 @@ func DefaultTarget(repo string) string {
return "main"
}

// Branches lists the repo's local branches, leaving out arranger's own agent branches.
func Branches(repo string) []string {
out, _ := Run(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
var bs []string
for _, b := range lines(out) {
if !strings.HasPrefix(b, "arranger/") {
bs = append(bs, b)
}
}
return bs
}

// Closest is the branch in bs most like name, when it's close enough to be a likely typo
// (release/v1.0.4 for release/v0.1.4), else "".
func Closest(name string, bs []string) string {
best, bestD := "", 4
for _, b := range bs {
if d := editDistance(name, b); d > 0 && d < bestD && d <= max(1, len(name)/3) {
best, bestD = b, d
}
}
return best
}

// editDistance counts the single-character edits (insert, delete, replace, or swap of two
// neighbours) that turn a into b.
func editDistance(a, b string) int {
d := make([][]int, len(a)+1)
for i := range d {
d[i] = make([]int, len(b)+1)
d[i][0] = i
}
for j := range d[0] {
d[0][j] = j
}
for i := 1; i <= len(a); i++ {
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
d[i][j] = min(d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+cost)
if i > 1 && j > 1 && a[i-1] == b[j-2] && a[i-2] == b[j-1] {
d[i][j] = min(d[i][j], d[i-2][j-2]+1)
}
}
}
return d[len(a)][len(b)]
}

type MergePreview struct {
Source string `json:"source"`
Target string `json:"target"`
Expand All @@ -496,6 +546,10 @@ type MergePreview struct {
// Uncommitted counts files changed in the source's worktree since its last checkpoint. They
// aren't in the numbers above; merging commits them first.
Uncommitted int `json:"uncommitted"`
// Branches are the repo's branches, to pick the target from; Similar is the existing branch a
// target that doesn't exist probably meant.
Branches []string `json:"branches"`
Similar string `json:"similar"`
}

// Uncommitted counts the files changed in the worktree at dir that aren't committed yet, without
Expand All @@ -507,7 +561,10 @@ func Uncommitted(dir string) int {

// PreviewMerge reports what merging source into target would do, without changing anything.
func PreviewMerge(repo, source, target, start string) (MergePreview, error) {
m := MergePreview{Source: source, Target: target, Start: start, Exists: BranchExists(repo, target), Conflicts: []string{}}
m := MergePreview{Source: source, Target: target, Start: start, Exists: BranchExists(repo, target), Conflicts: []string{}, Branches: Branches(repo)}
if !m.Exists {
m.Similar = Closest(target, m.Branches)
}
into := target
if !m.Exists {
into = start
Expand Down
18 changes: 18 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,21 @@ func TestEnsureWorktreeConcurrently(t *testing.T) {
}
}
}

// A mistyped target is matched to the branch it probably meant; a new name that's nothing like
// an existing branch isn't.
func TestClosest(t *testing.T) {
bs := []string{"main", "release/v0.1.4", "release/0.1.3", "feature/login"}
for name, want := range map[string]string{
"release/v1.0.4": "release/v0.1.4", // 0 and 1 swapped
"release/v0.1.5": "release/v0.1.4",
"mian": "main",
"main": "", // it exists; nothing to suggest
"docker": "",
"feature/signup": "",
} {
if got := Closest(name, bs); got != want {
t.Errorf("Closest(%q) = %q, want %q", name, got, want)
}
}
}
82 changes: 77 additions & 5 deletions internal/orch/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,50 @@ type revisionDraft struct {

// proposePlan asks the manager to split its goal into a subgoal per report.
func (j *job) proposePlan(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, feedback string) ([]subgoal, error) {
var plan planDraft
err := j.decide(planPrompt(j.a, g, kids, feedback, j.change), dir, &plan, func() error { return validatePlan(plan.Subgoals, byID) })
var plan struct {
Subgoals []subgoal `json:"subgoals"`
Repo string `json:"repo"` // its notes on the repository, for its next plan
}
err := j.decide(planPrompt(j.a, g, kids, feedback, j.change, j.repoText(g, dir)), dir, &plan, func() error { return validatePlan(plan.Subgoals, byID) })
if err == nil && strings.TrimSpace(plan.Repo) != "" {
j.saveRepoNotes(plan.Repo, dir)
}
return plan.Subgoals, err
}

// maxRepoNotes caps a manager's notes on the repository, which go into every prompt it plans with.
const maxRepoNotes = 4000

// repoText is the manager's notes on the repository from its last plan, with the files changed
// since, for its next prompt; "" before it has any.
func (j *job) repoText(g store.Goal, dir string) string {
if strings.TrimSpace(g.RepoNotes) == "" {
return ""
}
changed := "(couldn't tell: the commit the notes describe is gone; check anything they rely on)"
if out, err := git.Run(dir, "diff", "--name-only", g.RepoSHA, "HEAD"); err == nil {
fs := lines(out)
if len(fs) > 40 {
fs = append(fs[:40], fmt.Sprintf("and %d more", len(fs)-40))
}
changed = strings.Join(fs, "\n")
}
return repoText(g.RepoNotes, g.RepoSHA, changed)
}

// saveRepoNotes keeps the manager's notes on the repository, and the commit they describe, for its next plan.
func (j *job) saveRepoNotes(notes, dir string) {
head, err := git.Run(dir, "rev-parse", "HEAD")
if err != nil {
return
}
notes = strings.TrimSpace(notes)
if len(notes) > maxRepoNotes {
notes = notes[:maxRepoNotes]
}
j.o.Store.SetGoal(j.a.ID, map[string]any{"repo_notes": notes, "repo_sha": strings.TrimSpace(head)})
}

// apply saves a plan (sgs) or a routed change (cs) to the reports and returns who runs.
func (j *job) apply(sgs []subgoal, cs []revision, byID map[string]store.Agent, goals map[string]store.Goal, changes map[string]string) []store.Agent {
if cs != nil {
Expand All @@ -298,7 +337,7 @@ func (j *job) applyPlan(sgs []subgoal, byID map[string]store.Agent) []store.Agen
func (j *job) proposeRevision(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal,
feedback string) ([]revision, error) {
var rev revisionDraft
err := j.decide(revisePrompt(j.a, g, kids, goals, j.change, feedback), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) })
err := j.decide(revisePrompt(j.a, g, kids, goals, j.change, feedback, j.repoText(g, dir)), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) })
return rev.Changes, err
}

Expand Down Expand Up @@ -347,7 +386,7 @@ func names(as []store.Agent) string {
func (j *job) proposeFix(g store.Goal, dir string, kids []store.Agent, byID map[string]store.Agent, goals map[string]store.Goal,
failure string) ([]revision, error) {
var rev revisionDraft
err := j.decide(fixPrompt(j.a, g, kids, goals, failure), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) })
err := j.decide(fixPrompt(j.a, g, kids, goals, failure, j.repoText(g, dir)), dir, &rev, func() error { return validateRevision(rev.Changes, byID, goals) })
return rev.Changes, err
}

Expand Down Expand Up @@ -423,14 +462,15 @@ func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string
var mu sync.Mutex
var wg sync.WaitGroup
res := map[string]string{}
team := teamText(j, kids)
for _, k := range kids {
ctx, cancel := context.WithCancel(j.ctx)
if !j.o.register(k.ID, cancel) {
cancel()
res[k.ID] = "already running"
continue
}
kj := &job{o: j.o, ctx: ctx, p: j.p, a: k, change: changes[k.ID], fix: fix && changes[k.ID] != ""}
kj := &job{o: j.o, ctx: ctx, p: j.p, a: k, change: changes[k.ID], fix: fix && changes[k.ID] != "", team: team[k.ID]}
kj.status("starting", nil) // the hand-off shows on the canvas at once, not after the worktree is ready
wg.Add(1)
go func(k store.Agent) {
Expand All @@ -447,6 +487,38 @@ func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string
return res
}

// teamText tells each report what its manager is after, what its teammates in this run work on,
// and the manager's notes on the repository, so it stays in its lane and reads less.
func teamText(j *job, kids []store.Agent) map[string]string {
mg, _ := j.o.Store.Goal(j.a.ID)
titles := map[string]string{}
for _, k := range kids {
kg, _ := j.o.Store.Goal(k.ID)
titles[k.ID] = kg.Title
}
out := map[string]string{}
for _, k := range kids {
var b strings.Builder
fmt.Fprintf(&b, "THIS IS YOUR PART OF YOUR MANAGER'S GOAL: %s\n", mg.Title)
first := true
for _, o := range kids {
if o.ID == k.ID || titles[o.ID] == "" {
continue
}
if first {
b.WriteString("YOUR TEAMMATES WORK AT THE SAME TIME, EACH ON ITS OWN BRANCH. Leave their parts to them:\n")
first = false
}
fmt.Fprintf(&b, "- %s: %s\n", o.Name, titles[o.ID])
}
if n := strings.TrimSpace(mg.RepoNotes); n != "" {
b.WriteString("YOUR MANAGER'S NOTES ON THE REPOSITORY (a starting point; the code is the truth):\n" + n + "\n")
}
out[k.ID] = b.String()
}
return out
}

// decide asks the manager's CLI for a JSON decision, decodes it into v and checks it with
// valid, re-asking with the error when it's unusable. Managers decide, they don't edit:
// any file changes a decision call makes are thrown away.
Expand Down
7 changes: 4 additions & 3 deletions internal/orch/orch.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ type job struct {
// change is what the user asked to change since the last run (or, for a report, the part of
// it its manager passed down). "" for a plain run from the goal.
change string
fix bool // change is a fix the manager asks for because the team's merged work fails its checks
cont bool // continuing a blocked run: same plan, only unfinished reports run
fix bool // change is a fix the manager asks for because the team's merged work fails its checks
cont bool // continuing a blocked run: same plan, only unfinished reports run
team string // for a report: its manager's goal, its teammates' parts, its manager's notes on the repo

allow []string // shell commands the agent may run without asking: a worker's own checks

Expand Down Expand Up @@ -393,7 +394,7 @@ func runWorker(j *job, g store.Goal, dir, feedback string) string {
j.newRun(attempt)
j.emit("msg", fmt.Sprintf("attempt %d/%d with %s", attempt, maxAttempts, j.a.Runtime), "")

summary, agentErr := runAgent(j, workerPrompt(j.a, g, dir, checks, feedback, j.change, j.fix), dir)
summary, agentErr := runAgent(j, workerPrompt(j.a, g, dir, checks, feedback, j.change, j.fix, j.team), dir)
msg := checkpointMessage(summary, j.change, g.Title)
if sha, err := git.Commit(dir, msg); err != nil {
j.emit("error", err.Error(), "")
Expand Down
50 changes: 50 additions & 0 deletions internal/orch/orch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,3 +752,53 @@ func TestContinueBlockedRun(t *testing.T) {
}
}
}

// A manager's plan leaves notes on the repo; its next plan gets them back with what changed since,
// instead of reading the repo again, and its reports get them with their teammates' parts.
func TestManagerReusesRepoNotes(t *testing.T) {
o := setup(t)
prompts := t.TempDir()
plain := func(l []byte) []agents.Event { return []agents.Event{{Kind: "msg", Text: string(l)}} }
agents.Runtimes["notesmgr"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string {
return []string{"-c", `p=$(cat); case "$p" in
*"YOUR TEAM:"*) n=$(ls ` + prompts + ` | wc -l | tr -d ' '); printf '%s' "$p" > ` + prompts + `/plan$n
echo '{"subgoals":[{"agent":"a","title":"write a","checks":["test -f a.txt"]},{"agent":"b","title":"write b","checks":["test -f b.txt"]}],"repo":"NOTES: the code lives in src"}' ;;
*) echo '{"verdicts":[{"agent":"a","accept":true},{"agent":"b","accept":true}]}' ;;
esac`}
}}
agents.Runtimes["notesworker"] = agents.Runtime{Bin: "sh", Parse: plain, Args: func(string) []string {
return []string{"-c", `p=$(cat); case "$p" in *"You are Alpha"*) printf '%s' "$p" > ` + prompts + `/../alpha.txt; date +%N >> a.txt ;; *) date +%N >> b.txt ;; esac; echo worked`}
}}
defer delete(agents.Runtimes, "notesmgr")
defer delete(agents.Runtimes, "notesworker")

repo := newRepo(t)
p, _ := o.Store.CreateProject(store.Project{Name: "t", Repo: repo, Base: "main"})
o.Store.SaveArrangement(p.ID, []store.Agent{
{ID: "m", Name: "Lead", Role: "manager", Runtime: "notesmgr"},
{ID: "a", Name: "Alpha", Role: "coder", Parent: "m", Runtime: "notesworker"},
{ID: "b", Name: "Beta", Role: "coder", Parent: "m", Runtime: "notesworker"},
})
for i, title := range []string{"first goal", "second goal"} {
o.Store.SaveGoal("m", store.Goal{Title: title, Checks: "test -f a.txt && test -f b.txt"})
if err := o.Start("m"); err != nil {
t.Fatal(err)
}
if g := waitDone(t, o, "m"); g.Status != "done" {
t.Fatalf("goal %d: %+v", i+1, g)
}
}
first, _ := os.ReadFile(filepath.Join(prompts, "plan0"))
second, _ := os.ReadFile(filepath.Join(prompts, "plan1"))
if strings.Contains(string(first), "WHAT YOU ALREADY KNOW") {
t.Fatal("the first plan has no notes to reuse")
}
if !strings.Contains(string(second), "WHAT YOU ALREADY KNOW ABOUT THE REPOSITORY") || !strings.Contains(string(second), "NOTES: the code lives in src") ||
!strings.Contains(string(second), "a.txt") {
t.Fatalf("the second plan should reuse the notes and list what changed:\n%s", second)
}
alpha, _ := os.ReadFile(filepath.Join(prompts, "..", "alpha.txt"))
if !strings.Contains(string(alpha), "- Beta: write b") || !strings.Contains(string(alpha), "NOTES: the code lives in src") || !strings.Contains(string(alpha), "second goal") {
t.Fatalf("a report should see its manager's goal, its teammates' parts and the repo notes:\n%s", alpha)
}
}
Loading
Loading