Skip to content
This repository was archived by the owner on Aug 1, 2026. It is now read-only.
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: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ csdd plan approve ──▶ csdd plan run ──▶ Pull Request

The session's verdict declares one of three intents: `done` (the whole feat is delivered and the session's own checks — `csdd spec validate`, `csdd graph analyze --strict`, the plan's Quality Gates — pass), `continue` (honest partial work; the summary is the handoff to the next session on the same feat), or `blocked` (the feat turned out to need another feat of this plan that has not landed — it parks without spending an attempt, and the discovered edge is recorded in `.csdd/plan/<slug>/discovered-deps.json`, never written back into `plan.md`).

What a session is handed is **the feat, not the method**. The brief carries the feat row, its governing refs, the seeds, the Executor Notes and the plan's Quality Gates; the development process — the session's authority to approve its own phases, the cycle, what it delegates, the git it owns and what makes a `done` acceptable — lives in the plan-session `CLAUDE.md` written into each worktree and in the `plan-dev` skill, both of which the session reads every turn anyway.

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 | 🟠 Major | ⚡ Quick win

Align this section with the canonical plan-session contract.

The surrounding text still says the session owns branches and opens PRs, while internal/templater/templates/plan/CLAUDE.md.tmpl says it must not create or switch branches, push, or open a PR. It also says the process lives in both CLAUDE.md and plan-dev, conflicting with the stated centralization in CLAUDE.md. Update this section so users and autonomous sessions receive one consistent contract.

🤖 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` at line 305, Update the README section around “What a session is
handed” to match the canonical plan-session contract in CLAUDE.md.tmpl: remove
claims that sessions own branches or open PRs, and state that process authority
is centralized in the plan-session CLAUDE.md rather than also living in
plan-dev. Preserve the distinction that sessions receive the feat and plan
inputs while following the worktree contract for development actions.


Before a feat is dispatched, a **context pass** (`--enrich-model`, default `sonnet`) reads its worktree once and records what the feat touches, which decisions and stack rows govern it, what is already there and what is not, into `.csdd/plan/<slug>/briefs/<feat>.json`. Every path and every `adr:`/`stack:` citation is checked against the workspace before it is stored — an unverifiable claim is dropped, not rendered — and the pack is keyed to the feat row, so a feat's later attempts reuse it instead of rediscovering the same tree at the orchestrator's price. `csdd plan brief <slug> --feat F --refresh` re-runs the pass and prints the brief, which is how you review (and, by editing the JSON, correct) what a session will be handed. `--enrich-model none` turns the pass off and briefs from the plan alone.

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.
Expand All @@ -323,6 +327,8 @@ csdd plan run <slug> # bypass-mode loop — alerts + asks
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
--enrich-model none # skip the per-feat context pass (default: sonnet)
csdd plan brief <slug> --feat F --refresh # re-run the context pass and print what the session gets
```

