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
31 changes: 31 additions & 0 deletions internal/cli/cli_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,37 @@ func TestSpecGenerateBugfixAndResearch(t *testing.T) {
}
}

// TestSpecGenerateTasksShapeFollowsFlow guards the flow-aware tasks scaffold:
// under `unit` the generated tasks.md is implementation-then-cover (no RED/GREEN
// pair); under `tdd` it is the RED→GREEN pair. Generating the wrong shape would
// hand the author a tasks.md `csdd spec validate` immediately rejects.
func TestSpecGenerateTasksShapeFollowsFlow(t *testing.T) {
for _, tc := range []struct {
flow string
wantRedGreen bool
}{
{"unit", false},
{"tdd", true},
} {
t.Run(tc.flow, func(t *testing.T) {
dir := freshWorkspace(t)
if code, _, _ := run(t, "spec", "init", "feat", "--flow", tc.flow, "--root", dir); code != 0 {
t.Fatalf("spec init --flow %s failed", tc.flow)
}
// tasks normally needs design approved; --force bypasses the gate so we
// exercise only the template-selection branch.
if code, _, _ := run(t, "spec", "generate", "feat", "--artifact", "tasks", "--force", "--root", dir); code != 0 {
t.Fatalf("spec generate tasks failed")
}
tasks := readFile(t, filepath.Join(dir, "specs", "feat", "tasks.md"))
hasRedGreen := strings.Contains(tasks, "RED —") || strings.Contains(tasks, "GREEN —")
if hasRedGreen != tc.wantRedGreen {
t.Errorf("flow %q: RED/GREEN present=%v, want %v", tc.flow, hasRedGreen, tc.wantRedGreen)
}
})
}
}

// TestSpecGenerateAuxiliaryDoesNotClobberPhase guards the fix where research and
// bugfix (auxiliary, ungated artifacts) must not reset the phase chain or the
// approvals state advanced by the main requirements/design/tasks flow.
Expand Down
229 changes: 223 additions & 6 deletions internal/cli/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/protonspy/csdd/internal/paths"
Expand Down Expand Up @@ -45,14 +46,18 @@ func runPlan(args []string, templates embed.FS) int {
case "generate":
return planGenerate(rest, templates)
case "run":
return planRun(rest)
return planRun(rest, templates)
case "cost":
return planCost(rest)
case "verify":
return planVerify(rest)
default:
render.Err("unknown plan action: " + action)
return 1
}
}

func planRun(args []string) int {
func planRun(args []string, templates embed.FS) int {
fs := flag.NewFlagSet("plan run", flag.ContinueOnError)
var root, model, effort string
var autonomous, assumeYes, noTelegram bool
Expand All @@ -63,13 +68,13 @@ func planRun(args []string) int {
fs.BoolVar(&assumeYes, "yes", false, "Skip the unverified-sandbox prompt: accept running --dangerously-skip-permissions even when `sandbox doctor` fails.")
fs.BoolVar(&noTelegram, "no-telegram", false, "Do not auto-start the Telegram notifier even when a bot is configured (.csdd/bot.json).")
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 authors and decides the spec; task implementation is delegated to the `implementer` sub-agent (which runs on its own, cheaper model). Empty inherits the ambient default.")
fs.StringVar(&effort, "effort", "high", "Reasoning effort the orchestrating session runs at (claude --effort): low|medium|high|xhigh|max. Empty inherits the ambient default.")
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.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", 100, "Sessions the run may spend; one iteration is one claude session.")
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.")
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 8). Bounds a feat whose `done` the verdict gate keeps refusing.")
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(&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.")
Expand Down Expand Up @@ -129,6 +134,7 @@ func planRun(args []string) int {
FeatAttempts: featAttempts,
SessionIdle: sessionIdle,
SquadLimit: squadLimit,
WorktreeEntry: planEntryDoc(templates),
Out: os.Stdout,
})
if err != nil {
Expand Down Expand Up @@ -613,3 +619,214 @@ func printPlanStatus(st plan.PlanStatus) {
fmt.Printf(" %-3s %-*s %-13s %-10s %s\n", f.Num, maxName, f.Slug, f.State, f.Milestone, progress)
}
}

// planCost reports what a run spent, read back out of sessions.jsonl.
//
// The file has recorded every attempt's cost since R9.2 and nothing read it back,
// so the only way to see a run's spend was to parse JSONL by hand. Exit code stays
// 0 whatever the numbers say — this reports, it does not judge.
func planCost(args []string) int {
fs := flag.NewFlagSet("plan cost", flag.ContinueOnError)
var root string
var jsonOut bool
addRoot(fs, &root)
addJSON(fs, &jsonOut)
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " plan cost SLUG [--json]")
return 1
}
r, doc, code := resolvePlan(root, positionals[0])
if code != 0 {
return code
}
rep := plan.BuildCostReport(r, doc.Slug)
if jsonOut {
return emitJSON(rep)
}
printCostReport(rep)
return 0
}

