diff --git a/.gitignore b/.gitignore index 3badfba..f1a3c36 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ *.txt /dist/ /.dev-data/ +notes +demo/out/ diff --git a/README.md b/README.md index 438bfb3..4d939a9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/git/git.go b/internal/git/git.go index 0e9fbce..f73c225 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -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"` @@ -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 @@ -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 diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 46021be..a1a0e60 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -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) + } + } +} diff --git a/internal/orch/manager.go b/internal/orch/manager.go index 98b2604..242a5ae 100644 --- a/internal/orch/manager.go +++ b/internal/orch/manager.go @@ -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 { @@ -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 } @@ -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 } @@ -423,6 +462,7 @@ 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) { @@ -430,7 +470,7 @@ func runChildren(j *job, kids []store.Agent, feedback, changes map[string]string 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) { @@ -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. diff --git a/internal/orch/orch.go b/internal/orch/orch.go index f0a65e8..4542a20 100644 --- a/internal/orch/orch.go +++ b/internal/orch/orch.go @@ -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 @@ -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(), "") diff --git a/internal/orch/orch_test.go b/internal/orch/orch_test.go index 132eb8b..d7708a2 100644 --- a/internal/orch/orch_test.go +++ b/internal/orch/orch_test.go @@ -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) + } +} diff --git a/internal/orch/prompts.go b/internal/orch/prompts.go index 7246173..7e4d79e 100644 --- a/internal/orch/prompts.go +++ b/internal/orch/prompts.go @@ -22,14 +22,26 @@ func goalText(b *strings.Builder, g store.Goal, checks []string) { } } -func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feedback, change string, fix bool) string { +// team is what the worker's manager is after and what its teammates do meanwhile; "" for a +// top-level worker. It keeps parallel workers in their own lane. +func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feedback, change string, fix bool, team string) string { var b strings.Builder fmt.Fprintf(&b, "You are %s, a %s agent. You work in the git worktree %s; only change files there.\n", a.Name, a.Role, dir) if a.Prompt != "" { b.WriteString("\n" + a.Prompt + "\n") } goalText(&b, g, checks) - b.WriteString("\nStay within the goal. Don't claim something works unless you verified it. Don't commit; the orchestrator does.\n") + if team != "" { + b.WriteString("\n" + team) + } + b.WriteString(` +How to work: +- Read the code your goal touches before you change it, and follow the conventions you find there. +- Stay within the goal: change only what it needs, and nothing a teammate is doing. +- Before you finish, run every check above yourself and fix what fails. You may run them without asking. +- Don't claim something works unless you verified it. If a check can't pass for a reason outside your goal, say so plainly. +- Don't commit; the orchestrator does. +`) b.WriteString("End your reply with one line describing what you changed, used as the commit message, in this form:\n" + "Commit: \n") if g.Notes != "" { @@ -41,12 +53,44 @@ func workerPrompt(a store.Agent, g store.Goal, dir string, checks []string, feed b.WriteString("\nYOU ALREADY WORKED ON THIS GOAL, AND THE USER ASKS FOR THESE CHANGES. Keep the rest of your work; change only this:\n" + change + "\n") } if feedback != "" { - b.WriteString("\nYOUR PREVIOUS ATTEMPT WAS NOT ACCEPTED. Fix this:\n" + feedback) + b.WriteString("\nYOUR PREVIOUS ATTEMPT WAS NOT ACCEPTED. Your changes from it are still in the worktree: build on them, don't start over. " + + "Find the cause of the failure below before you change anything, then fix it and run the checks again:\n" + feedback) + } + return b.String() +} + +// repoText is what a manager already knows about the repository from its last plan, and what +// changed since, so it doesn't read the whole repository again for every goal. "" when it has no notes. +func repoText(notes, sha, changed string) string { + if strings.TrimSpace(notes) == "" { + return "" } + var b strings.Builder + fmt.Fprintf(&b, "\nWHAT YOU ALREADY KNOW ABOUT THE REPOSITORY (your notes from your last plan, at commit %s):\n%s\n", shortSHA(sha), strings.TrimSpace(notes)) + if changed == "" { + b.WriteString("Nothing changed since then.\n") + } else { + b.WriteString("Files changed since then:\n" + changed + "\n") + } + b.WriteString("Trust these notes. Read files only for what they don't cover, or what changed.\n") return b.String() } -func planPrompt(a store.Agent, g store.Goal, kids []store.Agent, feedback, change string) string { +func shortSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} + +// checkAdvice is how a manager should write a subgoal's checks: ones that prove this subgoal's work, +// not ones that already pass on the code as it is. +const checkAdvice = `A good check fails on the code as it is now and passes only once the subgoal is done: a test named for the +new behaviour run so it can't pass by matching nothing (e.g. go test -run '^TestInviteExpiry$' -v ./invites | grep -q -- '--- PASS'), +a grep for the new route, flag or text, or a command that exercises the feature. A whole existing test suite only proves nothing broke; +add it next to a specific check, not instead of one.` + +func planPrompt(a store.Agent, g store.Goal, kids []store.Agent, feedback, change, repo string) string { var b strings.Builder fmt.Fprintf(&b, "You are %s, a manager agent. You delegate and review; you do not edit files.\n", a.Name) if a.Prompt != "" { @@ -67,18 +111,25 @@ func planPrompt(a store.Agent, g store.Goal, kids []store.Agent, feedback, chang if feedback != "" { b.WriteString("\nYOUR PREVIOUS RESULT WAS NOT ACCEPTED. Plan to fix this:\n" + feedback + "\n") } + b.WriteString(repo) b.WriteString(` -Read the repository if you need to, then split the goal into at most one subgoal per team member (skip members you don't need). +Read the repository if you need to, then split the goal into at most one subgoal per team member. Give work only to the members +the goal needs; leave the others out, they don't run. Each subgoal must be small, clearly scoped, and have at least one shell check that proves it's done; checks run from the repository root. +In the body, name the files or packages the member should work in and anything it must agree on with a teammate (a function name, +an API shape), since members can't see each other's work. +` + checkAdvice + ` Members work in parallel on separate branches, so avoid giving two members the same files. +In "repo", write short notes on the repository for your next plan, so it doesn't have to read the repository again: layout, +conventions, how to build and test, and the files that matter (under 300 words). Update your earlier notes if you have them. Reply with ONLY a JSON object, no prose: -{"subgoals":[{"agent":"","title":"...","body":"...","criteria":"...","checks":["..."]}]}`) +{"subgoals":[{"agent":"","title":"...","body":"...","criteria":"...","checks":["..."]}],"repo":"..."}`) return b.String() } // revisePrompt asks a manager whose team already worked which reports must change what, so only // they re-run. Each keeps its goal and work; it gets just its part of the user's request. -func revisePrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string]store.Goal, change, feedback string) string { +func revisePrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string]store.Goal, change, feedback, repo string) string { var b strings.Builder fmt.Fprintf(&b, "You are %s, a manager agent. You delegate and review; you do not edit files.\n", a.Name) if a.Prompt != "" { @@ -99,6 +150,7 @@ func revisePrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[str } fmt.Fprintf(&b, ": subgoal %q (%s, +%d -%d lines; checks: %s)\n", kg.Title, kg.Status, kg.Adds, kg.Dels, strings.Join(lines(kg.Checks), "; ")) } + b.WriteString(repo) b.WriteString(` Read the repository if you need to. Decide which members must change something to satisfy the request, and give each one only the part of the request that concerns it, as a concrete instruction. They keep their current work and goal. @@ -110,7 +162,7 @@ Reply with ONLY a JSON object, no prose: // fixPrompt asks a manager whose team's merged work fails its checks which reports must fix what. // Like revisePrompt, the reports keep their goal and work and get only their part of the fix. -func fixPrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string]store.Goal, failure string) string { +func fixPrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string]store.Goal, failure, repo string) string { var b strings.Builder fmt.Fprintf(&b, "You are %s, a manager agent. You delegate and review; you do not edit files.\n", a.Name) if a.Prompt != "" { @@ -128,6 +180,7 @@ func fixPrompt(a store.Agent, g store.Goal, kids []store.Agent, goals map[string } fmt.Fprintf(&b, ": subgoal %q (checks: %s)\n", kg.Title, strings.Join(lines(kg.Checks), "; ")) } + b.WriteString(repo) b.WriteString(` Read the repository and the failure output. The parts may each work alone but not together, e.g. one member calls a function another named differently. Decide which members must fix what, and give each one a concrete instruction. diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 2191362..f324200 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -2,10 +2,8 @@ package server import ( "database/sql" - "encoding/json" "errors" "fmt" - "io" "net/http" "os" "path/filepath" @@ -192,14 +190,10 @@ func (s *Server) saveGoal(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// compact shrinks the goal's carried context (feedback and notes) to its newest 1000 -// characters each, or empties it with {"clear":true}, so the next prompt is smaller. +// compact shrinks the notes on removed changes that the agent's every prompt carries (see +// compactNotes). They're the only context carried from run to run: each run starts a fresh agent +// session, and a failure's output only goes into the retries of the run it happened in. func (s *Server) compact(w http.ResponseWriter, r *http.Request) { - var req struct{ Clear bool } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) { - fail(w, http.StatusBadRequest, err) - return - } a, _, err := s.st.Agent(r.PathValue("id")) if err != nil { http.Error(w, "agent not found", http.StatusNotFound) @@ -214,19 +208,9 @@ func (s *Server) compact(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusInternalServerError, err) return } - before := len(g.Feedback) + len(g.Notes) - keep := func(v string) string { - if req.Clear { - return "" - } - if len(v) > 1000 { - return v[len(v)-1000:] - } - return v - } - g.Feedback, g.Notes = keep(g.Feedback), keep(g.Notes) - s.st.SetGoal(a.ID, map[string]any{"feedback": g.Feedback, "notes": g.Notes}) - writeJSON(w, map[string]int{"before": before, "after": len(g.Feedback) + len(g.Notes)}) + notes := compactNotes(g.Notes) + s.st.SetGoal(a.ID, map[string]any{"notes": notes}) + writeJSON(w, map[string]int{"before": len(g.Notes), "after": len(notes)}) } func (s *Server) run(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a993217..e67a808 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -722,23 +722,30 @@ func TestReviseFinishedGoal(t *testing.T) { } } -// Compacting keeps only the newest part of the goal's feedback and notes; clearing empties them. +// Compacting shrinks the notes on removed changes to each file's added and removed lines, keeps +// the newest files when that's still too much, and leaves already compacted notes alone. func TestCompact(t *testing.T) { a := newApp(t) a.must(204, "POST", "/api/projects/"+a.pid+"/arrangement", `[{"id":"k","name":"Kid","role":"programmer"}]`) - a.st.SetGoal("k", map[string]any{"feedback": strings.Repeat("f", 3000), "notes": strings.Repeat("n", 2500)}) + patch := "diff --git a/x.go b/x.go\nindex 1..2 100644\n--- a/x.go\n+++ b/x.go\n@@ -1,3 +1,3 @@\n ctx\n-old line\n+new line\n+\n ctx\n" + a.st.SetGoal("k", map[string]any{"notes": patch}) a.must(404, "POST", "/api/agents/nobody/compact", "") var res struct{ Before, After int } json.Unmarshal([]byte(a.must(200, "POST", "/api/agents/k/compact", "")), &res) - if res.Before != 5500 || res.After >= res.Before || res.After > 2000 { - t.Fatalf("compact: %+v", res) + want := "diff --git a/x.go b/x.go\n-old line\n+new line\n" + if g, _ := a.st.Goal("k"); g.Notes != want || res.Before != len(patch) || res.After != len(want) { + t.Fatalf("compact: %+v, notes %q", res, g.Notes) } - json.Unmarshal([]byte(a.must(200, "POST", "/api/agents/k/compact", `{"clear":true}`)), &res) - if res.After != 0 { - t.Fatalf("clear: %+v", res) + a.must(200, "POST", "/api/agents/k/compact", "") + if g, _ := a.st.Goal("k"); g.Notes != want { + t.Fatalf("compacting again changed the notes: %q", g.Notes) } - if g, _ := a.st.Goal("k"); g.Feedback != "" || g.Notes != "" { - t.Fatalf("stored goal not cleared: %+v", g) + + big := strings.Repeat(patch, 400) + a.st.SetGoal("k", map[string]any{"notes": big}) + a.must(200, "POST", "/api/agents/k/compact", "") + if g, _ := a.st.Goal("k"); len(g.Notes) > 4000 || !strings.HasPrefix(g.Notes, "diff --git ") { + t.Fatalf("big notes should keep whole newest files under 4000 bytes: %d bytes, starts %q", len(g.Notes), g.Notes[:20]) } } @@ -786,10 +793,17 @@ func TestSuggestChecks(t *testing.T) { for _, c := range suggestChecks(repo) { got = append(got, c.Cmd) } - want := "go build ./...|go vet ./...|go test ./...|pnpm run build|pnpm run lint|make test" + // checks that prove the goal's own work come first, then the ones that only prove nothing broke + want := "! git diff --quiet main...|" + + `t=TestName; go test -run "^$t\$" -v ./... | grep -q -- "--- PASS: $t "|` + + `p=$(git diff --name-only main... -- '*.go' | sed -e 's|[^/]*$||' -e 's|^|./|' | sort -u); test -n "$p" && go test $p|` + + "git diff --stat main... | grep -q '_test\\.go'|go build ./...|go vet ./...|go test ./...|pnpm run build|pnpm run lint|make test" if strings.Join(got, "|") != want { t.Fatalf("got %s\nwant %s", strings.Join(got, "|"), want) } + if cs := suggestChecks(repo); cs[1].Edit != "TestName" || !strings.Contains(cs[1].Cmd, cs[1].Edit) { + t.Fatalf("the named-test check should say what to replace: %+v", cs[1]) + } if len(suggestChecks("")) != 0 { t.Fatal("no repo, no suggestions") } diff --git a/internal/server/setup.go b/internal/server/setup.go index 05d1941..2c4da61 100644 --- a/internal/server/setup.go +++ b/internal/server/setup.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "fmt" "net/http" "os" "os/exec" @@ -12,6 +13,7 @@ import ( "time" "arranger/internal/agents" + "arranger/internal/git" "arranger/internal/store" ) @@ -66,10 +68,35 @@ func tools(ctx context.Context) []tool { return ts } -// check is a shell check that fits the repo, and what it proves. +// check is a shell check that fits the repo, and what it proves. Specific checks prove the goal's +// own work: they fail before it's done. The rest (a build, the whole test suite) already pass on the +// code as it is, so they only prove nothing broke. Edit is a placeholder in Cmd the user replaces +// with something from their goal, e.g. the name of the test that proves it. type check struct { - Cmd string `json:"cmd"` - Why string `json:"why"` + Cmd string `json:"cmd"` + Why string `json:"why"` + Specific bool `json:"specific,omitempty"` + Edit string `json:"edit,omitempty"` +} + +// changedCheck suggests a check that fails when the goal's work changed nothing at all, so an agent +// that only claims success can't pass on checks that already passed before it started. +func changedCheck(repo string) check { + return check{Cmd: fmt.Sprintf("! git diff --quiet %s...", git.DefaultTarget(repo)), + Why: "the work changed something; fails if the agent changed no file", Specific: true} +} + +// newTestCheck suggests a heuristic check that fails unless the diff against the repo's default +// branch touches a file matching pattern: it catches "tests pass" checks that would pass even if +// the goal added no test at all. It's a filename heuristic, not real coverage: a test added to an +// existing test file it doesn't rename, or an inline test in a non-matching file, slips through. +func newTestCheck(repo, pattern, label string) check { + target := git.DefaultTarget(repo) + return check{ + Cmd: fmt.Sprintf("git diff --stat %s... | grep -q '%s'", target, pattern), + Why: fmt.Sprintf("heuristic: fails unless the diff touches a %s file; doesn't check the test actually covers the change", label), + Specific: true, + } } // suggestChecks reads the repo's build files and suggests checks that prove work is done. @@ -79,8 +106,20 @@ func suggestChecks(repo string) []check { if repo == "" { return cs } + cs = append(cs, changedCheck(repo)) if has("go.mod") { - cs = append(cs, check{"go build ./...", "it compiles"}, check{"go vet ./...", "no suspicious code"}, check{"go test ./...", "the tests pass"}) + target := git.DefaultTarget(repo) + cs = append(cs, + // checks that prove this goal's work, not just that nothing broke + check{Cmd: `t=TestName; go test -run "^$t\$" -v ./... | grep -q -- "--- PASS: $t "`, + Why: "a test named for this goal exists and passes; can't pass by matching no test. Replace TestName", Specific: true, Edit: "TestName"}, + check{Cmd: fmt.Sprintf(`p=$(git diff --name-only %s... -- '*.go' | sed -e 's|[^/]*$||' -e 's|^|./|' | sort -u); test -n "$p" && go test $p`, target), + Why: "the tests of the packages the goal changed pass; fails if it changed no Go code", Specific: true}, + newTestCheck(repo, `_test\.go`, "_test.go"), + check{Cmd: "go build ./...", Why: "it compiles"}, + check{Cmd: "go vet ./...", Why: "no suspicious code"}, + check{Cmd: "go test ./...", Why: "the existing tests still pass; won't fail if the goal added no test for its own change"}, + ) } if has("package.json") { pm := "npm" @@ -96,22 +135,36 @@ func suggestChecks(repo string) []check { if b, err := os.ReadFile(filepath.Join(repo, "package.json")); err == nil { json.Unmarshal(b, &pkg) } - for _, s := range []struct{ name, why string }{{"build", "it builds"}, {"typecheck", "types check"}, {"lint", "lint passes"}, {"test", "the tests pass"}} { + for _, s := range []struct{ name, why string }{ + {"build", "it builds"}, + {"typecheck", "types check"}, + {"lint", "lint passes"}, + {"test", "the existing tests still pass; won't fail if the goal added no test for its own change"}, + } { if sc, ok := pkg.Scripts[s.name]; ok && !strings.Contains(sc, "no test specified") { - cs = append(cs, check{pm + " run " + s.name, s.why}) + cs = append(cs, check{Cmd: pm + " run " + s.name, Why: s.why}) + if s.name == "test" { + cs = append(cs, newTestCheck(repo, `\.(test|spec)\.[jt]sx\?`, ".test./.spec.")) + } } } } if has("Cargo.toml") { - cs = append(cs, check{"cargo build", "it compiles"}, check{"cargo test", "the tests pass"}) + cs = append(cs, check{Cmd: "t=test_name; cargo test $t -- --exact 2>&1 | grep -q \"test .*$t ... ok\"", Why: "a test named for this goal exists and passes. Replace test_name", Specific: true, Edit: "test_name"}, + check{Cmd: "cargo build", Why: "it compiles"}, check{Cmd: "cargo test", Why: "the existing tests still pass; won't fail if the goal added no test for its own change"}) } if has("pyproject.toml") || has("setup.py") || has("requirements.txt") { - cs = append(cs, check{"python3 -m pytest -q", "the tests pass"}) + cs = append(cs, check{Cmd: "python3 -m pytest -q -k test_name | grep -q ' passed'", Why: "a test named for this goal exists and passes; pytest exits 5 when nothing matches. Replace test_name", Specific: true, Edit: "test_name"}, + check{Cmd: "python3 -m pytest -q", Why: "the existing tests still pass; won't fail if the goal added no test for its own change"}, newTestCheck(repo, `test_.*\.py\|_test\.py`, "test_*.py / *_test.py")) } if b, err := os.ReadFile(filepath.Join(repo, "Makefile")); err == nil { for _, target := range []string{"test", "check", "lint"} { if strings.Contains("\n"+string(b), "\n"+target+":") { - cs = append(cs, check{"make " + target, "make " + target + " passes"}) + why := "make " + target + " passes" + if target == "test" { + why += "; won't fail if the goal added no test for its own change" + } + cs = append(cs, check{Cmd: "make " + target, Why: why}) } } } diff --git a/internal/server/work.go b/internal/server/work.go index 136d762..6a0315f 100644 --- a/internal/server/work.go +++ b/internal/server/work.go @@ -189,11 +189,8 @@ func (s *Server) hunks(w http.ResponseWriter, r *http.Request) { return } git.Commit(wk.dir, fmt.Sprintf("Remove %d change(s) by hand in the Diff tab\n\nThe agent won't add them back.", n)) - notes := wk.g.Notes + patch - if len(notes) > 8000 { // keep the newest removals; the prompt shouldn't grow without bound - notes = notes[len(notes)-8000:] - } - s.st.SetGoal(wk.a.ID, map[string]any{"notes": notes}) + // keep the newest removals; the prompt shouldn't grow without bound + s.st.SetGoal(wk.a.ID, map[string]any{"notes": newestFiles(wk.g.Notes+patch, 8000)}) s.changed(wk.p, wk.a, wk.dir, wk.g.Base) case "promote": mgr, _, err := s.st.Agent(wk.a.Parent) @@ -256,7 +253,10 @@ func (s *Server) merge(w http.ResponseWriter, r *http.Request) { req.Target = r.URL.Query().Get("target") } if req.Target = strings.TrimSpace(req.Target); req.Target == "" { - req.Target = git.DefaultTarget(wk.p.Repo) + req.Target = wk.p.Base // the branch the project works from, where its work usually goes back + if !git.BranchExists(wk.p.Repo, req.Target) { + req.Target = git.DefaultTarget(wk.p.Repo) + } } if _, err := git.Run(wk.p.Repo, "check-ref-format", "--branch", req.Target); err != nil || strings.HasPrefix(req.Target, "arranger/") { fail(w, http.StatusBadRequest, fmt.Errorf("%q isn't a branch name you can merge into", req.Target)) @@ -284,6 +284,7 @@ func (s *Server) merge(w http.ResponseWriter, r *http.Request) { if req.Message == "" { req.Message = "Merge work from " + wk.a.Name } + created := !git.BranchExists(wk.p.Repo, req.Target) sha, err := git.MergeInto(wk.p.Repo, git.BranchOf(wk.a.ID), req.Target, wk.p.Base, req.Strategy, req.Message) if err != nil { fail(w, http.StatusConflict, err) @@ -292,5 +293,37 @@ func (s *Server) merge(w http.ResponseWriter, r *http.Request) { e := store.LogEvent{Agent: wk.a.ID, Kind: "merge", Text: fmt.Sprintf("merged into %s (%s) → %s", req.Target, req.Strategy, sha)} s.st.AddEvent(&e) s.o.Hub.Publish(wk.p.ID, map[string]any{"type": "event", "event": e}) - writeJSON(w, map[string]string{"sha": sha, "target": req.Target}) + writeJSON(w, map[string]any{"sha": sha, "target": req.Target, "created": created}) +} + +// newestFiles keeps the newest whole files of a diff that fit in max bytes, so a cut never lands +// mid-hunk. A single file bigger than max keeps its newest max bytes. +func newestFiles(d string, max int) string { + if len(d) <= max { + return d + } + tail := d[len(d)-max:] + if i := strings.Index(tail, "diff --git "); i >= 0 { + return tail[i:] + } + return tail +} + +// compactNotes shrinks the changes the user removed, which every prompt of the agent carries, to +// what the agent needs to not re-add them: each file and its added and removed lines. Hunk headers, +// index lines and unchanged context go. Compacting compacted notes changes nothing. +func compactNotes(notes string) string { + var b strings.Builder + for _, l := range strings.Split(notes, "\n") { + switch { + case strings.HasPrefix(l, "diff --git "): + b.WriteString(l + "\n") + case strings.HasPrefix(l, "+++ "), strings.HasPrefix(l, "--- "): + case strings.HasPrefix(l, "+"), strings.HasPrefix(l, "-"): + if strings.TrimSpace(l[1:]) != "" { // blank added or removed lines say nothing + b.WriteString(l + "\n") + } + } + } + return newestFiles(b.String(), 4000) } diff --git a/internal/store/store.go b/internal/store/store.go index a7abd6b..acd1b07 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -73,6 +73,8 @@ var migrations = []string{ `UPDATE queue SET status = 'stopped' WHERE status = 'running'`, `UPDATE agents SET queue_active = 0`, `UPDATE plans SET status = 'expired', decided = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE status = 'pending'`, + `ALTER TABLE goals ADD COLUMN repo_notes TEXT NOT NULL DEFAULT ''`, // a manager's notes on the repo from its last plan, reused by the next + `ALTER TABLE goals ADD COLUMN repo_sha TEXT NOT NULL DEFAULT ''`, // the commit repo_notes describe } type Project struct { @@ -126,6 +128,10 @@ type Goal struct { Base string `json:"base"` Notes string `json:"notes"` From string `json:"from"` // the branch Base came from: its manager's, or the project base + // RepoNotes is what a manager learned about the repo when it last planned, at commit RepoSHA; + // its next plan starts from them instead of reading the repo again. + RepoNotes string `json:"-"` + RepoSHA string `json:"-"` } // LogEvent is one line of an agent's activity log. @@ -358,8 +364,8 @@ func (s *Store) SaveArrangement(projectID string, as []Agent) error { // Goal returns an agent's goal; an agent without one gets an empty, idle goal. func (s *Store) Goal(agentID string) (g Goal, err error) { - err = s.db.QueryRow(`SELECT title, body, criteria, checks, status, attempts, feedback, passed, total, adds, dels, base, notes, from_branch FROM goals WHERE agent_id=?`, agentID). - Scan(&g.Title, &g.Body, &g.Criteria, &g.Checks, &g.Status, &g.Attempts, &g.Feedback, &g.Passed, &g.Total, &g.Adds, &g.Dels, &g.Base, &g.Notes, &g.From) + err = s.db.QueryRow(`SELECT title, body, criteria, checks, status, attempts, feedback, passed, total, adds, dels, base, notes, from_branch, repo_notes, repo_sha FROM goals WHERE agent_id=?`, agentID). + Scan(&g.Title, &g.Body, &g.Criteria, &g.Checks, &g.Status, &g.Attempts, &g.Feedback, &g.Passed, &g.Total, &g.Adds, &g.Dels, &g.Base, &g.Notes, &g.From, &g.RepoNotes, &g.RepoSHA) if err == sql.ErrNoRows { return Goal{Status: "idle"}, nil } diff --git a/web/app.js b/web/app.js index 48e8e1b..1bcaa93 100644 --- a/web/app.js +++ b/web/app.js @@ -97,12 +97,15 @@ function layout(all) { } // linkFlow says whether the link between a manager and a report is live, and which way it moves: -// "down" while the manager hands out subgoals (planning, or the report is just starting), -// "up" while the report works for its manager, or the manager reviews what it sent back. -function linkFlow(mgrStatus, kidStatus) { - if (kidStatus === "starting" || mgrStatus === "planning" || mgrStatus === "fixing") return { dir: "down", status: kidStatus === "starting" ? kidStatus : mgrStatus }; - if (BUSY.includes(kidStatus)) return { dir: "up", status: kidStatus }; - if (mgrStatus === "reviewing" && kidStatus === "done") return { dir: "up", status: mgrStatus }; +// "down" while the manager hands the report work (it's just starting), "up" while the report works +// for its manager, or the manager reviews what it sent back. Only reports working on the manager's +// current goal light up: not the ones its plan leaves out, nor work finished for an earlier goal. +function linkFlow(mgrId, kidId) { + const m = summary.agents[mgrId] ?? {}, k = summary.agents[kidId] ?? {}; + const ms = cardOf(mgrId)?.dataset.status, ks = cardOf(kidId)?.dataset.status; + if (ks === "starting") return { dir: "down", status: ks }; + if (BUSY.includes(ks)) return { dir: "up", status: ks }; + if (ms === "reviewing" && ks === "done" && m.since && k.since >= m.since) return { dir: "up", status: ms }; return null; } @@ -116,7 +119,7 @@ function drawLinks() { const bend = Math.max(40, Math.abs(y2 - y1) / 2); const path = document.createElementNS(SVG, "path"); path.setAttribute("d", `M${x1},${y1} C${x1},${y1 + bend} ${x2},${y2 - bend} ${x2},${y2}`); - const flow = linkFlow(p.el.dataset.status, a.el.dataset.status); + const flow = linkFlow(p.id, a.id); if (flow) { path.dataset.status = flow.status; path.classList.add("flow", flow.dir); @@ -421,19 +424,7 @@ async function removeAgents(ids) { } canvas.addEventListener("click", e => { if (e.target.classList.contains("x")) removeAgent(e.target.closest(".card").dataset.id); - if (e.target.matches(".acts button")) compactAgent(e); }); -// compactAgent shrinks (or with the clear button, drops) the feedback and notes carried into the agent's next prompt. -async function compactAgent(e) { - e.stopPropagation(); - const id = e.target.closest(".card").dataset.id, clear = e.target.classList.contains("clear"); - if (clear && !confirm(`Clear the feedback and notes carried into ${nameOf(id)}'s next prompt?`)) return; - try { - const r = await api("POST", `/api/agents/${id}/compact`, clear ? { clear: true } : undefined); - say(`${clear ? "cleared" : "compacted"} ${nameOf(id)}: ${fmtTok(r.before)} → ${fmtTok(r.after)} chars of carried context`); - } catch (err) { say(err.message, true); } - refreshSummary(); -} // Backspace or Delete removes the selected agent, unless the user is typing or a dialog is open document.addEventListener("keydown", e => { const t = e.target; @@ -797,7 +788,6 @@ $("#a-parent").onchange = e => { function showGoalState(g) { const mgr = parentOf(selected), team = kidsOf(selected); - $("#i-run").title = !mgr ? "Run the goals in its list" : "Run from its goal"; const canContinue = team.length > 0 && ["blocked", "failed"].includes(g.status); $("#i-continue").hidden = !canContinue; $("#i-run").classList.toggle("primary", !canContinue); // one primary action at a time @@ -831,12 +821,15 @@ function renderWorking(st) { ? "It's reading the repo and writing the plan. The plan shows up here for your approval; nothing runs until you approve it." : ""; } -// showStop enables Stop only while the selected agent is working or waiting on its plan. +// showStop enables Stop only while the selected agent is working or waiting on its plan, and +// Run, Continue and Compact only while it isn't. function showStop() { const s = selected && summary.agents[selected]?.status; const on = BUSY.includes(s) || s === "awaiting"; $("#i-stop").disabled = !on; $("#i-stop").title = on ? "Stop it (and its team)" : "Nothing to stop: it isn't running"; + $("#i-run").disabled = $("#i-continue").disabled = $("#i-compact").disabled = on; + $("#i-run").title = on ? `${nameOf(selected)} is running. Stop it first, or wait for it to finish.` : !parentOf(selected) ? "Run the goals in its list" : "Run from its goal"; } function renderStats() { @@ -932,7 +925,7 @@ $("#i-run").onclick = async e => { refreshSummary(); showTab("logs"); } catch (err) { runMsg(err.message, true); } - btn.disabled = false; + showStop(); }; // Request changes: re-run an agent that already worked with what the user wants different. // Request changes: after a run, send the agent back to change what isn't right, keeping its work. @@ -1017,6 +1010,18 @@ $("#i-clone").onclick = async () => { select(a.id); } catch (e) { say(e.message, true); } }; +// Compact context shrinks the notes on changes the user removed, the one thing every prompt of the +// agent carries from run to run, to each file's added and removed lines. +$("#i-compact").onclick = async () => { + $("#i-more").open = false; + const id = selected; + try { + const r = await api("POST", `/api/agents/${encodeURIComponent(id)}/compact`); + runMsg(!r.before ? `Nothing to compact: ${nameOf(id)}'s prompts carry no notes from earlier runs. Each run starts a fresh session.` + : r.after < r.before ? `Compacted ${nameOf(id)}'s notes on removed changes from ${fmtTok(r.before)} to ${fmtTok(r.after)} characters.` + : `${nameOf(id)}'s notes on removed changes are already compact (${fmtTok(r.after)} characters).`); + } catch (err) { runMsg(err.message, true); } +}; $("#i-stop").onclick = () => api("POST", `/api/agents/${selected}/stop`).then(() => runMsg("Stopping…")).catch(e => runMsg(e.message, true)); /* ---------- plan approval ---------- */ @@ -1107,7 +1112,7 @@ function renderPlan() { items.set(k.id, { on: !!x, f: x ? { ...structuredClone(x), checks: x.checks || [] } : d.kind === "revision" ? { agent: k.id, change: "", checks: [] } : { agent: k.id, title: "", body: "", criteria: "", checks: [] } }); } - planEdit = { draft: d.id, kind: d.kind, mgr: selected, items, open: planItems(d)[0]?.agent }; + planEdit = { draft: d.id, kind: d.kind, mgr: selected, items, open: null }; // every report starts folded $("#pr-feedback").value = ""; status($("#pr-msg"), ""); } @@ -1118,8 +1123,16 @@ const WEAK_CHECK = /^(true|:|exit 0|pwd|ls(\s.*)?|echo(\s.*)?)$/; function checkWarnings(kind, checks) { const ws = checks.filter(c => WEAK_CHECK.test(c)).map(c => `\`${c}\` can't fail, so it proves nothing.`); if (kind === "plan" && !checks.length) ws.unshift("No checks: nothing can prove this is done."); + else if (kind === "plan" && onlyGeneric(checks)) ws.push(GENERIC_WARNING); return ws; } +// onlyGeneric says whether every check is one that already passes before any work, like the whole test +// suite or a build: they prove nothing broke, not that the goal was met. +const GENERIC_WARNING = "These checks already pass before any work is done. Add one that fails until this goal is met, e.g. a test named for it."; +function onlyGeneric(checks) { + const generic = new Set((setupData?.checks || []).filter(c => !c.specific).map(c => c.cmd)); + return checks.length > 0 && checks.every(c => generic.has(c)); +} const checkLines = v => v.split("\n").map(l => l.trim()).filter(Boolean); function drawPlan() { @@ -1220,6 +1233,32 @@ $("#pr-cancel").onclick = async e => { decidePlan(btn, { action: "cancel" }, "Cancelling…", `${nameOf(planEdit.mgr)}'s plan was cancelled.`); }; +/* ---------- expand: edit a goal's details or criteria in a larger window ---------- */ + +let expanding = null; +document.addEventListener("click", e => { + const b = e.target.closest("button.expand"); + if (!b) return; + e.preventDefault(); + expanding = document.getElementById(b.dataset.for); + $("#expand-dialog h3").textContent = b.parentElement.firstChild.textContent.trim(); + $("#x-text").value = expanding.value; + $("#x-text").placeholder = expanding.placeholder; + $("#expand-dialog").showModal(); + $("#x-text").focus(); +}); +$("#x-text").onkeydown = e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); $("#expand-dialog").close("ok"); } }; +$("#expand-form").onsubmit = e => { e.preventDefault(); $("#expand-dialog").close(e.submitter?.value || "ok"); }; +$("#expand-dialog").onclose = () => { + if ($("#expand-dialog").returnValue === "ok" && expanding) { + expanding.value = $("#x-text").value; + expanding.dispatchEvent(new Event("input", { bubbles: true })); + expanding.focus(); + } + $("#expand-dialog").returnValue = ""; + expanding = null; +}; + /* ---------- queue: goals a top-level agent runs one after another ---------- */ let queue = null, editingItem = null; @@ -1570,15 +1609,23 @@ function showSuggestions(box, textarea) { if (!cs.length) { box.replaceChildren(); return; } const have = new Set(lines(textarea.value)); const todo = cs.filter(c => !have.has(c.cmd)); - if (!todo.length) { box.replaceChildren(); return; } - box.replaceChildren("Suggested:", ...todo.map(c => { + const weak = onlyGeneric(lines(textarea.value)) ? [el("span", "weak", "⚠ " + GENERIC_WARNING)] : []; + if (!todo.length) { box.replaceChildren(...weak); return; } + // the ones that prove this goal's work come first; the rest only prove nothing broke + box.replaceChildren(...weak, "Suggested:", ...todo.map(c => { const b = el("button", "", c.cmd); b.type = "button"; b.title = `Add this check: ${c.why}`; b.onclick = () => { - textarea.value = (textarea.value.trim() ? textarea.value.trimEnd() + "\n" : "") + c.cmd; + const before = textarea.value.trim() ? textarea.value.trimEnd() + "\n" : ""; + textarea.value = before + c.cmd; textarea.dispatchEvent(new Event("input", { bubbles: true })); showSuggestions(box, textarea); + if (c.edit) { // select the part to replace, e.g. the name of the test that proves this goal + const at = before.length + c.cmd.indexOf(c.edit); + textarea.focus(); + textarea.setSelectionRange(at, at + c.edit.length); + } }; return b; })); @@ -2141,9 +2188,21 @@ async function loadPreview() { const something = (p.commits && p.files) || p.uncommitted > 0; lines.push(el("span", "", p.commits && p.files ? `${p.commits} commit${p.commits > 1 ? "s" : ""} · ${p.files} file${p.files !== 1 ? "s" : ""} · +${p.adds} −${p.dels}` - : something ? "No checkpoints to merge yet." : `Nothing to merge: ${p.target} already has all of this work.`)); + : something ? "No checkpoints to merge yet." : `Nothing to merge: ${p.exists ? p.target : p.start} already has all of this work.`)); if (p.uncommitted) lines.push(el("p", "note", `Plus ${p.uncommitted} file${p.uncommitted > 1 ? "s" : ""} changed since the last checkpoint, not counted above. Merging saves ${p.uncommitted > 1 ? "them" : "it"} as a checkpoint first.`)); - if (!p.exists) lines.push(el("span", "", `Creates branch ${p.target} from ${p.start}.`)); + $("#m-branches").replaceChildren(...(p.branches || []).map(b => new Option(b))); + if (!p.exists) { // a typo would otherwise quietly put the work on a new branch nobody is looking at + const note = el("p", "note new"); + note.append(el("b", "", `${p.target} doesn't exist.`), ` Merging creates it from ${p.start}.`); + if (p.similar) { + const b = el("button", "", p.similar); + b.type = "button"; + b.onclick = () => { mform.elements.target.value = p.similar; loadPreview(); }; + note.append(" Did you mean ", b, "?"); + } + lines.push(note); + } + $("#m-go").textContent = p.exists ? "Merge" : `Create ${p.target} and merge`; if (p.checkedOut) lines.push(el("p", p.dirty ? "note bad" : "note", p.dirty ? `${p.target} is checked out in ${p.checkedOut} with uncommitted changes. Commit or stash them first.` : `${p.target} is checked out in ${p.checkedOut}; the files there will update.`)); @@ -2178,7 +2237,7 @@ mform.onsubmit = async e => { try { const r = await api("POST", `/api/agents/${selected}/merge`, { target: mform.elements.target.value.trim(), strategy: mform.elements.strategy.value, message: mform.elements.message.value }); - status($("#m-msg"), `Merged into ${r.target} as ${r.sha}.`); + status($("#m-msg"), r.created ? `Created branch ${r.target} and merged into it as ${r.sha}.` : `Merged into ${r.target} as ${r.sha}.`); say(`merged ${nameOf(selected)} into ${r.target}`); } catch (err) { status($("#m-msg"), err.message, true); } loadPreview(); @@ -2194,7 +2253,6 @@ function refreshCards() { card.dataset.status = st?.status ?? "idle"; const busy = BUSY.includes(card.dataset.status); card.classList.toggle("busy", busy); - card.querySelectorAll(".acts button").forEach(b => b.disabled = busy); card.querySelector(".now").textContent = st?.now || (st?.title ? "goal: " + st.title : "no goal yet"); const draft = plans.get(parentOf(a.id)), item = draft && planItems(draft).find(x => x.agent === a.id); card.classList.toggle("proposed", !!item); diff --git a/web/arrange.html b/web/arrange.html index 30263ec..84981c9 100644 --- a/web/arrange.html +++ b/web/arrange.html @@ -152,8 +152,6 @@ .card .now { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-height: 14px; } .card .badges { display: flex; gap: 8px; } .card .badges:empty { display: none; } -.card .acts { grid-column: 1 / -1; display: flex; gap: 4px; } -.card .acts button { font: 10px var(--mono); padding: 0 5px; } .card .badges .warn { color: var(--verifying); font-weight: 600; } .card .badges .over { color: var(--failed); font-weight: 600; } /* working agents: an outline in their status color plus a sweeping bar, so activity is visible at a glance */ .card.busy { border-color: var(--s); border-left-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--s) 22%, transparent); } @@ -250,6 +248,11 @@ .lout { margin: 10px 0 0; font-size: 14px; font-weight: 600; color: var(--s); line-height: 1.4; } .lnext { margin: 12px 0 0; font-size: 13px; color: var(--muted); } #revise-dialog { width: min(520px, 94vw); } +label .expand { float: right; border: 0; background: none; padding: 0; font-size: 12px; color: var(--accent); } +label .expand:hover { text-decoration: underline; } +#expand-dialog { width: min(900px, 94vw); } +#x-text { margin-top: 10px; min-height: 50vh; font-size: 14px; line-height: 1.5; } +.suggest .weak { flex-basis: 100%; color: var(--verifying); } #get-started { width: 100%; margin-top: 8px; } #empty-canvas { position: absolute; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 2; display: grid; justify-items: center; gap: 8px; padding: 24px 28px; background: var(--panel); border: 1px dashed var(--line); text-align: center; color: var(--muted); } @@ -335,13 +338,12 @@ .radios small { color: var(--muted); } #m-preview { font-size: 13px; margin-top: 12px; display: grid; gap: 6px; } #m-preview ul { margin: 0; padding-left: 18px; font: 12px var(--mono); } +#m-preview .new { border-left-color: var(--awaiting); } +#m-preview .new button { border: 0; background: none; padding: 0; color: var(--accent); font: 12px var(--mono); text-decoration: underline; } /* the way to iterate once an agent has run: say what should change, re-run only what it concerns */ #g-working { display: flex; align-items: center; gap: 2px; margin-top: 14px; padding: 8px 12px; border: 1px solid var(--s); border-left-width: 3px; background: color-mix(in srgb, var(--s) 8%, var(--panel)); font-weight: 600; } #g-working[hidden] { display: none; } -.help { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 8px; font-size: 12px; line-height: 1.35; color: var(--muted); } -.help li { display: flex; gap: 8px; align-items: flex-start; } -.help svg { width: 14px; height: 14px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } #queue { margin-top: 16px; } #q-actions { margin-top: 8px; } #q-actions[hidden] { display: none; } @@ -427,7 +429,6 @@ · - @@ -469,16 +470,6 @@

Agent types

-
    -
  • Drag a type onto the canvas to add an agent
  • -
  • Drop it on an agent to put it under that agent
  • -
  • Drop a box on another to change its manager
  • -
  • Drag a box's dots to another agent to connect them
  • -
  • Hover a connection and click its × to remove it
  • -
  • Double-click a box to rename it
  • -
  • Drag on empty space (or ⌘/Ctrl+A) to select boxes; Backspace removes them
  • -
  • Hold Space and drag, or scroll, to move around
  • -
{{if not .Project.Repo}}

This project has no git repository yet. Set one in Settings to run agents.

{{end}} {{with .Version}}

@@ -502,6 +493,7 @@

Agent types

...
@@ -522,8 +514,8 @@

Agent types