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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,9 @@ Before a feat is dispatched, a **context pass** (`--enrich-model`, default `sonn

Feats are handed out in **dependency order**, not table order: the `Depends` column the plan already declares is what schedules the run, so a feat is only dispatched once everything it builds on is delivered. If a feat is blocked, everything downstream of it is reported as unreachable with the root cause named, instead of being dispatched into a tree where its foundation does not exist.

`--squad-limit N` (1..6, default 1) runs up to **N sessions at once, each on its own feat and each in its own git worktree**. Filesystem isolation is what makes it safe: every concurrent session gets a worktree under `.csdd/plan/<slug>/trees/<feat>` on branch `csdd/<slug>/<feat>`, so no two agents ever share an index, a build, or a half-written file. Scheduling is the `Depends` graph alone — the `(P)` column is **not** consulted, because it used to mean "consents to share a working tree" and feats no longer share one.
**A serial run works in your checkout.** At `--squad-limit 1` (the default) one session runs at a time, in the repository itself — the environment your suite needs is already installed there. The worktrees below are what a *squad* pays for isolation: a worktree carries only tracked files, so it starts without `node_modules/`, `.venv/` or any other build cache git ignores, and rebuilding those per feat buys a serial run nothing, since it has no peer to collide with.

`--squad-limit N` (2..6) runs up to **N sessions at once, each on its own feat and each in its own git worktree**. Filesystem isolation is what makes it safe: every concurrent session gets a worktree under `.csdd/plan/<slug>/trees/<feat>` on branch `csdd/<slug>/<feat>`, so no two agents ever share an index, a build, or a half-written file. Scheduling is the `Depends` graph alone — the `(P)` column is **not** consulted, because it used to mean "consents to share a working tree" and feats no longer share one.
Comment on lines +311 to +313

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scope the Git precondition to squad runs.

Line 319 still says plan run always refuses non-Git, detached, or dirty workspaces, but serial runs bypass preflightGit. Qualify that statement as applying to --squad-limit 2..6 worktree runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 311 - 313, Update the README guidance around the
`plan run` Git precondition to clarify that refusal of non-Git, detached, or
dirty workspaces applies only to squad runs using `--squad-limit 2..6`; preserve
the existing explanation that serial runs bypass `preflightGit`.


Because a plan is a DAG, isolation alone is not enough: `c` depends on `a` and `b`, and a worktree cut from an untouched base would contain neither. So the runner **merges a feat into the run's base the moment its `done` clears the verdict gate**, and every later worktree is cut from that updated base. The branch survives the merge; the worktree is removed. The human PR gate still sits where it always did, after the run, over the base branch — the session commits on its feat's branch and does not open one.

Expand All @@ -326,7 +328,7 @@ csdd plan status <slug> # feats, milestones, what's next
csdd plan run <slug> # bypass-mode loop — alerts + asks if the sandbox isn't verified
csdd plan run <slug> --yes # pre-accept the unverified-sandbox alert (non-interactive)
--session-budget 5 # per-session USD cap · --max-iterations (100) · --stall (10)
--squad-limit 3 # run up to 3 sessions at once (1..6, default 1), one git worktree each
--squad-limit 3 # 3 sessions at once, one git worktree each (default 1: serial, in this checkout)
--enrich-model none # skip the per-feat context pass (default: sonnet)
csdd plan brief <slug> --feat F # print what the session gets (enriches on first use)
csdd plan brief <slug> --feat F --refresh # discard the stored context pack and run the pass again
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func planRun(args []string, templates embed.FS) int {
fs.IntVar(&stall, "stall", 10, "Stop early after this many consecutive sessions without a step advancing.")
fs.DurationVar(&sessionIdle, "session-idle", 0, "Kill a session that makes no progress — no event stream output and no CPU — for this long (default 15m). Not a time limit: real work of any duration keeps resetting it.")
fs.IntVar(&featAttempts, "feat-attempts", 0, "Stop handing out ONE feat after this many sessions and surface it as blocked (default 4). Bounds a feat whose `done` the verdict gate keeps refusing.")
fs.IntVar(&squadLimit, "squad-limit", 0, "Maximum claude sessions running at once, each on its own feat in its own git worktree (1..6, default 1). Feats run together whenever the plan's Depends graph allows it; each delivered feat is merged into the run's base branch. Requires a clean git repository.")
fs.IntVar(&squadLimit, "squad-limit", 0, "Maximum claude sessions running at once (1..6, default 1). At 1 the run is serial and works in this checkout, where the environment your suite needs is already installed. Above 1 each feat gets its own git worktree — cut from and merged back into the run's base branch, which requires a clean repository, and which starts without anything git ignores (node_modules/, .venv/, build caches).")
fs.IntVar(&maxRetries, "max-retries", 0, "Deprecated no-op: each iteration is one session, and the next iteration is the retry.")
fs.IntVar(&maxRepairs, "max-repairs", 0, "Deprecated no-op: the self-correcting loop replaced repair sessions.")
positionals, err := parseFlags(fs, args)
Expand Down
50 changes: 37 additions & 13 deletions internal/plan/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,24 +200,15 @@ func Run(opts RunOptions) (RunSummary, error) {
if !h.ClaudeAvailable() {
return RunSummary{}, fmt.Errorf("the `claude` CLI is not available on PATH; the runner needs it to spawn sessions")
}
// Every feat now works in its own git worktree, so a repository is no longer
// incidental to a run — it is the isolation mechanism. Resolve the base the run
// integrates into and refuse anything that would make merging feats back
// ambiguous. Skipped when a treeKeeper was injected: the loop's own tests drive
// the whole run against plain directories and no git at all.
// Where the feats work. Skipped when a treeKeeper was injected: the loop's own
// tests drive the whole run against plain directories and no git at all.
if opts.Hooks.Trees == nil {
base, err := preflightGit(opts.Root)
keeper, err := resolveTrees(opts, logf)
if err != nil {
return RunSummary{}, err
}
opts.Hooks.Trees = gitTrees{root: opts.Root, slug: opts.Slug, base: base, logf: logf, entry: opts.WorktreeEntry}
opts.Hooks.Trees = keeper
h = opts.Hooks
logf("worktrees: one per feat, branched from and merged back into %s", base)
if specsAreIgnored(opts.Root) {
logf("⚠ specs/ is gitignored: each feat's spec will stay in the worktree that authored it and be")
logf(" discarded with it. The code lands on %s; the artifacts justifying it will not, so", base)
logf(" `csdd plan status` and the graph will disagree with the ledger after this run.")
}
}
// Every session runs bypass-mode (--dangerously-skip-permissions), so a
// verified sandbox is the expected home (principle 7). When doctor fails, the
Expand Down Expand Up @@ -1107,6 +1098,39 @@ func journalDone(opts RunOptions, feat, summary string) {
journal(opts, feat, "done", summary)
}

// resolveTrees picks where the run's feats work, and the squad size is what decides
// it.
//
// A worktree per feat exists to keep concurrent sessions off one shared index. That
// is a real hazard with a squad and no hazard at all with one session — while the
// cost is the same either way, and it is not small: a worktree holds only tracked
// files, so a serial run was reinstalling every gitignored dependency the suite needs
// before it could run a single gate, per feat and again per attempt. So a serial run
// (the default) works in the repository itself, and only a squad pays for isolation.
//
// The git preflight moves with the worktrees, deliberately. It resolves the base
// feats are cut from and merged back into, and refuses a repository where that would
// be ambiguous — questions an in-place run does not ask, because it lands nothing:
// the session commits on the branch the human left it on.
func resolveTrees(opts RunOptions, logf func(string, ...any)) (treeKeeper, error) {
if opts.SquadLimit <= 1 {
logf("worktrees: off — one session at a time, working in %s itself", opts.Root)
logf(" (--squad-limit >1 isolates each feat in its own worktree, cut from and merged back into the base)")
return inPlaceTrees{root: opts.Root}, nil
}
base, err := preflightGit(opts.Root)
if err != nil {
return nil, err
}
logf("worktrees: one per feat, branched from and merged back into %s", base)
if specsAreIgnored(opts.Root) {
logf("⚠ specs/ is gitignored: each feat's spec will stay in the worktree that authored it and be")
logf(" discarded with it. The code lands on %s; the artifacts justifying it will not, so", base)
logf(" `csdd plan status` and the graph will disagree with the ledger after this run.")
}
return gitTrees{root: opts.Root, slug: opts.Slug, base: base, logf: logf, entry: opts.WorktreeEntry}, nil
}

// runLogf is the runner's line-oriented logger onto opts.Out.
func runLogf(opts RunOptions) func(string, ...any) {
return func(format string, a ...any) { _, _ = fmt.Fprintf(opts.Out, format+"\n", a...) }
Expand Down
22 changes: 22 additions & 0 deletions internal/plan/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ type treeKeeper interface {
Discard(feat string) error
}

// inPlaceTrees is the keeper for a serial run: every feat works in the repository
// itself, on the branch the human is already standing on.
//
// A worktree contains only TRACKED files, so everything a suite needs and git
// ignores is absent from it — node_modules/, .venv/, build caches, the lot. On a
// real project that is not a detail: the session cannot run a test, a lint or a
// build until it has rebuilt the environment, and it pays that per feat and again
// per attempt. The isolation this buys is worth it for a squad, where several
// sessions would otherwise share one index. With ONE session there is no peer to
// collide with, so the run pays the cost and gets nothing for it.
//
// Integrate and Discard are therefore no-ops rather than stubs: the work is already
// on the branch (nothing to land) and no directory belongs to the feat (nothing to
// remove). The plan-session CLAUDE.md swap is deliberately absent too — it works by
// marking the file skip-worktree, and doing that to the entry file a human maintains,
// in the checkout they work in, would hide their own file from them.
type inPlaceTrees struct{ root string }

func (t inPlaceTrees) Ensure(string) (string, error) { return t.root, nil }
func (t inPlaceTrees) Integrate(string) error { return nil }
func (t inPlaceTrees) Discard(string) error { return nil }

// MergeConflictError signals that a delivered feat could not be landed on the run's
// base because it conflicts with what is already there.
//
Expand Down
57 changes: 57 additions & 0 deletions internal/plan/tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,3 +525,60 @@ func containsSubstring(hay []string, needle string) bool {
}
return false
}

// TestSerialRunWorksInTheCheckoutItself pins which keeper a run gets, because the
// squad size is the only thing that decides it and the difference is not cosmetic.
//
// A worktree carries only tracked files, so a serial run in one was reinstalling
// every gitignored dependency its suite needs — per feat, and again per attempt —
// to buy isolation from peers it does not have. At --squad-limit 1 the feats work
// in the repository itself; above it, isolation is what the concurrency is paying
// for and the worktrees come back.
func TestSerialRunWorksInTheCheckoutItself(t *testing.T) {
root := gitRepo(t)
writeRepoFile(t, root, "README.md", "# repo\n")
commitAll(t, root, "init")
quiet := func(string, ...any) {}

keeper, err := resolveTrees(RunOptions{Root: root, Slug: "p", SquadLimit: 1}, quiet)
if err != nil {
t.Fatal(err)
}
inPlace, ok := keeper.(inPlaceTrees)
if !ok {
t.Fatalf("a serial run must work in the checkout, got %T", keeper)
}
// Every feat resolves to the repository itself, and neither landing nor cleanup
// has anything to do: the work is already on the branch the human is standing on.
for _, feat := range []string{"a", "b"} {
dir, err := inPlace.Ensure(feat)
if err != nil {
t.Fatal(err)
}
if dir != root {
t.Errorf("feat %s should work in %s, got %s", feat, root, dir)
}
if err := inPlace.Integrate(feat); err != nil {
t.Errorf("integrate should be a no-op: %v", err)
}
if err := inPlace.Discard(feat); err != nil {
t.Errorf("discard should be a no-op: %v", err)
}
}
// Nothing was created under the run's state dir — no worktree, no branch.
if entries, err := os.ReadDir(filepath.Join(stateDir(root, "p"), "trees")); err == nil && len(entries) > 0 {
t.Errorf("a serial run must cut no worktrees, found %d", len(entries))
}
if out, _ := runGit(root, "branch", "--list", "csdd/*"); strings.TrimSpace(out) != "" {
t.Errorf("a serial run must create no feat branches, got %q", out)
}

// A squad still gets the worktrees, and with them the git preflight.
squad, err := resolveTrees(RunOptions{Root: root, Slug: "p", SquadLimit: 2}, quiet)
if err != nil {
t.Fatal(err)
}
if _, ok := squad.(gitTrees); !ok {
t.Errorf("a squad must be isolated per feat, got %T", squad)
}
}
Loading