func printCostReport(rep plan.CostReport) {
fmt.Printf("plan: %s\n", rep.Plan)
if rep.Totals.Attempts == 0 {
render.Info("no sessions recorded yet — this plan has not been run")
return
}
fmt.Printf(" %-28s %8s %8s %6s %10s %12s\n", "feat", "attempts", "settled", "gated", "cost", "tokens")
for _, f := range rep.Feats {
fmt.Printf(" %-28s %8d %8d %6d %10s %12s%s\n",
f.Feat, f.Attempts, f.Settled, f.Gated, money(f.CostUSD), thousands(f.Tokens.Total()), deliveredMark(f.Delivered))
}
fmt.Printf(" %-28s %8d %8d %6d %10s %12s\n",
"TOTAL", rep.Totals.Attempts, rep.Totals.Settled, rep.Totals.Gated,
money(rep.Totals.CostUSD), thousands(rep.Totals.Tokens.Total()))

t := rep.Totals.Tokens
fmt.Printf("\n tokens: %s fresh input · %s output · %s cache read · %s cache write\n",
thousands(t.Input), thousands(t.Output), thousands(t.CacheRead), thousands(t.CacheCreation))

if len(rep.ByModel) > 0 {
fmt.Printf("\n by model\n")
for _, m := range rep.ByModel {
fmt.Printf(" %-34s %12s %10s\n", m.Model, thousands(m.Tokens.Total()), money(m.CostUSD))
}
// The two figures come from different fields of the same event: the
// envelope's own `usage` block, and the CLI's per-model aggregation. A gap
// between them is work the top-level figure did not count, which on a plan
// run means the sub-agents. Reporting both is the honest move — asserting
// which one is right would be a guess.
if mt := rep.ModelTotal().Total(); mt != rep.Totals.Tokens.Total() {
fmt.Printf(" %-34s %12s\n", "(per-model total)", thousands(mt))
render.Warn(fmt.Sprintf("the per-model breakdown and the session totals disagree by %s tokens; "+
"the session total counts the orchestrating chain, the breakdown counts every model that billed",
thousands(abs(mt-rep.Totals.Tokens.Total()))))
}
}

if n := len(rep.Unmeasured); n > 0 {
render.Warn(fmt.Sprintf("%d attempt(s) were opened and never settled: %s", n, strings.Join(rep.Unmeasured, ", ")))
fmt.Println(" Those sessions ran and spent money; the run was interrupted before their cost")
fmt.Println(" could be recorded, so every figure above understates the real total.")
}
if rep.Totals.Gated > 0 {
fmt.Printf("\n %d of %d settled session(s) were paid for and handed back (a refused `done`).\n",
rep.Totals.Gated, rep.Totals.Settled)
}
}