#### When a run ends without completing
Expand Down
36 changes: 33 additions & 3 deletions internal/cli/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func runPlan(args []string, templates embed.FS) int {

func planRun(args []string, templates embed.FS) int {
fs := flag.NewFlagSet("plan run", flag.ContinueOnError)
var root, model, effort string
var root, model, effort, enrichModel string
var autonomous, assumeYes, noTelegram bool
var sessionBudget float64
var maxIterations, stall, featAttempts, maxRetries, maxRepairs, squadLimit int
Expand All @@ -72,6 +72,7 @@ func planRun(args []string, templates embed.FS) int {
fs.BoolVar(&autonomous, "autonomous", false, "Deprecated no-op: plan run always runs bypass-mode (--dangerously-skip-permissions).")
fs.StringVar(&model, "model", "opus", "Model the orchestrating session runs on (claude --model): sonnet|opus|haiku|fable or a full model ID. It reviews and decides the spec; spec authoring is delegated to the `spec-author` sub-agent (sonnet) and task implementation to the `implementer` sub-agent (each on its own, cheaper model). Empty inherits the ambient default.")
fs.StringVar(&effort, "effort", "medium", "Reasoning effort the orchestrating session runs at (claude --effort): low|medium|high|xhigh|max. Empty inherits the ambient default.")
fs.StringVar(&enrichModel, "enrich-model", "sonnet", "Model the per-feat context pass runs on (claude --model). Before each feat is dispatched it reads the worktree once and records what the feat touches, what governs it and what is already there, so the orchestrator does not rediscover it every attempt. `none` turns the pass off and briefs from the plan alone.")
fs.Float64Var(&sessionBudget, "session-budget", 0, "Per-session cap in USD (claude --max-budget-usd). Default 0 = no cap; the session runs under the Claude account's own limits.")
fs.IntVar(&maxIterations, "max-iterations", 30, "Sessions the run may spend; one iteration is one claude session.")
fs.IntVar(&stall, "stall", 10, "Stop early after this many consecutive sessions without a step advancing.")
Expand Down Expand Up @@ -100,6 +101,11 @@ func planRun(args []string, templates embed.FS) int {
render.Err(fmt.Sprintf("--squad-limit must be between 1 and %d (got %d)", planSquadLimitMax, squadLimit))
return 1
}
// `none` is how a human spells "no enrichment pass"; the runner reads an empty
// model as disabled.
if strings.EqualFold(strings.TrimSpace(enrichModel), "none") {
enrichModel = ""
}
if autonomous {
render.Warn("--autonomous is deprecated and now a no-op: plan run always runs bypass-mode")
}
Expand Down Expand Up @@ -136,6 +142,7 @@ func planRun(args []string, templates embed.FS) int {
FeatAttempts: featAttempts,
SessionIdle: sessionIdle,
SquadLimit: squadLimit,
EnrichModel: enrichModel,
WorktreeEntry: planEntryDoc(templates),
Out: os.Stdout,
})
Expand Down Expand Up @@ -516,15 +523,18 @@ func planNext(args []string) int {

func planBrief(args []string) int {
fs := flag.NewFlagSet("plan brief", flag.ContinueOnError)
var root, feat string
var root, feat, enrichModel string
var refresh bool
addRoot(fs, &root)
fs.StringVar(&feat, "feat", "", "Feat to brief (default: the sequencer's next feat).")
fs.BoolVar(&refresh, "refresh", false, "Re-run the context pass for this feat before printing, replacing its stored pack. This is the same pass `plan run` makes before dispatching a feat, so it is how you review — and, by editing .csdd/plan/<slug>/briefs/<feat>.json, correct — what a session will be handed.")
fs.StringVar(&enrichModel, "enrich-model", "sonnet", "Model --refresh runs the context pass on (claude --model).")
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " plan brief SLUG [--feat F]")
render.Err("usage: " + prog() + " plan brief SLUG [--feat F] [--refresh]")
return 1
}
r, doc, code := resolvePlan(root, positionals[0])
Expand All @@ -551,6 +561,26 @@ func planBrief(args []string) int {
}
target = f
}
// --refresh runs the pass against the workspace itself rather than a worktree:
// there is no run in flight to have cut one, and the tree a human is looking at
// is the tree they are asking about. Diagnostics go to stderr so the brief on
// stdout stays a clean artifact to pipe or diff.
if refresh {
if err := plan.RemovePack(r, doc.Slug, target.Slug); err != nil {
render.Err(err.Error())
return 1
}
hook := plan.EnrichHook(enrichModel)
if hook == nil {
render.Err("--refresh needs a model: pass --enrich-model")
return 1
}
Comment on lines +568 to +577

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

--refresh deletes the stored pack before validating the model.

With --enrich-model none (or empty), RemovePack has already run when the nil-hook check fails, so the user loses the pack — including one they hand-edited, which the --refresh help text explicitly invites. Build and validate the hook first.

🔧 Proposed reorder
 	if refresh {
+		hook := plan.EnrichHook(enrichModel)
+		if hook == nil {
+			render.Err("--refresh needs a model: pass --enrich-model")
+			return 1
+		}
 		if err := plan.RemovePack(r, doc.Slug, target.Slug); err != nil {
 			render.Err(err.Error())
 			return 1
 		}
-		hook := plan.EnrichHook(enrichModel)
-		if hook == nil {
-			render.Err("--refresh needs a model: pass --enrich-model")
-			return 1
-		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if refresh {
if err := plan.RemovePack(r, doc.Slug, target.Slug); err != nil {
render.Err(err.Error())
return 1
}
hook := plan.EnrichHook(enrichModel)
if hook == nil {
render.Err("--refresh needs a model: pass --enrich-model")
return 1
}
if refresh {
hook := plan.EnrichHook(enrichModel)
if hook == nil {
render.Err("--refresh needs a model: pass --enrich-model")
return 1
}
if err := plan.RemovePack(r, doc.Slug, target.Slug); err != nil {
render.Err(err.Error())
return 1
}
🤖 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 `@internal/cli/plan.go` around lines 568 - 577, In the refresh flow around
plan.EnrichHook, build and validate the enrichment hook before calling
plan.RemovePack. If the hook is nil, return the existing --refresh model error
without deleting the stored pack; only invoke RemovePack after validation
succeeds.

if plan.EnsurePack(r, doc, target, r, hook, func(format string, a ...any) {
render.Warn(strings.TrimSpace(fmt.Sprintf(format, a...)))
}) == nil {
render.Warn("the context pass produced nothing for " + target.Slug + "; briefing from the plan alone")
}
}
out, err := plan.FeatBrief(r, doc, target)
if err != nil {
render.Err(err.Error())
Expand Down
3 changes: 0 additions & 3 deletions internal/cli/plan_adr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,6 @@ func TestADRValidateE2E(t *testing.T) {
if strings.Contains(out, "BODY_TOKEN") {
t.Errorf("brief must NOT inline the ADR body (the gate enforces citation now):\n%s", out)
}
if !strings.Contains(out, "docs/stack.md Decided row") {
t.Errorf("brief should carry the record-your-decisions mission line")
}

// Broken citation: rewrite the ADR to a different slug.
mustWrite(t, adrPath("0001-pick-store.md"), "# renamed\n\nbody\n") // still slug pick-store
Expand Down
9 changes: 6 additions & 3 deletions internal/cli/plan_bridge_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,16 @@ func TestPlanNextAndBriefCLI(t *testing.T) {
if !strings.Contains(out, `"feat": "upload"`) {
t.Errorf("unexpected next feat: %s", out)
}
// brief prints the whole-feat mission pack for that feat.
// brief prints the feat's mission pack: the feat itself, what governs it and the
// plan's gates — the development process lives in the worktree's CLAUDE.md.
code, out, errOut := run(t, "plan", "brief", "photos", "--root", dir)
if code != 0 {
t.Fatalf("plan brief failed (code=%d): %s", code, errOut)
}
if !strings.Contains(out, "Mission — photos / upload") || !strings.Contains(out, "Forbidden actions") {
t.Errorf("brief output incomplete: %s", out)
for _, want := range []string{"# Feat: upload — plan photos", "Objective:", "Quality gates for this plan"} {
if !strings.Contains(out, want) {
t.Errorf("brief output is missing %q: %s", want, out)
}
}
}

Expand Down
5 changes: 0 additions & 5 deletions internal/plan/adr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,6 @@ func TestBriefListsGoverningADRs(t *testing.T) {
if !strings.Contains(out, "adr:ghost-ref") || !strings.Contains(out, "WARNING") {
t.Errorf("brief should warn on the unresolved ADR ref:\n%s", out)
}
// The mission tells the session to record any technology / hard-to-reverse
// trade-off it makes (stack row + ADR), never adopt one silently.
if !strings.Contains(out, "docs/stack.md Decided row") {
t.Errorf("brief should carry the record-your-decisions mission line:\n%s", out)
}
}

func joinIssues(issues []validator.Issue) string {
Expand Down
Loading
Loading