// planVerify proves — or fails to prove — that each feat was delivered, by
// cross-checking the ledger, the on-disk artifacts, git, and the worktrees.
// Findings exit 2, the convention every lint/validate command in this CLI follows.
func planVerify(args []string) int {
fs := flag.NewFlagSet("plan verify", flag.ContinueOnError)
var root string
var jsonOut bool
addRoot(fs, &root)
addJSON(fs, &jsonOut)
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " plan verify SLUG [--json]")
return 1
}
r, doc, code := resolvePlan(root, positionals[0])
if code != 0 {
return code
}
rep := plan.Verify(r, doc)
if jsonOut {
if !rep.OK {
_ = emitJSON(rep)
return 2
}
return emitJSON(rep)
}
printVerifyReport(rep)
if !rep.OK {
return 2
}
return 0
}

func printVerifyReport(rep plan.VerifyReport) {
fmt.Printf("plan: %s\n", rep.Plan)
if rep.Git {
fmt.Printf("merges checked against: %s\n", rep.Branch)
} else {
render.Warn("not a git repository (or detached HEAD) — merge evidence cannot be checked")
}
fmt.Printf(" %-28s %-18s %-7s %-10s %-7s %s\n", "feat", "state", "ledger", "artifacts", "merged", "worktree")
for _, f := range rep.Feats {
fmt.Printf(" %-28s %-18s %-7s %-10s %-7s %s\n",
f.Feat, f.State, yesNo(f.LedgerDone), yesNo(f.Artifacts), mergedCell(rep.Git, f.Merged, f.MergeCommit), liveCell(f.LiveWorktree))
}
var findings int
for _, f := range rep.Feats {
for _, msg := range f.Findings {
findings++
render.Err(msg)
}
}
if findings == 0 {
render.OK("every feat's records agree")
return
}
fmt.Printf("\n%d finding(s): the records disagree about what was delivered.\n", findings)
}

func deliveredMark(done bool) string {
if done {
return " ✓"
}
return ""
}

func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}

func mergedCell(git, merged bool, commit string) string {
if !git {
return "—"
}
if merged {
return commit
}
return "no"
}

func liveCell(live bool) string {
if live {
return "live"
}
return "—"
}

func money(usd float64) string { return fmt.Sprintf("$%.2f", usd) }

func abs(n int) int {
if n < 0 {
return -n
}
return n
}

// thousands groups an integer with thin separators so a seven-digit token count is
// readable at a glance, which is the only reason these numbers are printed at all.
func thousands(n int) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
return s
}
var b strings.Builder
for i, r := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte('.')
}
b.WriteRune(r)
}
return b.String()
}

// planEntryDoc is the lean CLAUDE.md each feat's worktree gets for the duration of
// the run. A template that will not render is not worth failing a run over: the
// sessions fall back to the repository's own file, which is more expensive to read
// and still correct enough to work from.
func planEntryDoc(templates embed.FS) string {
doc, err := templater.PlanEntry(templates)
if err != nil {
render.Warn("could not load the plan-session CLAUDE.md; sessions will read the repository's own: " + err.Error())
return ""
}
return doc
}
16 changes: 12 additions & 4 deletions internal/cli/plan_adr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ status: draft

// TestADRValidateE2E drives the decision-ref lint through the real CLI: a plan
// citing a present ADR validates clean; broken, ambiguous, and cites-superseded
// variants each fail with exit 2; and the brief inlines the cited record in full.
// variants each fail with exit 2; and the brief lists the cited record as a short
// ref (it no longer inlines the body — the gate enforces citation instead).
func TestADRValidateE2E(t *testing.T) {
dir := freshWorkspace(t)
if code, _, e := run(t, "plan", "init", "photos", "--root", dir); code != 0 {
Expand All @@ -85,13 +86,20 @@ func TestADRValidateE2E(t *testing.T) {
t.Fatalf("plan citing a present ADR should validate clean: %s", e)
}

// The brief inlines the cited ADR in full.
// The brief lists the governing ADR as a short ref and directs fetching the
// body via the graph; it must NOT inline the ADR title or body.
code, out, _ := run(t, "plan", "brief", "photos", "--feat", "upload", "--root", dir)
if code != 0 {
t.Fatalf("plan brief failed: %d", code)
}
if !strings.Contains(out, "Store under docs/adr") || !strings.Contains(out, "BODY_TOKEN") {
t.Errorf("brief should inline the cited ADR in full:\n%s", out)
if !strings.Contains(out, "adr:pick-store") {
t.Errorf("brief should list the governing ADR ref:\n%s", out)
}
if !strings.Contains(out, "csdd graph explain adr:") {
t.Errorf("brief should direct the session to fetch the ADR via graph explain:\n%s", out)
}
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")
Expand Down
21 changes: 16 additions & 5 deletions internal/cli/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,16 @@ var knownSpecKeys = []string{
}

// defaultDevelopmentFlow is the flow assumed when none is selected and steering
// declares no default. It keeps csdd's test-first posture as the default.
const defaultDevelopmentFlow = "tdd"
// declares no default. `unit` is the default: implementation first, with a unit
// test covering the behavior in the same task — the lightest posture that still
// keeps every behavior tested. `tdd` (RED→GREEN) is REQUIRED for money, auth,
// tenancy, and anything irreversible; set it explicitly (steering default or
// --flow) where those guarantees matter.
const defaultDevelopmentFlow = "unit"

// developmentFlows is the closed set of selectable development flows:
// - unit: tests written after the code (no RED-first ritual)
// - tdd: test-first RED→GREEN (the default)
// - unit: implementation first, then a unit test covering it in the same task (the default)
// - tdd: test-first RED→GREEN — required for money/auth/tenancy/irreversible surfaces
// - tdd-e2e: TDD plus end-to-end coverage of golden and error flows
var developmentFlows = []string{"unit", "tdd", "tdd-e2e"}

Expand Down Expand Up @@ -228,7 +232,7 @@ func specInit(args []string, templates embed.FS) int {
var opts SpecInitOptions
addRoot(fs, &opts.Root)
fs.StringVar(&opts.Language, "language", "en", "Spec language (default: en).")
fs.StringVar(&opts.Flow, "flow", "", "Development flow: unit|tdd|tdd-e2e (default: steering default, else tdd).")
fs.StringVar(&opts.Flow, "flow", "", "Development flow: unit|tdd|tdd-e2e (default: steering default, else unit).")
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
Expand Down Expand Up @@ -481,6 +485,13 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error {
"bugfix": {"bugfix.md", "templates/spec/bugfix.md.tmpl"},
}
pair := templateMap[opts.Artifact]
// tasks.md is shaped by the spec's development_flow: the RED/GREEN pair under
// tdd/tdd-e2e, implementation-then-cover under unit. Scaffolding the wrong shape
// would hand the author a tasks.md the validator immediately rejects, so pick
// the template the declared flow will accept.
if opts.Artifact == "tasks" && effectiveFlow(data.DevelopmentFlow) == "unit" {
pair[1] = "templates/spec/tasks-unit.md.tmpl"
}
Comment on lines +492 to +494

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

Normalize the flow before selecting the tasks template.

validator.specDevelopmentFlow trims development_flow, so a value such as " " resolves to unit. SpecGenerate calls effectiveFlow without trimming, falls through to the TDD template, and produces RED/GREEN tasks that validation rejects. Normalize in effectiveFlow and add a whitespace-only regression test.

Proposed fix
 func effectiveFlow(f string) string {
+	f = strings.TrimSpace(f)
 	if f == "" {
 		return defaultDevelopmentFlow
 	}
 	return f
 }
🤖 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/spec.go` around lines 492 - 494, Update effectiveFlow to trim
surrounding whitespace from development_flow before determining the effective
flow, keeping template selection in SpecGenerate consistent with
validator.specDevelopmentFlow. Add a regression test covering a whitespace-only
development_flow and verify it selects the unit tasks template rather than the
TDD template.

target := filepath.Join(sdir, pair[0])
if pathExists(target) && !opts.Force {
return fmt.Errorf("%s already exists. Use --force to overwrite", target)
Expand Down
Loading
Loading