-
-
-
- Tech Talk · Development Team
- csdd
- Claude Spec-Driven Development as an executable contract
- A single binary that turns the SDD workflow for Claude Code from "good intentions in markdown"
- into a mechanically validated contract — for humans and AI agents.
-
-
-
1
binary, zero runtime deps
-
-
-
-
-
-
- The problem
- AI agents write code fast.
Too fast to ship without a contract.
-
-
🎯Scope that slips
-
Without an explicit, testable requirement, the agent "interprets" — and every run interprets differently. There is no baseline to review against.
-
🔗Zero traceability
-
Code with no link to a requirement. Nobody knows why a function exists, or what breaks if it changes.
-
⚖️Human review too late
-
The human only sees the result in the PR — when reverting is expensive. The decision should be visible before the code.
-
- The answer is SDD — Spec-Driven Development: a contract layer
- (requirements → design → tasks) with human approval at every gate.
- csdd is the tool that makes that contract impossible to break by accident.
-
-
-
-
- What csdd is
- CLI + TUI in a single Go binary
- The only sanctioned author of the workflow's artifacts. You don't hand-edit frontmatter,
- spec.json or task annotations — you generate from a template, edit the body, and validate.
-
-
-
🖥️ CLI — for agents & automation
-
Flag-driven, headless. Exposes 100% of the functionality
- so Claude Code, Cursor, Codex or CI can drive the binary without reading the source.
-
csdd spec generate photo-albums --artifact requirements
-
-
-
⌨️ TUI — for humans
-
Interactive interface (Bubble Tea). csdd with no arguments
- opens wizards and an artifact browser. Same operations, same rules.
-
csdd # no args → interactive TUI
-
-
- 🔑 Core principle: both surfaces call the same operation helpers.
- A single source of truth — what a human does in the TUI, an agent does identically via the CLI.
-
-
-
-
- The 5 resources csdd governs
- Everything an agent needs to work safely
-
-
🧭steering
-
Project memory loaded into every agent interaction. Standards and the why behind decisions.
-
.claude/steering/*.md
-
📐spec
-
Per-feature contract: spec.json + requirements + design + tasks (+ research/bugfix).
-
specs/<feature>/
-
🛠️skill
-
Executable workflow bundle: SKILL.md + references + assets + scripts.
-
.claude/skills/<name>/
-
🤖agent
-
Custom sub-agent with a least-privilege tool scope (reviewer, debugger…).
-
.claude/agents/<name>.md
-
🔌mcp
-
Model Context Protocol servers the agent can connect to. stdio or remote, never both.
-
.mcp.json
-
-
⌨️verbs per resource
-
Common base create/init · list · show · delete. spec adds generate · approve · validate · status; mcp uses add · remove · enable · disable · validate; skill adds add-reference/script/asset · validate.
-
-
-
-
-
- Mental model · read this first
- Two distinctions matter more than anything
-
-
-
📜 Specification — the contract
-
The requirements, the File Structure Plan in the design, and the
- _Boundary:_ / _Depends:_ annotations on the tasks.
-
👤 Humans review and approve this.
-
-
-
🧩 Design — the implementation space
-
Components, internals, sequencing within each task.
- How the contract is fulfilled.
-
🤖 The agent is free here, after approval.
-
-
- The second distinction is the phase gates: no phase is
- generated before the previous one is approved by a human — and that is enforced mechanically, not by convention.
-
-
-
-
- Phase gates · the heart of the flow
- Four phases, three human gates
-
-
-
→
-
human gategenerate
Requirements
-
→
-
-
→
-
-
→
-
-
-
-
-
State lives in spec.json. Generating design while
- requirements is not approved fails — it's not a warning, it's exit code 2.
-
- - ready_for_implementation only becomes
true after all 3 approvals.
- - --force breaks the gate — only with explicit human authorization (Quick Plan).
-
-
-
csdd spec generate albums --artifact design
-✗ phase gate: 'requirements' must be
- approved before generating 'design'.
-
-# the right path:
-csdd spec approve albums --phase requirements
-✓ requirements approved
-
-
-
-
-
- A feature's lifecycle
- From idea to ready to implement
- # 1 · bootstrap (once per repo)
-csdd init --with-baseline
-
-# 2 · create the feature workspace
-csdd spec init photo-albums
-
-# 3 · requirements → edit in EARS → validate → approve
-csdd spec generate photo-albums --artifact requirements
-csdd spec validate photo-albums # exit 2 = fix what it flags
-csdd spec approve photo-albums --phase requirements
-
-# 4 · design (blocked until step 3 passes) → 5 · tasks (same)
-csdd spec generate photo-albums --artifact design # ... validate, approve
-csdd spec generate photo-albums --artifact tasks # ... validate, approve
-
-✓ spec.json: ready_for_implementation = true # implementation can begin
- 💡 csdd spec status <feature> between any two steps: phase + approvals + validation issues on a single screen.
-
-
-
-
- The conventions the validator enforces
- EARS & task annotations
-
-
-
📝 Requirements in EARS
-
Fixed, testable syntax, one behavior per criterion.
- SHALL — never should. Unique N.M IDs.
-
### Requirement 1: Album Management
-1. WHEN a user creates an album
- THEN the system SHALL persist it <500ms.
-2. IF the name is empty
- THEN the system SHALL return 400.
-3. WHILE deleting THE SYSTEM SHALL
- block new uploads.
-
-
-
✅ Annotated tasks (not a todo-list)
-
Each leaf traces requirements; parallelism is declared and verified.
-
- [ ] 2. AlbumService _Boundary: AlbumService_
- - [ ] 2.1 create / rename / delete
- _Requirements: 1.1, 1.2_
-- [ ] 3. PhotoService _Boundary: PhotoService_ (P)
- - [ ] 3.1 upload S3
- _Requirements: 2.1_
- _Depends: 1.2_
-
- _Requirements:_ every leaf
- _Boundary:_ every (P)
- _Depends:_ across boundaries
-
-
-
- (P) = runs in parallel. Two (P) tasks cannot
- share a boundary — the validator rejects it, guaranteeing safe parallel execution by agents.
-
-
-
-
- Implementation phase · one task at a time
- Once the gate is open, the agent implements in TDD
-
-
test first1 · write the test
RED
-
→
-
-
→
-
3 · clean under green
REFACTOR
-
→
-
4 · suite + lint
widen the net
-
-
-
-
🔴 Skill tdd-cycle
-
- - One leaf task per invocation. Takes the ID from
specs/<f>/tasks.md; doesn't batch tasks "to save time".
- - RED fails for the right reason. A compile error doesn't count — it cites the failure before moving on.
- - Never weakens a test to make the suite pass. New behavior = new RED.
-
-
-
-
✅ verify-change + Definition of Done
-
Before reporting done: run the executable checks and produce real evidence — "compiles" and "looks right" are not done.
-
# evidence pasted into the report / PR
-go test ./... ✓
-lint ✓
-typecheck ✓
-build ✓
-
-
- Each leaf traces _Requirements:_ → the test proves the requirement.
- Evidence beats assertion.
-
-
-
-
- From done code to PR · fixed order, with evidence
- review → commit → push → PR
-
-
read-only sub-agents1 · adversarial
code-reviewer
-
→
-
2 · only after clean
/csdd-commit
-
→
-
pre-push gate3 · tests run here
git push
-
→
-
-
-
-
-
🔎 Adversarial review — skill pr-review
-
- code-reviewer runs on the diff; resolve every Blocker before moving on.
- security-reviewer if it touches auth / secrets / input — resolve Critical/High.
- - Reviewers don't write — you apply the fix and re-review until clean.
-
-
-
-
✍️ /csdd-commit + pre-push gate
-
# Conventional Commits, generated from diff + spec
-feat(photo-albums): add album rename
-
-Implements photo-albums; tasks 2.1, 2.2.
-
-# git push → hook runs the suite; red BLOCKS
-git push
-✗ pre-push: test gate failed — push blocked
-
-
- Never commit with an open Blocker; never git push --no-verify.
- The PR carries evidence: spec links · completed tasks · real check output · risks.
-
-
-
-
- Architecture · flow view
- Two surfaces, one core
-
-
main.go
no args → TUI · with args → CLI
-
│
-
-
cmd/ · CLI
dispatcher + 1 file per resource
-
tui/ · TUI
Bubble Tea · wizards + browser
-
-
└──── both call the SAME operation helpers ────┘
-
│
-
-
workspace
resolves .claude/ · kebab-case
-
-
validator
mechanical checks
-
templater
go:embed templates
-
frontmatter
minimal YAML parser
-
-
-
│
-
artifacts on disk
.claude/ · specs/ · CLAUDE.md · .mcp.json — plain text, reviewable in a PR
-
-
-
-
-
- Architecture · each package's role
- Where each responsibility lives
-
- | Package | Responsibility | Why it matters |
-
- cmd/ | CLI surface. Dispatches resource action, flag parsing, 1 file per resource. Includes CLAUDE.md and .gitignore wiring. | The public contract — 100% of functionality, headless. |
- tui/ | Interactive front-end Bubble Tea: menu, wizards, artifact browser. | Calls the same helpers as cmd. No duplicated logic. |
- internal/workspace | Resolves the .claude/ root by walking up the tree; validates kebab-case; enumerates phases and artifacts. | Defines what a workspace is and the valid names. |
- internal/paths | Centralizes the on-disk layout: .claude/, CLAUDE.md, .mcp.json, specs/. | The layout lives in exactly one place. |
- internal/validator | The mechanical checks: EARS, unique IDs, traceability, annotations, parallelism safety, skill structure. | The agent's "friend." Never asks for judgment — only true/false. Exit 2. |
- internal/templater | Renders templates embedded at compile time (go:embed). | A fully self-contained binary — zero runtime dependencies. |
- internal/frontmatter | Parser for a minimal subset of YAML (scalars, bool, inline arrays). | Does only what's needed — small, predictable surface. |
- internal/render | Terminal output helpers with color (respects NO_COLOR/TTY). | Consistent ✓ ✗ ! • messages in the CLI. |
-
-
-
-
-
-
- Architecture decisions worth highlighting
- Four deliberate choices
-
-
① CLI = TUI, always
-
Both surfaces converge on the same helpers. There is no function only the TUI can do — which is why a headless agent has 100% of the power.
-
② Embedded templates
-
go:embed all:templates compiles the templates into the binary. You download one file and it works offline, with nothing to install.
-
③ Mechanical, not opinionated, validation
-
The validator never asks for judgment: either the criterion starts with WHEN or it doesn't. Deterministic → an agent trusts the exit code.
-
④ Artifacts are plain text
-
Everything becomes versionable markdown/JSON in .claude/ and specs/. Review happens in the PR, with the tools the team already uses.
-
- The result: the CLI never stops you from doing the right thing — it stops you from doing
- the wrong thing without making the decision visible. Breaking a gate requires an explicit --force, and that shows up in history.
-
-
-
-
- What each validation gate catches
- The validator is the safety net
-
-
spec · requirements
-
- - Every criterion starts with WHEN/WHILE/IF/WHERE/THE SYSTEM
- - None uses
should
- - Unique
### Requirement N: headers
-
-
spec · design
-
- - Boundary Map and File Structure Plan sections present
- - Every requirement ID appears in the traceability table
- design.md ≤ 1000 lines (else, split the spec)
-
-
spec · tasks
-
- - Every leaf has _Requirements:_ with real IDs
- - Every (P) has a _Boundary:_ that matches the design
- - No (P) pair shares a boundary
-
-
skill · mcp · steering
-
- - SKILL.md ≤ 500 lines / ~5k tokens; refs cited
- - mcp: exactly 1 transport (stdio or url)
- - steering: valid
inclusion; fileMatch has a pattern
-
-
- Exit codes: 0 ok · 1 usage error · 2 validation failure. Scriptable in CI.
-
-
-
-
- How this reaches our day-to-day
- Native to Claude Code — no conversion layer
-
-
-
The workspace csdd writes is the layout Claude Code expects.
- csdd init bootstraps it and handles the wiring:
-
# native layout, written straight to disk
-CLAUDE.md # entry point + steering imports
-.claude/steering/*.md # @-referenced from CLAUDE.md
-.claude/agents/*.md # sub-agents (Read, Grep…)
-.claude/skills/<n>/ # skill bundles
-.claude/commands/ # slash commands (/csdd-commit)
-.claude/hooks/ # deterministic automation
-specs/<feature>/ # SDD contracts
-.mcp.json # MCP servers
-
Creating a steering automatically inserts
- @.claude/steering/<name> into a managed block of CLAUDE.md — idempotent, never clobbering manual edits.
-
-
-
What the team gains
-
- - Zero friction with Claude Code. Artifacts are read natively — no exporting or converting.
- - Review where we already work. Specs and steering are text in a PR — diff, comment, approve.
- - Least privilege by default. Sub-agents are born with
Read, Grep; MCP with a restricted scope.
- - CI validates the contract. A
csdd spec validate in the pipeline blocks a broken spec before merge.
-
-
-
-
-
-
-
- Takeaways
- The validator is your friend.
The gate makes the decision visible.
-
-
📐Contract before code
-
requirements → design → tasks, each approved by a human before the next.
-
⚙️Always generate from a template
-
Never hand-write frontmatter or spec.json. Generate, edit the body, validate.
-
🛡️Least privilege
-
Agents and MCP with the minimum scope. The more power, the smaller the scope and the more review.
-
- Suggested next step: run csdd init --with-baseline on a real feature
- and take the first spec all the way to ready_for_implementation together.
- Questions? · csdd · thank you 🙌
-
-
-
-
-
-
-
-
-
diff --git a/internal/cli/agent.go b/internal/cli/agent.go
index 58bb31d..3419cc9 100644
--- a/internal/cli/agent.go
+++ b/internal/cli/agent.go
@@ -21,6 +21,10 @@ func runAgent(args []string, templates embed.FS) int {
render.Err(err.Error())
return 1
}
+ if isHelpFlag(action) {
+ help(os.Stdout)
+ return 0
+ }
switch action {
case "create":
return agentCreate(rest, templates)
@@ -46,6 +50,7 @@ type AgentCreateOptions struct {
Tools []string
Model string
Effort string // optional; empty = omit (inherit session config)
+ Color string // optional; empty = omit (no display color)
Title string
Force bool
}
@@ -57,8 +62,9 @@ func agentCreate(args []string, templates embed.FS) int {
addRoot(fs, &opts.Root)
fs.StringVar(&opts.Description, "description", "", "When the orchestrator should pick this agent.")
fs.Var(&tools, "tools", "Tool name (repeatable). Default: Read, Grep.")
- fs.StringVar(&opts.Model, "model", "", "Optional model override (e.g., sonnet, opus, haiku).")
+ fs.StringVar(&opts.Model, "model", "", "Optional model override (e.g., sonnet, opus, haiku, fable, or a full model ID).")
fs.StringVar(&opts.Effort, "effort", "", "Optional effort: low|medium|high|xhigh|max.")
+ fs.StringVar(&opts.Color, "color", "", "Optional display color: red|blue|green|yellow|purple|orange|pink|cyan.")
fs.StringVar(&opts.Title, "title", "", "Document title (default: derived from name).")
addForce(fs, &opts.Force)
positionals, err := parseFlags(fs, args)
@@ -86,10 +92,13 @@ func AgentCreate(templates embed.FS, opts AgentCreateOptions) error {
if err := workspace.KebabCheck(opts.Name, "agent"); err != nil {
return err
}
- // Validate effort before writing any file, so an invalid value creates nothing.
+ // Validate effort and color before writing any file, so an invalid value creates nothing.
if err := validateEffort(opts.Effort); err != nil {
return err
}
+ if err := validateColor(opts.Color); err != nil {
+ return err
+ }
r, err := workspace.Resolve(opts.Root)
if err != nil {
return err
@@ -114,6 +123,7 @@ func AgentCreate(templates embed.FS, opts AgentCreateOptions) error {
"Tools": toolsStr,
"Model": opts.Model,
"Effort": opts.Effort,
+ "Color": opts.Color,
"Title": title,
})
if err != nil {
@@ -166,6 +176,9 @@ func agentList(args []string) int {
}
func findAgent(root, name string) (string, bool) {
+ if err := workspace.SafeName(name, "agent"); err != nil {
+ return "", false
+ }
base := workspace.AgentRoot(root)
candidate := filepath.Join(base, name+".md")
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
diff --git a/internal/cli/agent_color_test.go b/internal/cli/agent_color_test.go
new file mode 100644
index 0000000..8edf080
--- /dev/null
+++ b/internal/cli/agent_color_test.go
@@ -0,0 +1,42 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestAgentCreateColor(t *testing.T) {
+ dir := freshWorkspace(t)
+ code, _, errOut := run(t, "agent", "create", "color-agent",
+ "--description", "d", "--color", "purple", "--root", dir)
+ if code != 0 {
+ t.Fatalf("agent create with color failed (code=%d): %s", code, errOut)
+ }
+ body := readAgentMD(t, dir, "color-agent")
+ if !strings.Contains(body, "color: purple") {
+ t.Errorf("agent missing color line:\n%s", body)
+ }
+}
+
+func TestAgentCreateNoColorNoLine(t *testing.T) {
+ dir := freshWorkspace(t)
+ _, _, _ = run(t, "agent", "create", "plain-color-agent", "--description", "d", "--root", dir)
+ body := readAgentMD(t, dir, "plain-color-agent")
+ if strings.Contains(body, "color:") {
+ t.Errorf("agent without --color must not carry a color line:\n%s", body)
+ }
+}
+
+func TestAgentCreateInvalidColorRejected(t *testing.T) {
+ dir := freshWorkspace(t)
+ code, _, _ := run(t, "agent", "create", "bad-color-agent",
+ "--description", "d", "--color", "teal", "--root", dir)
+ if code != 1 {
+ t.Errorf("invalid --color should exit 1, got %d", code)
+ }
+ if _, err := os.Stat(filepath.Join(dir, ".claude/agents/bad-color-agent.md")); !os.IsNotExist(err) {
+ t.Errorf("invalid --color must not create the agent file (err=%v)", err)
+ }
+}
diff --git a/internal/cli/claudemd.go b/internal/cli/claudemd.go
index f010816..910f506 100644
--- a/internal/cli/claudemd.go
+++ b/internal/cli/claudemd.go
@@ -5,6 +5,8 @@ import (
"strings"
"github.com/protonspy/csdd/internal/paths"
+ "github.com/protonspy/csdd/internal/textutil"
+ "github.com/protonspy/csdd/internal/workspace"
)
// Markers delimiting the csdd-managed steering import block inside CLAUDE.md.
@@ -24,7 +26,10 @@ func ensureSteeringImports(root string, names ...string) (int, error) {
if err != nil {
return 0, nil // no CLAUDE.md yet — nothing to wire
}
- text := string(data)
+ // Normalize line endings before matching: on a CRLF CLAUDE.md the existing
+ // import line is "imp\r\n", which neither dedup check below would match, so a
+ // duplicate @-import was appended on every run.
+ text := textutil.NormalizeNewlines(string(data))
start := strings.Index(text, steeringMarkerStart)
end := strings.Index(text, steeringMarkerEnd)
if start == -1 || end == -1 || end < start {
@@ -52,7 +57,7 @@ func ensureSteeringImports(root string, names ...string) (int, error) {
lines = append([]string{existing}, additions...)
}
rebuilt := text[:blockStart] + "\n" + strings.Join(lines, "\n") + "\n" + text[end:]
- if err := os.WriteFile(entry, []byte(rebuilt), 0o644); err != nil {
+ if err := workspace.AtomicWrite(entry, []byte(rebuilt), 0o644); err != nil {
return 0, err
}
return len(additions), nil
diff --git a/internal/cli/clean.go b/internal/cli/clean.go
index f38b2bc..e6770ac 100644
--- a/internal/cli/clean.go
+++ b/internal/cli/clean.go
@@ -26,6 +26,10 @@ func runClean(args []string, templates embed.FS) int {
if err := fset.Parse(args); err != nil {
return failOnFlagParse(err)
}
+ if err := rejectPositionals("clean", fset); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
r, err := workspace.Resolve(root)
if err != nil {
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index 4122e3b..a54c4a2 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -116,7 +116,7 @@ func prog() string {
}
func help(w *os.File) {
- fmt.Fprintln(w, strings.TrimSpace(helpText()))
+ _, _ = fmt.Fprintln(w, strings.TrimSpace(helpText()))
}
func helpText() string {
@@ -233,6 +233,16 @@ func parseAction(resource string, args []string) (string, []string, error) {
return args[0], args[1:], nil
}
+// rejectPositionals errors when a command that takes no positional arguments
+// received some, so e.g. `csdd init mydir` (a user expecting `git init DIR`
+// semantics) fails loudly instead of silently operating on the cwd.
+func rejectPositionals(cmd string, fs *flag.FlagSet) error {
+ if extra := fs.Args(); len(extra) > 0 {
+ return fmt.Errorf("`%s %s` takes no positional arguments; got %q (did you mean a flag like --root?)", prog(), cmd, extra[0])
+ }
+ return nil
+}
+
// failOnFlagParse short-circuits when a flag parse fails.
func failOnFlagParse(err error) int {
if err == nil {
@@ -245,6 +255,12 @@ func failOnFlagParse(err error) int {
return 1
}
+// isHelpFlag reports whether an action token is really a request for help, so
+// `csdd spec --help` prints usage instead of erroring with "unknown action".
+func isHelpFlag(s string) bool {
+ return s == "-h" || s == "--help" || s == "help"
+}
+
// containsString reports whether needle is in haystack.
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
diff --git a/internal/cli/cli_extra_test.go b/internal/cli/cli_extra_test.go
index 31dbba8..aa8cb71 100644
--- a/internal/cli/cli_extra_test.go
+++ b/internal/cli/cli_extra_test.go
@@ -6,6 +6,7 @@ import (
"flag"
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
)
@@ -367,6 +368,9 @@ func TestSteeringListAuthorTruncation(t *testing.T) {
// It works by chmod'ing the workspace tree read-only after init, so any
// subsequent write attempt fails at the syscall layer. Skipped under root.
func TestWriteOperationsAgainstReadOnlyRoot(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -419,6 +423,9 @@ func TestWriteOperationsAgainstReadOnlyRoot(t *testing.T) {
// TestSteeringInitWriteFailure forces steeringInit down its SafeWrite error
// branch by deleting the baseline files and then locking the directory.
func TestSteeringInitWriteFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -439,6 +446,9 @@ func TestSteeringInitWriteFailure(t *testing.T) {
// TestSpecGenerateAndApproveWriteFailures cover SpecGenerate's WriteFile
// branch and SpecApprove's saveSpecJSON branch by locking the spec dir.
func TestSpecGenerateAndApproveWriteFailures(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -459,6 +469,9 @@ func TestSpecGenerateAndApproveWriteFailures(t *testing.T) {
// Making the parent .claude/skills read-only is enough — RemoveAll cannot
// unlink entries from a directory it has no write permission on.
func TestSkillDeleteFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -476,6 +489,9 @@ func TestSkillDeleteFailure(t *testing.T) {
// TestAgentDeleteFailure mirrors TestSkillDeleteFailure for agents.
func TestAgentDeleteFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -493,6 +509,9 @@ func TestAgentDeleteFailure(t *testing.T) {
// TestSteeringDeleteFailure mirrors the delete-failure pattern for steering.
func TestSteeringDeleteFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -510,6 +529,9 @@ func TestSteeringDeleteFailure(t *testing.T) {
// TestSpecDeleteFailure covers specDelete's os.RemoveAll error branch.
func TestSpecDeleteFailure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -530,6 +552,9 @@ func TestSpecDeleteFailure(t *testing.T) {
// (`skill list` is intentionally excluded: a missing/unreadable skills dir is a
// valid "no skills found" result, not an error — SkillRoot no longer creates it.)
func TestSkillRootFailures(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -557,6 +582,9 @@ func TestSkillRootFailures(t *testing.T) {
// TestAgentRootFailures mirrors TestSkillRootFailures for the agent commands.
func TestAgentRootFailures(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("chmod-based permission restriction is a no-op on Windows")
+ }
if os.Geteuid() == 0 {
t.Skip("running as root bypasses chmod; skipping")
}
@@ -733,7 +761,7 @@ func TestInitScaffoldsClaudeCodeArtifacts(t *testing.T) {
}
for _, exe := range []string{".claude/hooks/block-destructive.sh", ".githooks/pre-push"} {
info, err := os.Stat(filepath.Join(dir, exe))
- if err != nil || info.Mode()&0o111 == 0 {
+ if err != nil || (runtime.GOOS != "windows" && info.Mode()&0o111 == 0) {
t.Errorf("%s must be executable: err=%v mode=%v", exe, err, info.Mode())
}
}
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 5616568..ce9d35a 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -504,7 +504,7 @@ func TestSpecInitAndShow(t *testing.T) {
if code != 0 {
t.Fatalf("spec init: %d", code)
}
- if !strings.Contains(out, "specs/photo-albums/") {
+ if !strings.Contains(filepath.ToSlash(out), "specs/photo-albums/") {
t.Errorf("expected path in output: %s", out)
}
if _, err := os.Stat(filepath.Join(dir, "specs/photo-albums/spec.json")); err != nil {
diff --git a/internal/cli/color.go b/internal/cli/color.go
new file mode 100644
index 0000000..720d58f
--- /dev/null
+++ b/internal/cli/color.go
@@ -0,0 +1,28 @@
+package cli
+
+import (
+ "fmt"
+ "strings"
+)
+
+// agentColors is the closed, case-sensitive set of display colors Claude Code
+// honors in a sub-agent's `color` frontmatter field (shown in the task list and
+// transcript). Like effortLevels, it is the single source of truth for both the
+// membership check and the error message, so the two cannot drift.
+var agentColors = []string{"red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"}
+
+// validateColor reports whether a color value may be written to frontmatter. The
+// empty string is accepted and means "omit the key" (no color assigned); any
+// other value must appear in agentColors exactly. It never touches the
+// filesystem, so callers run it before creating any artifact.
+func validateColor(color string) error {
+ if color == "" {
+ return nil
+ }
+ for _, c := range agentColors {
+ if color == c {
+ return nil
+ }
+ }
+ return fmt.Errorf("invalid --color %q: must be one of %s", color, strings.Join(agentColors, ", "))
+}
diff --git a/internal/cli/color_test.go b/internal/cli/color_test.go
new file mode 100644
index 0000000..0415960
--- /dev/null
+++ b/internal/cli/color_test.go
@@ -0,0 +1,39 @@
+package cli
+
+import "testing"
+
+// TestValidateColor pins the closed, case-sensitive color set Claude Code honors
+// in a sub-agent's `color` frontmatter. The empty string is accepted (it means
+// "omit the key"); anything else must be one of the eight names exactly.
+func TestValidateColor(t *testing.T) {
+ cases := []struct {
+ name string
+ color string
+ wantErr bool
+ }{
+ {"empty means omit", "", false},
+ {"red", "red", false},
+ {"blue", "blue", false},
+ {"green", "green", false},
+ {"yellow", "yellow", false},
+ {"purple", "purple", false},
+ {"orange", "orange", false},
+ {"pink", "pink", false},
+ {"cyan", "cyan", false},
+ {"out of set", "teal", true},
+ {"wrong case Red", "Red", true},
+ {"hex not allowed", "#ff0000", true},
+ {"whitespace", " red", true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := validateColor(tc.color)
+ if tc.wantErr && err == nil {
+ t.Errorf("validateColor(%q): expected error, got nil", tc.color)
+ }
+ if !tc.wantErr && err != nil {
+ t.Errorf("validateColor(%q): expected nil, got %v", tc.color, err)
+ }
+ })
+ }
+}
diff --git a/internal/cli/copy.go b/internal/cli/copy.go
index d81e784..d196933 100644
--- a/internal/cli/copy.go
+++ b/internal/cli/copy.go
@@ -50,7 +50,13 @@ func runCopy(args []string, templates embed.FS) int {
var force bool
addRoot(fset, &root)
addForce(fset, &force)
- if err := fset.Parse(args); err != nil {
+ // Use parseFlags (not fset.Parse) so flags may follow the positional, e.g.
+ // `csdd copy skills/foo --force`. Plain Parse stops at the first non-flag and
+ // would leave --force unparsed, so --force was silently ignored — and the
+ // documented `csdd --root PATH copy …` form (rewritten to trailing --root)
+ // operated on the wrong workspace.
+ rest, err := parseFlags(fset, args)
+ if err != nil {
return failOnFlagParse(err)
}
@@ -65,7 +71,6 @@ func runCopy(args []string, templates embed.FS) int {
}
kinds := copyKinds()
- rest := fset.Args()
if len(rest) == 0 {
return listCopyable(templates, kinds, "")
}
diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go
index e22b1df..f9216d3 100644
--- a/internal/cli/destroy.go
+++ b/internal/cli/destroy.go
@@ -33,6 +33,10 @@ func runDestroy(args []string, _ embed.FS) int {
if err := fset.Parse(args); err != nil {
return failOnFlagParse(err)
}
+ if err := rejectPositionals("destroy", fset); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
r, err := workspace.Resolve(root)
if err != nil {
diff --git a/internal/cli/gitignore.go b/internal/cli/gitignore.go
index 8e083a1..5375df3 100644
--- a/internal/cli/gitignore.go
+++ b/internal/cli/gitignore.go
@@ -4,18 +4,25 @@ import (
"os"
"path/filepath"
"strings"
+
+ "github.com/protonspy/csdd/internal/workspace"
)
// pinggyTokenFile is the workspace-local store for the pinggy Pro access token.
// It is a secret — never committed — so it is always gitignored.
const pinggyTokenFile = ".pinggy-token"
+// pinggyKnownHostsFile is the workspace-local TOFU known_hosts the tunnel writes
+// for pinggy ssh host-key pinning. Not a secret, but workspace-local churn that
+// should not be committed.
+const pinggyKnownHostsFile = ".pinggy-known_hosts"
+
// gitignoreTargets lists the root-level csdd artifacts that should not be
-// committed — the compiled binary and the pinggy token secret. The binary is
-// added only when present; the token file is always ignored because
-// `csdd web --pinggy-token` may create it later.
+// committed — the compiled binary, the pinggy token secret, and the pinggy
+// known_hosts. The binary is added only when present; the pinggy files are
+// always ignored because `csdd web` may create them later.
func gitignoreTargets(root string) []string {
- targets := []string{pinggyTokenFile}
+ targets := []string{pinggyTokenFile, pinggyKnownHostsFile}
for _, name := range []string{"csdd", "csdd.exe"} {
if pathExists(filepath.Join(root, name)) {
targets = append(targets, name)
@@ -67,7 +74,7 @@ func ensureGitignore(root string, names []string) ([]string, error) {
for _, e := range toAdd {
b.WriteString(e + "\n")
}
- if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
+ if err := workspace.AtomicWrite(path, []byte(b.String()), 0o644); err != nil {
return nil, err
}
return toAdd, nil
diff --git a/internal/cli/gitignore_test.go b/internal/cli/gitignore_test.go
index 8433867..81aff3e 100644
--- a/internal/cli/gitignore_test.go
+++ b/internal/cli/gitignore_test.go
@@ -128,15 +128,16 @@ func TestEnsureGitignoreRespectsExistingBareEntry(t *testing.T) {
// only the artifacts actually present at root.
func TestGitignoreTargetsOnlyExisting(t *testing.T) {
dir := t.TempDir()
- // The pinggy token secret is always ignored, even before it exists.
- if got := gitignoreTargets(dir); len(got) != 1 || got[0] != ".pinggy-token" {
- t.Errorf("empty dir should yield [.pinggy-token], got %v", got)
+ // The pinggy token secret and known_hosts are always ignored, even before
+ // they exist (csdd web may create them later).
+ if got := gitignoreTargets(dir); len(got) != 2 || got[0] != ".pinggy-token" || got[1] != ".pinggy-known_hosts" {
+ t.Errorf("empty dir should yield [.pinggy-token .pinggy-known_hosts], got %v", got)
}
if err := os.WriteFile(filepath.Join(dir, "csdd"), []byte("ELF"), 0o755); err != nil {
t.Fatal(err)
}
got := gitignoreTargets(dir)
- if len(got) != 2 || got[0] != ".pinggy-token" || got[1] != "csdd" {
- t.Errorf("expected [.pinggy-token csdd], got %v", got)
+ if len(got) != 3 || got[2] != "csdd" {
+ t.Errorf("expected [.pinggy-token .pinggy-known_hosts csdd], got %v", got)
}
}
diff --git a/internal/cli/hardening_test.go b/internal/cli/hardening_test.go
new file mode 100644
index 0000000..3e2f8c2
--- /dev/null
+++ b/internal/cli/hardening_test.go
@@ -0,0 +1,134 @@
+package cli
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestSpecDeleteRejectsTraversal is the regression for the highest-severity
+// finding: `csdd spec delete .. --force` must not RemoveAll the workspace root.
+func TestSpecDeleteRejectsTraversal(t *testing.T) {
+ dir := freshWorkspace(t)
+ sentinel := filepath.Join(dir, "CLAUDE.md")
+ if _, err := os.Stat(sentinel); err != nil {
+ t.Fatalf("precondition: CLAUDE.md should exist: %v", err)
+ }
+ for _, bad := range []string{"..", "../..", "../src"} {
+ code, _, errOut := run(t, "spec", "delete", bad, "--force", "--root", dir)
+ if code == 0 {
+ t.Errorf("spec delete %q should fail, got code 0", bad)
+ }
+ if !strings.Contains(errOut, "invalid") {
+ t.Errorf("spec delete %q should report an invalid name, got %q", bad, errOut)
+ }
+ }
+ if _, err := os.Stat(sentinel); err != nil {
+ t.Fatalf("workspace was damaged by a traversal delete: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, ".claude")); err != nil {
+ t.Fatalf(".claude/ was damaged by a traversal delete: %v", err)
+ }
+}
+
+func TestSkillDeleteRejectsTraversal(t *testing.T) {
+ dir := freshWorkspace(t)
+ code, _, _ := run(t, "skill", "delete", "..", "--force", "--root", dir)
+ if code == 0 {
+ t.Error("skill delete .. should not succeed")
+ }
+ if _, err := os.Stat(filepath.Join(dir, ".claude", "skills")); err != nil {
+ t.Fatalf(".claude/skills was damaged: %v", err)
+ }
+}
+
+func TestSteeringDeleteRejectsTraversal(t *testing.T) {
+ dir := freshWorkspace(t)
+ code, _, _ := run(t, "steering", "delete", "../../CLAUDE", "--force", "--root", dir)
+ if code == 0 {
+ t.Error("steering delete ../../CLAUDE should not succeed")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); err != nil {
+ t.Fatalf("CLAUDE.md was deleted via steering traversal: %v", err)
+ }
+}
+
+func TestInitRejectsStrayPositional(t *testing.T) {
+ dir := t.TempDir()
+ code, _, errOut := run(t, "init", "somedir", "--root", dir)
+ if code == 0 {
+ t.Error("`csdd init somedir` should fail (no positional args), not silently scaffold cwd")
+ }
+ if !strings.Contains(errOut, "positional") {
+ t.Errorf("expected a positional-args error, got %q", errOut)
+ }
+}
+
+func TestSpecValidateJSON(t *testing.T) {
+ dir := freshWorkspace(t)
+ if code, _, e := run(t, "spec", "init", "photo-albums", "--root", dir); code != 0 {
+ t.Fatalf("spec init failed: %s", e)
+ }
+ if code, _, e := run(t, "spec", "generate", "photo-albums", "--artifact", "requirements", "--root", dir); code != 0 {
+ t.Fatalf("generate failed: %s", e)
+ }
+ code, out, _ := run(t, "spec", "validate", "photo-albums", "--json", "--root", dir)
+ _ = code
+ var payload validationJSON
+ if err := json.Unmarshal([]byte(out), &payload); err != nil {
+ t.Fatalf("validate --json did not emit valid JSON: %v\noutput=%q", err, out)
+ }
+ if payload.Target != "photo-albums" {
+ t.Errorf("json target = %q, want photo-albums", payload.Target)
+ }
+}
+
+func TestSpecListJSON(t *testing.T) {
+ dir := freshWorkspace(t)
+ run(t, "spec", "init", "alpha", "--root", dir)
+ code, out, _ := run(t, "spec", "list", "--json", "--root", dir)
+ if code != 0 {
+ t.Fatalf("spec list --json failed: %d", code)
+ }
+ var rows []specSummaryJSON
+ if err := json.Unmarshal([]byte(out), &rows); err != nil {
+ t.Fatalf("spec list --json invalid: %v\n%q", err, out)
+ }
+ if len(rows) != 1 || rows[0].Feature != "alpha" {
+ t.Errorf("unexpected list payload: %+v", rows)
+ }
+}
+
+// TestApprovalDriftDetected covers the approval-gate content binding: editing an
+// approved artifact after approval must surface a drift issue.
+func TestApprovalDriftDetected(t *testing.T) {
+ dir := freshWorkspace(t)
+ run(t, "spec", "init", "feat", "--root", dir)
+ if code, _, e := run(t, "spec", "generate", "feat", "--artifact", "requirements", "--root", dir); code != 0 {
+ t.Fatalf("generate failed: %s", e)
+ }
+ // Overwrite requirements.md with a valid EARS criterion so approval passes.
+ reqPath := filepath.Join(dir, "specs", "feat", "requirements.md")
+ valid := "# Requirements\n\n### Requirement 1: R\n\n**Acceptance Criteria**\n1. WHEN a user acts THE SYSTEM SHALL respond.\n"
+ if err := os.WriteFile(reqPath, []byte(valid), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if code, _, e := run(t, "spec", "approve", "feat", "--phase", "requirements", "--root", dir); code != 0 {
+ t.Fatalf("approve failed: %s", e)
+ }
+ // No drift right after approval.
+ if code, _, _ := run(t, "spec", "validate", "feat", "--root", dir); code != 0 {
+ t.Errorf("validate should pass immediately after approval, got %d", code)
+ }
+ // Edit the approved artifact → drift.
+ if err := os.WriteFile(reqPath, []byte(valid+"\n2. WHEN b THE SYSTEM SHALL c.\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, out, errOut := run(t, "spec", "validate", "feat", "--root", dir)
+ combined := out + errOut
+ if code == 0 || !strings.Contains(combined, "after approval") {
+ t.Errorf("edited-after-approval drift not reported: code=%d out=%q", code, combined)
+ }
+}
diff --git a/internal/cli/init.go b/internal/cli/init.go
index 52432ca..0d6773a 100644
--- a/internal/cli/init.go
+++ b/internal/cli/init.go
@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
@@ -28,6 +29,10 @@ func runInit(args []string, templates embed.FS) int {
if err := fs.Parse(args); err != nil {
return failOnFlagParse(err)
}
+ if err := rejectPositionals("init", fs); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
exSet, err := parseExclusions(exclude.values)
if err != nil {
render.Err(err.Error())
@@ -347,9 +352,10 @@ func initWorkspace(root string, opts initOptions, templates embed.FS) (initCount
// Baseline steering files, imported into CLAUDE.md as always-on memory.
if opts.withBaseline && !opts.skip("steering") {
- var names []string
- for name, tplPath := range standardSteeringTemplates() {
- content, err := templater.Static(templates, tplPath)
+ tpls := standardSteeringTemplates()
+ names := standardSteeringOrder()
+ for _, name := range names {
+ content, err := templater.Static(templates, tpls[name])
if err != nil {
return c, err
}
@@ -360,7 +366,6 @@ func initWorkspace(root string, opts initOptions, templates embed.FS) (initCount
if created {
c.files++
}
- names = append(names, name)
}
// A no-op when CLAUDE.md was excluded (there is nothing to wire into).
if _, err := ensureSteeringImports(root, names...); err != nil {
@@ -386,3 +391,17 @@ func standardSteeringTemplates() map[string]string {
"api-conventions.md": "templates/steering/api-conventions.md.tmpl",
}
}
+
+// standardSteeringOrder returns the baseline steering file names in a stable
+// sorted order. Both scaffolders iterate this instead of the map directly, so
+// the files are created — and the CLAUDE.md @-import block is written — in the
+// same order on every run and machine (Go map iteration is randomized).
+func standardSteeringOrder() []string {
+ m := standardSteeringTemplates()
+ names := make([]string, 0, len(m))
+ for name := range m {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
diff --git a/internal/cli/jsonout.go b/internal/cli/jsonout.go
new file mode 100644
index 0000000..5ed080e
--- /dev/null
+++ b/internal/cli/jsonout.go
@@ -0,0 +1,62 @@
+package cli
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+
+ "github.com/protonspy/csdd/internal/render"
+ "github.com/protonspy/csdd/internal/validator"
+)
+
+// addJSON binds the conventional --json flag. Every command that has a
+// machine-readable variant uses this so the flag name and help text are
+// identical across the CLI — the surface an AI agent scripts against.
+func addJSON(fs *flag.FlagSet, dst *bool) {
+ fs.BoolVar(dst, "json", false, "Emit machine-readable JSON on stdout instead of the human table.")
+}
+
+// emitJSON writes v as indented JSON to stdout and returns a process exit code.
+// Diagnostics still go to stderr via render, so stdout stays a clean JSON stream
+// an agent can parse.
+func emitJSON(v any) int {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ render.Err(err.Error())
+ return 1
+ }
+ fmt.Println(string(b))
+ return 0
+}
+
+// issueJSON is the stable machine-readable shape of a validation issue. It uses
+// explicit snake_case keys so the schema is decoupled from validator.Issue's Go
+// field names.
+type issueJSON struct {
+ File string `json:"file"`
+ Line int `json:"line,omitempty"`
+ Msg string `json:"message"`
+}
+
+func issuesToJSON(in []validator.Issue) []issueJSON {
+ out := make([]issueJSON, 0, len(in))
+ for _, i := range in {
+ out = append(out, issueJSON{File: i.File, Line: i.Line, Msg: i.Msg})
+ }
+ return out
+}
+
+// validationJSON is the payload for every `... validate --json` command.
+type validationJSON struct {
+ Target string `json:"target"`
+ OK bool `json:"ok"`
+ Issues []issueJSON `json:"issues"`
+}
+
+// specSummaryJSON is one row of `spec list --json`.
+type specSummaryJSON struct {
+ Feature string `json:"feature"`
+ Phase string `json:"phase"`
+ Approved []string `json:"approved"`
+ Ready bool `json:"ready_for_implementation"`
+}
diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go
index 5681873..3b1252c 100644
--- a/internal/cli/mcp.go
+++ b/internal/cli/mcp.go
@@ -5,7 +5,6 @@ import (
"flag"
"fmt"
"os"
- "path/filepath"
"sort"
"strings"
@@ -44,6 +43,10 @@ func runMCP(args []string) int {
render.Err(err.Error())
return 1
}
+ if isHelpFlag(action) {
+ help(os.Stdout)
+ return 0
+ }
switch action {
case "add":
return mcpAdd(rest)
@@ -105,10 +108,7 @@ func saveMCP(path string, cfg MCPConfig) error {
return err
}
b = append(b, '\n')
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- return os.WriteFile(path, b, 0o644)
+ return workspace.AtomicWrite(path, b, 0o644)
}
// MCPAddOptions is the headless equivalent shared by the CLI and the TUI.
@@ -271,6 +271,26 @@ func mcpList(args []string) int {
return code
}
cfg := r.cfg
+ if r.jsonOut {
+ type serverJSON struct {
+ Name string `json:"name"`
+ Transport string `json:"transport"`
+ Disabled bool `json:"disabled"`
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ URL string `json:"url,omitempty"`
+ }
+ out := []serverJSON{}
+ for _, n := range sortedServerNames(cfg) {
+ s := cfg.MCPServers[n]
+ transport := "stdio"
+ if s.URL != "" {
+ transport = s.Type
+ }
+ out = append(out, serverJSON{Name: n, Transport: transport, Disabled: s.Disabled, Command: s.Command, Args: s.Args, URL: s.URL})
+ }
+ return emitJSON(out)
+ }
if len(cfg.MCPServers) == 0 {
render.Info("no mcp servers configured")
return 0
@@ -429,6 +449,13 @@ func mcpValidate(args []string) int {
return code
}
issues := validateMCPConfig(res.cfg)
+ if res.jsonOut {
+ emitJSON(validationJSON{Target: "mcp.json", OK: len(issues) == 0, Issues: issuesToJSON(issues)})
+ if len(issues) > 0 {
+ return 2
+ }
+ return 0
+ }
if len(issues) == 0 {
render.OK(fmt.Sprintf("mcp.json valid (%d server(s))", len(res.cfg.MCPServers)))
return 0
@@ -487,15 +514,20 @@ func sortedServerNames(cfg MCPConfig) []string {
}
// mcpResult bundles a resolved root + loaded config for the read commands.
+// jsonOut carries the shared --json flag so list/show/validate can emit
+// machine-readable output without each re-parsing flags.
type mcpResult struct {
- root string
- cfg MCPConfig
+ root string
+ cfg MCPConfig
+ jsonOut bool
}
func mcpResolveAndLoad(args []string) (mcpResult, []string, int) {
fs := flag.NewFlagSet("mcp", flag.ContinueOnError)
var root string
+ var jsonOut bool
addRoot(fs, &root)
+ addJSON(fs, &jsonOut)
positionals, err := parseFlags(fs, args)
if err != nil {
return mcpResult{}, nil, failOnFlagParse(err)
@@ -515,7 +547,7 @@ func mcpResolveAndLoad(args []string) (mcpResult, []string, int) {
render.Err(err.Error())
return mcpResult{}, nil, 1
}
- return mcpResult{root: r, cfg: cfg}, positionals, 0
+ return mcpResult{root: r, cfg: cfg, jsonOut: jsonOut}, positionals, 0
}
func mcpResolveLoadNamed(args []string, action string) (mcpResult, string, int) {
diff --git a/internal/cli/skill.go b/internal/cli/skill.go
index 19eb745..a85468b 100644
--- a/internal/cli/skill.go
+++ b/internal/cli/skill.go
@@ -22,6 +22,10 @@ func runSkill(args []string, templates embed.FS) int {
render.Err(err.Error())
return 1
}
+ if isHelpFlag(action) {
+ help(os.Stdout)
+ return 0
+ }
switch action {
case "create":
return skillCreate(rest, templates)
@@ -173,6 +177,11 @@ func skillList(args []string) int {
}
func findSkill(root, name string) (string, bool) {
+ // Guard before joining: a name like ".." would resolve findSkill to .claude/
+ // itself, which skillDelete would then RemoveAll.
+ if err := workspace.SafeName(name, "skill"); err != nil {
+ return "", false
+ }
base := workspace.SkillRoot(root)
candidate := filepath.Join(base, name)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
@@ -204,7 +213,7 @@ func skillShow(args []string) int {
return 1
}
fmt.Println(render.Bold(workspace.Relative(r, sdir)))
- filepath.Walk(sdir, func(path string, info os.FileInfo, err error) error {
+ _ = filepath.Walk(sdir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
@@ -309,13 +318,15 @@ func safeSkillChildPath(base, rel string) (string, error) {
func skillValidate(args []string) int {
fs := flag.NewFlagSet("skill validate", 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() + " skill validate NAME")
+ render.Err("usage: " + prog() + " skill validate NAME [--json]")
return 1
}
r, err := workspace.Resolve(root)
@@ -330,6 +341,17 @@ func skillValidate(args []string) int {
return 1
}
issues, lines, tokens := validator.ValidateSkill(sdir, name)
+ if jsonOut {
+ emitJSON(struct {
+ validationJSON
+ Lines int `json:"lines"`
+ Tokens int `json:"tokens"`
+ }{validationJSON{Target: name, OK: len(issues) == 0, Issues: issuesToJSON(issues)}, lines, tokens})
+ if len(issues) > 0 {
+ return 2
+ }
+ return 0
+ }
if len(issues) == 0 {
render.OK(fmt.Sprintf("%s: skill valid (%d lines, ~%d tokens)", name, lines, tokens))
return 0
diff --git a/internal/cli/spec.go b/internal/cli/spec.go
index ad8abc4..6b6e399 100644
--- a/internal/cli/spec.go
+++ b/internal/cli/spec.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/protonspy/csdd/internal/frontmatter"
+ "github.com/protonspy/csdd/internal/manifest"
"github.com/protonspy/csdd/internal/paths"
"github.com/protonspy/csdd/internal/render"
"github.com/protonspy/csdd/internal/session"
@@ -24,8 +25,13 @@ import (
"github.com/protonspy/csdd/internal/workspace"
)
+// specSchemaVersion is the current spec.json schema. It is written on every save
+// so a future csdd can detect and migrate older layouts instead of guessing.
+const specSchemaVersion = 1
+
// SpecJSON mirrors the schema produced by the Python reference implementation.
type SpecJSON struct {
+ SchemaVersion int `json:"schema_version,omitempty"`
FeatureName string `json:"feature_name"`
Language string `json:"language"`
Phase string `json:"phase"`
@@ -33,6 +39,18 @@ type SpecJSON struct {
Approvals map[string]ApprovalFlag `json:"approvals"`
ReadyForImplementation bool `json:"ready_for_implementation"`
CreatedAt string `json:"created_at"`
+
+ // extra preserves any keys a newer csdd wrote that this binary does not model,
+ // so round-tripping spec.json through an older binary never silently drops
+ // them. Unexported ⇒ ignored by encoding/json; merged back in saveSpecJSON.
+ extra map[string]json.RawMessage
+}
+
+// knownSpecKeys are the JSON object keys SpecJSON models directly; anything else
+// on disk is captured into SpecJSON.extra and round-tripped untouched.
+var knownSpecKeys = []string{
+ "schema_version", "feature_name", "language", "phase", "development_flow",
+ "approvals", "ready_for_implementation", "created_at",
}
// defaultDevelopmentFlow is the flow assumed when none is selected and steering
@@ -95,10 +113,15 @@ func resolveDefaultFlow(root string) string {
return defaultDevelopmentFlow
}
-// ApprovalFlag tracks generation + approval state for a phase.
+// ApprovalFlag tracks generation + approval state for a phase. ContentHash binds
+// an approval to the exact artifact content that was approved: if the phase's
+// requirements.md / design.md / tasks.md is edited by hand after approval, the
+// stored hash no longer matches and the drift is reported — closing the loophole
+// where a spec-driven gate could be bypassed by editing the file post-approval.
type ApprovalFlag struct {
- Generated bool `json:"generated"`
- Approved bool `json:"approved"`
+ Generated bool `json:"generated"`
+ Approved bool `json:"approved"`
+ ContentHash string `json:"content_hash,omitempty"`
}
func runSpec(args []string, templates embed.FS) int {
@@ -107,6 +130,10 @@ func runSpec(args []string, templates embed.FS) int {
render.Err(err.Error())
return 1
}
+ if isHelpFlag(action) {
+ help(os.Stdout)
+ return 0
+ }
switch action {
case "init":
return specInit(rest, templates)
@@ -218,7 +245,9 @@ func specInit(args []string, templates embed.FS) int {
func specList(args []string) int {
fs := flag.NewFlagSet("spec list", flag.ContinueOnError)
var root string
+ var jsonOut bool
addRoot(fs, &root)
+ addJSON(fs, &jsonOut)
_, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
@@ -240,6 +269,7 @@ func specList(args []string) int {
}
type row struct{ feature, phase, approved, ready string }
var rows []row
+ var jsonRows []specSummaryJSON
maxName := len("feature")
for _, e := range entries {
if !e.IsDir() {
@@ -247,13 +277,18 @@ func specList(args []string) int {
}
data, err := loadSpecJSON(filepath.Join(base, e.Name()))
if err != nil {
+ // A dir with no spec.json isn't a spec — skip it quietly. But a spec.json
+ // that fails to parse is corruption the user must see, not hide.
+ if !os.IsNotExist(err) {
+ render.Warn("skipping spec '" + e.Name() + "': " + err.Error())
+ }
continue
}
ready := "no"
if data.ReadyForImplementation {
ready = "yes"
}
- var approved []string
+ approved := []string{}
for k, v := range data.Approvals {
if v.Approved {
approved = append(approved, k)
@@ -265,10 +300,20 @@ func specList(args []string) int {
appStr = strings.Join(approved, ",")
}
rows = append(rows, row{e.Name(), data.Phase, appStr, ready})
+ jsonRows = append(jsonRows, specSummaryJSON{
+ Feature: e.Name(),
+ Phase: data.Phase,
+ Approved: approved,
+ Ready: data.ReadyForImplementation,
+ })
if len(e.Name()) > maxName {
maxName = len(e.Name())
}
}
+ if jsonOut {
+ sort.Slice(jsonRows, func(i, j int) bool { return jsonRows[i].Feature < jsonRows[j].Feature })
+ return emitJSON(jsonRows)
+ }
if len(rows) == 0 {
render.Info("no specs found")
return 0
@@ -284,13 +329,15 @@ func specList(args []string) int {
func specShow(args []string) int {
fs := flag.NewFlagSet("spec show", 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() + " spec show FEATURE")
+ render.Err("usage: " + prog() + " spec show FEATURE [--json]")
return 1
}
r, err := workspace.Resolve(root)
@@ -299,6 +346,10 @@ func specShow(args []string) int {
return 1
}
feature := positionals[0]
+ if err := workspace.SafeName(feature, "feature"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
sdir := filepath.Join(paths.Specs(r), feature)
if !pathExists(sdir) {
render.Err("spec not found: " + feature)
@@ -309,6 +360,9 @@ func specShow(args []string) int {
render.Err(err.Error())
return 1
}
+ if jsonOut {
+ return emitJSON(data)
+ }
fmt.Println(render.Bold("feature: " + data.FeatureName))
fmt.Println("phase: " + data.Phase)
fmt.Println("language: " + data.Language)
@@ -395,6 +449,9 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error {
if err != nil {
return err
}
+ if err := workspace.SafeName(opts.Feature, "feature"); err != nil {
+ return err
+ }
sdir := filepath.Join(paths.Specs(r), opts.Feature)
if !pathExists(sdir) {
return fmt.Errorf("spec not found: %s. Run `%s spec init %s` first", opts.Feature, prog(), opts.Feature)
@@ -409,8 +466,8 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error {
if opts.Artifact == "tasks" {
prev = "design"
}
- if !data.Approvals[prev].Approved && !opts.Force {
- return fmt.Errorf("phase gate: '%s' must be approved before generating '%s'. Use --force only for explicitly fast-track / Quick Plan flows", prev, opts.Artifact)
+ if !phaseApprovedAndCurrent(sdir, data, prev) && !opts.Force {
+ return fmt.Errorf("phase gate: '%s' must be approved (and unchanged since) before generating '%s'. Use --force only for explicitly fast-track / Quick Plan flows", prev, opts.Artifact)
}
}
templateMap := map[string][2]string{
@@ -442,6 +499,7 @@ func SpecGenerate(templates embed.FS, opts SpecGenerateOptions) error {
if a, ok := data.Approvals[opts.Artifact]; ok {
a.Generated = true
a.Approved = false
+ a.ContentHash = "" // regeneration invalidates any prior approval binding
data.Approvals[opts.Artifact] = a
}
if tracked {
@@ -500,6 +558,9 @@ func SpecApprove(opts SpecApproveOptions) error {
if err != nil {
return err
}
+ if err := workspace.SafeName(opts.Feature, "feature"); err != nil {
+ return err
+ }
sdir := filepath.Join(paths.Specs(r), opts.Feature)
if !pathExists(sdir) {
return fmt.Errorf("spec not found: %s", opts.Feature)
@@ -515,11 +576,11 @@ func SpecApprove(opts SpecApproveOptions) error {
if !state.Generated {
return fmt.Errorf("cannot approve '%s': not generated yet", opts.Phase)
}
- if prev := previousPhase(opts.Phase); prev != "" && !data.Approvals[prev].Approved {
+ if prev := previousPhase(opts.Phase); prev != "" && !phaseApprovedAndCurrent(sdir, data, prev) {
if !opts.Force {
- return fmt.Errorf("phase gate: '%s' must be approved before approving '%s'", prev, opts.Phase)
+ return fmt.Errorf("phase gate: '%s' must be approved (and unchanged since) before approving '%s'", prev, opts.Phase)
}
- render.Warn("approval forced despite missing prior approval for '" + prev + "'")
+ render.Warn("approval forced despite missing/stale prior approval for '" + prev + "'")
}
issues := validator.ValidateSpec(sdir, validator.Phase(opts.Phase))
if len(issues) > 0 {
@@ -532,6 +593,9 @@ func SpecApprove(opts SpecApproveOptions) error {
render.Warn("approval forced despite validation issues")
}
state.Approved = true
+ if h, err := phaseContentHash(sdir, opts.Phase); err == nil {
+ state.ContentHash = h
+ }
data.Approvals[opts.Phase] = state
data.Phase = opts.Phase + "-approved"
ready := true
@@ -552,6 +616,76 @@ func SpecApprove(opts SpecApproveOptions) error {
return nil
}
+// phaseArtifact maps an approvable phase to the artifact whose content its
+// approval certifies.
+func phaseArtifact(phase string) string {
+ switch phase {
+ case "requirements":
+ return "requirements.md"
+ case "design":
+ return "design.md"
+ case "tasks":
+ return "tasks.md"
+ }
+ return ""
+}
+
+// phaseContentHash returns the line-ending-normalized content hash of a phase's
+// artifact. Normalization means a CRLF re-checkout never looks like drift.
+func phaseContentHash(specDir, phase string) (string, error) {
+ name := phaseArtifact(phase)
+ if name == "" {
+ return "", fmt.Errorf("no artifact for phase %q", phase)
+ }
+ b, err := os.ReadFile(filepath.Join(specDir, name))
+ if err != nil {
+ return "", err
+ }
+ return manifest.Hash(string(b)), nil
+}
+
+// phaseApprovedAndCurrent reports whether a phase is approved AND its artifact
+// has not been edited since (no drift). A drifted approval must not satisfy a
+// phase gate — otherwise a post-approval hand-edit would let the next phase
+// proceed on a stale checkpoint. Approvals with no stored hash (written by an
+// older csdd) are trusted, since drift can't be detected for them.
+func phaseApprovedAndCurrent(specDir string, s SpecJSON, phase string) bool {
+ a, ok := s.Approvals[phase]
+ if !ok || !a.Approved {
+ return false
+ }
+ if a.ContentHash == "" {
+ return true
+ }
+ h, err := phaseContentHash(specDir, phase)
+ return err == nil && h == a.ContentHash
+}
+
+// approvalDriftIssues reports any phase whose artifact was edited after approval,
+// so a hand-edit can't silently ride on a stale approval. Approvals recorded by
+// older csdd versions (no stored hash) are skipped — no false positives.
+func approvalDriftIssues(specDir string, s SpecJSON) []validator.Issue {
+ var out []validator.Issue
+ for _, phase := range workspace.SpecPhases {
+ a, ok := s.Approvals[phase]
+ if !ok || !a.Approved || a.ContentHash == "" {
+ continue
+ }
+ h, err := phaseContentHash(specDir, phase)
+ if err != nil {
+ continue
+ }
+ if h != a.ContentHash {
+ out = append(out, validator.Issue{
+ File: phaseArtifact(phase),
+ Msg: fmt.Sprintf("edited after approval — the '%s' approval no longer certifies this content; re-approve (`%s spec approve %s --phase %s`) or regenerate",
+ phase, prog(), s.FeatureName, phase),
+ })
+ }
+ }
+ return out
+}
+
func previousPhase(phase string) string {
switch phase {
case "design":
@@ -566,13 +700,15 @@ func previousPhase(phase string) string {
func specValidate(args []string) int {
fs := flag.NewFlagSet("spec validate", 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() + " spec validate FEATURE")
+ render.Err("usage: " + prog() + " spec validate FEATURE [--json]")
return 1
}
r, err := workspace.Resolve(root)
@@ -581,6 +717,10 @@ func specValidate(args []string) int {
return 1
}
feature := positionals[0]
+ if err := workspace.SafeName(feature, "feature"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
sdir := filepath.Join(paths.Specs(r), feature)
if !pathExists(sdir) {
render.Err("spec not found: " + feature)
@@ -593,6 +733,16 @@ func specValidate(args []string) int {
}
phase, preflight := validationScope(sdir, data)
issues := append(preflight, validator.ValidateSpec(sdir, phase)...)
+ issues = append(issues, approvalDriftIssues(sdir, data)...)
+ if jsonOut {
+ // Exit code still encodes the result (2 = issues) so an agent can branch on
+ // $? without parsing, while stdout carries the details.
+ emitJSON(validationJSON{Target: feature, OK: len(issues) == 0, Issues: issuesToJSON(issues)})
+ if len(issues) > 0 {
+ return 2
+ }
+ return 0
+ }
if len(issues) == 0 {
render.OK(feature + ": validation passed")
return 0
@@ -637,6 +787,10 @@ func specDelete(args []string) int {
return 1
}
feature := positionals[0]
+ if err := workspace.SafeName(feature, "feature"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
if !force {
render.Err("refusing to delete spec '" + feature + "' without --force")
return 1
@@ -692,6 +846,10 @@ func specTestReport(args []string) int {
return 1
}
feature := positionals[0]
+ if err := workspace.SafeName(feature, "feature"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
r, err := workspace.Resolve(root)
if err != nil {
render.Err(err.Error())
@@ -900,12 +1058,23 @@ func nz(n int) int {
func loadSpecJSON(specDir string) (SpecJSON, error) {
var s SpecJSON
- data, err := os.ReadFile(filepath.Join(specDir, "spec.json"))
+ path := filepath.Join(specDir, "spec.json")
+ data, err := os.ReadFile(path)
if err != nil {
return s, err
}
if err := json.Unmarshal(data, &s); err != nil {
- return s, err
+ return s, fmt.Errorf("parse %s: %w", path, err)
+ }
+ // Capture keys this binary does not model so save() can round-trip them.
+ var all map[string]json.RawMessage
+ if json.Unmarshal(data, &all) == nil {
+ for _, k := range knownSpecKeys {
+ delete(all, k)
+ }
+ if len(all) > 0 {
+ s.extra = all
+ }
}
if s.Approvals == nil {
s.Approvals = map[string]ApprovalFlag{}
@@ -914,10 +1083,35 @@ func loadSpecJSON(specDir string) (SpecJSON, error) {
}
func saveSpecJSON(specDir string, s SpecJSON) error {
- b, err := json.MarshalIndent(s, "", " ")
+ if s.SchemaVersion == 0 {
+ s.SchemaVersion = specSchemaVersion
+ }
+ var b []byte
+ var err error
+ if len(s.extra) == 0 {
+ // Fast path: keep the clean, field-ordered layout with no diff churn.
+ b, err = json.MarshalIndent(s, "", " ")
+ } else {
+ // Forward-compat path: merge unknown keys back so an older binary never
+ // drops fields a newer csdd wrote.
+ known, mErr := json.Marshal(s)
+ if mErr != nil {
+ return mErr
+ }
+ merged := map[string]json.RawMessage{}
+ if err := json.Unmarshal(known, &merged); err != nil {
+ return err
+ }
+ for k, v := range s.extra {
+ if _, ok := merged[k]; !ok {
+ merged[k] = v
+ }
+ }
+ b, err = json.MarshalIndent(merged, "", " ")
+ }
if err != nil {
return err
}
b = append(b, '\n')
- return os.WriteFile(filepath.Join(specDir, "spec.json"), b, 0o644)
+ return workspace.AtomicWrite(filepath.Join(specDir, "spec.json"), b, 0o644)
}
diff --git a/internal/cli/steering.go b/internal/cli/steering.go
index 14415ef..11372be 100644
--- a/internal/cli/steering.go
+++ b/internal/cli/steering.go
@@ -23,6 +23,10 @@ func runSteering(args []string, templates embed.FS) int {
render.Err(err.Error())
return 1
}
+ if isHelpFlag(action) {
+ help(os.Stdout)
+ return 0
+ }
switch action {
case "init":
return steeringInit(rest, templates)
@@ -61,9 +65,10 @@ func steeringInit(args []string, templates embed.FS) int {
return 1
}
created := 0
- var names []string
- for name, tpl := range standardSteeringTemplates() {
- content, err := templater.Static(templates, tpl)
+ tpls := standardSteeringTemplates()
+ names := standardSteeringOrder()
+ for _, name := range names {
+ content, err := templater.Static(templates, tpls[name])
if err != nil {
render.Err(err.Error())
return 1
@@ -81,7 +86,6 @@ func steeringInit(args []string, templates embed.FS) int {
} else {
render.Info("skipped " + rel + " (exists)")
}
- names = append(names, name)
}
render.Info("standard steering files created: " + intStr(created))
if added, err := ensureSteeringImports(r, names...); err != nil {
@@ -226,6 +230,7 @@ func steeringList(args []string) int {
}
data, err := os.ReadFile(filepath.Join(sdir, e.Name()))
if err != nil {
+ render.Warn("skipping steering '" + e.Name() + "': " + err.Error())
continue
}
fm := frontmatter.Parse(string(data))
@@ -273,6 +278,10 @@ func steeringShow(args []string) int {
render.Err(err.Error())
return 1
}
+ if err := workspace.SafeName(positionals[0], "steering"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
sdir, err := workspace.SteeringDir(r)
if err != nil {
render.Err(err.Error())
@@ -299,10 +308,14 @@ func steeringDelete(args []string) int {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
- render.Err("usage: " + prog() + " steering delete NAME")
+ render.Err("usage: " + prog() + " steering delete NAME [--force]")
return 1
}
name := positionals[0]
+ if err := workspace.SafeName(name, "steering"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
r, err := workspace.Resolve(root)
if err != nil {
render.Err(err.Error())
@@ -333,7 +346,9 @@ func steeringDelete(args []string) int {
func steeringValidate(args []string) int {
fs := flag.NewFlagSet("steering validate", 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)
@@ -351,12 +366,27 @@ func steeringValidate(args []string) int {
name := ""
if len(positionals) >= 1 {
name = positionals[0]
+ if err := workspace.SafeName(name, "steering"); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
}
issues, err := validator.ValidateSteering(sdir, name)
if err != nil {
render.Err(err.Error())
return 1
}
+ target := name
+ if target == "" {
+ target = "steering"
+ }
+ if jsonOut {
+ emitJSON(validationJSON{Target: target, OK: len(issues) == 0, Issues: issuesToJSON(issues)})
+ if len(issues) > 0 {
+ return 2
+ }
+ return 0
+ }
if len(issues) == 0 {
render.OK("all steering files valid")
return 0
diff --git a/internal/cli/update.go b/internal/cli/update.go
index 19eec1a..60a01ca 100644
--- a/internal/cli/update.go
+++ b/internal/cli/update.go
@@ -133,6 +133,10 @@ func runUpdate(args []string, templates embed.FS) int {
if err := fset.Parse(args); err != nil {
return failOnFlagParse(err)
}
+ if err := rejectPositionals("update", fset); err != nil {
+ render.Err(err.Error())
+ return 1
+ }
root, err := workspace.Resolve(opts.root)
if err != nil {
@@ -162,17 +166,25 @@ func runUpdate(args []string, templates embed.FS) int {
// them — but only interactively. Non-interactive runs (CI, agents) keep the
// safe-by-backup behaviour: apply and preserve the old copy as .old.
conflicts := plan.countConflicts()
- if conflicts > 0 && !opts.force && !opts.yes && stdinIsInteractive() {
- render.Warn(fmt.Sprintf("%d file(s) you edited differ from this version and would be overridden:", conflicts))
+ if conflicts > 0 && !opts.force && !opts.yes {
+ render.Warn(fmt.Sprintf("%d file(s) you edited differ from this version:", conflicts))
for _, c := range plan.changes {
if c.kind == kindConflict {
render.Info(" ! " + c.rel)
}
}
render.Info("Your current versions are saved as .old before the new ones are written.")
- if !confirm("Override these files?") {
- opts.skipConflicts = true
- render.Info("Keeping your versions untouched. Re-run with --yes to override.")
+ if stdinIsInteractive() {
+ if !confirm("Override these files?") {
+ opts.skipConflicts = true
+ render.Info("Keeping your versions untouched. Re-run with --yes to override.")
+ }
+ } else {
+ // Non-interactive (CI, an agent driving the CLI): proceed, but never
+ // silently — the conflicts were just listed above and each edited file
+ // is preserved as .old. Pass --yes to suppress this notice, or answer
+ // interactively to choose per run.
+ render.Info("Non-interactive: applying new versions; your edits are kept as .old. (Pass --yes to acknowledge, or run interactively to choose.)")
}
}
@@ -287,12 +299,17 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd
res.changes = append(res.changes, ch)
continue
}
- // Preserve the user's copy as .old, then write the new version.
+ // Preserve the user's copy as .old, then write the new version. Carry the
+ // original file's mode over so a backed-up hook script stays executable.
if !opts.force {
old := nextOldPath(f.Abs)
ch.backup = filepath.ToSlash(workspace.Relative(opts.root, old))
if !opts.dryRun {
- if err := os.WriteFile(old, diskBytes, 0o644); err != nil {
+ mode := os.FileMode(0o644)
+ if info, statErr := os.Stat(f.Abs); statErr == nil {
+ mode = info.Mode().Perm()
+ }
+ if err := os.WriteFile(old, diskBytes, mode); err != nil {
return res, err
}
}
diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go
index 1afac73..b9ce796 100644
--- a/internal/cli/update_test.go
+++ b/internal/cli/update_test.go
@@ -118,8 +118,8 @@ func TestUpdatePreservesManagedAgentModelEffort(t *testing.T) {
agent := filepath.Join(dir, ".claude", "agents", "implementer.md")
withOverrides := strings.Replace(
readFile(t, agent),
- "tools: Read, Grep, Glob, Edit, Write, Bash\n---",
- "tools: Read, Grep, Glob, Edit, Write, Bash\nmodel: opus\neffort: high\n---",
+ "tools: Read, Grep, Glob, Edit, Write, Bash\n",
+ "tools: Read, Grep, Glob, Edit, Write, Bash\nmodel: opus\neffort: high\n",
1,
)
if err := os.WriteFile(agent, []byte(withOverrides), 0o644); err != nil {
diff --git a/internal/cli/web_test.go b/internal/cli/web_test.go
index 8c5c0c4..b7c1ba6 100644
--- a/internal/cli/web_test.go
+++ b/internal/cli/web_test.go
@@ -3,6 +3,7 @@ package cli
import (
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
)
@@ -41,7 +42,7 @@ func TestResolvePinggyTokenPersistsAndGitignores(t *testing.T) {
if strings.TrimSpace(string(saved)) != "TOK123" {
t.Errorf("saved token = %q", saved)
}
- if fi, _ := os.Stat(filepath.Join(dir, ".pinggy-token")); fi != nil && fi.Mode().Perm() != 0o600 {
+ if fi, _ := os.Stat(filepath.Join(dir, ".pinggy-token")); fi != nil && runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 {
t.Errorf("token file perms = %v, want 0600", fi.Mode().Perm())
}
if gi, _ := os.ReadFile(filepath.Join(dir, ".gitignore")); !strings.Contains(string(gi), ".pinggy-token") {
diff --git a/internal/frontmatter/frontmatter.go b/internal/frontmatter/frontmatter.go
index 7b6cdf4..b8848a6 100644
--- a/internal/frontmatter/frontmatter.go
+++ b/internal/frontmatter/frontmatter.go
@@ -5,6 +5,8 @@ package frontmatter
import (
"strings"
+
+ "github.com/protonspy/csdd/internal/textutil"
)
// Frontmatter holds parsed key/value pairs plus the remaining markdown body.
@@ -14,8 +16,13 @@ type Frontmatter struct {
}
// Parse extracts YAML frontmatter delimited by `---` fences at the head of text.
-// When no frontmatter is present, the entire text is returned in Body.
+// When no frontmatter is present, the entire text is returned in Body. Input is
+// line-ending normalized and BOM-stripped first, so a CRLF file written by a
+// Windows editor (or a file Notepad prefixed with a UTF-8 BOM) parses identically
+// to an LF one — otherwise the opening `---` fence would fail to match and the
+// whole file would be treated as body with no fields.
func Parse(text string) Frontmatter {
+ text = textutil.NormalizeNewlines(text)
lines := strings.Split(text, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return Frontmatter{Fields: map[string]any{}, Body: text}
@@ -31,23 +38,103 @@ func Parse(text string) Frontmatter {
return Frontmatter{Fields: map[string]any{}, Body: text}
}
fields := map[string]any{}
- for _, raw := range lines[1:end] {
+ for i := 1; i < end; {
+ raw := lines[i]
trim := strings.TrimSpace(raw)
if trim == "" || strings.HasPrefix(trim, "#") {
+ i++
continue
}
idx := strings.Index(raw, ":")
if idx < 0 {
+ i++
continue
}
key := strings.TrimSpace(raw[:idx])
value := strings.TrimSpace(raw[idx+1:])
- fields[key] = parseValue(value)
+ // A YAML block scalar (`key: >` / `key: |`) folds the following
+ // more-indented lines into the value; without this they would be dropped
+ // and the field left as a bare ">" or "|".
+ if isBlockScalarIndicator(value) {
+ folded, next := readBlockScalar(lines, i+1, end, value)
+ fields[key] = folded
+ i = next
+ continue
+ }
+ fields[key] = parseValue(stripInlineComment(value))
+ i++
}
body := strings.Join(lines[end+1:], "\n")
return Frontmatter{Fields: fields, Body: strings.TrimLeft(body, "\n")}
}
+// stripInlineComment removes a trailing "# comment" from an unquoted scalar. Per
+// YAML, a '#' begins a comment only when it is outside quotes and preceded by
+// whitespace, so `inclusion: always # standard` yields "always", while a '#'
+// inside quotes (or with no leading space) stays part of the value.
+func stripInlineComment(v string) string {
+ inSingle, inDouble := false, false
+ for i := 0; i < len(v); i++ {
+ switch c := v[i]; {
+ case c == '\'' && !inDouble:
+ inSingle = !inSingle
+ case c == '"' && !inSingle:
+ inDouble = !inDouble
+ case c == '#' && !inSingle && !inDouble && i > 0 && (v[i-1] == ' ' || v[i-1] == '\t'):
+ return strings.TrimRight(v[:i], " \t")
+ }
+ }
+ return v
+}
+
+// isBlockScalarIndicator reports whether a value is a lone block-scalar header:
+// '|' or '>' optionally followed by chomping/indentation indicators (-, +, digit)
+// and an inline comment. `> some text` (letters after the indicator) is not one.
+func isBlockScalarIndicator(v string) bool {
+ if v == "" || (v[0] != '|' && v[0] != '>') {
+ return false
+ }
+ rest := strings.TrimSpace(stripInlineComment(v[1:]))
+ for _, c := range rest {
+ if c != '-' && c != '+' && (c < '0' || c > '9') {
+ return false
+ }
+ }
+ return true
+}
+
+// readBlockScalar folds the block-scalar body starting at lines[start] up to end.
+// A folded scalar ('>') joins lines with spaces; a literal one ('|') keeps
+// newlines. It returns the value and the index of the first line after the block.
+func readBlockScalar(lines []string, start, end int, indicator string) (string, int) {
+ folded := indicator[0] == '>'
+ var content []string
+ baseIndent := -1
+ i := start
+ for ; i < end; i++ {
+ line := lines[i]
+ if strings.TrimSpace(line) == "" {
+ content = append(content, "")
+ continue
+ }
+ indent := len(line) - len(strings.TrimLeft(line, " \t"))
+ if baseIndent == -1 {
+ baseIndent = indent
+ }
+ if indent < baseIndent {
+ break // dedented to a sibling key: the block ends here
+ }
+ content = append(content, line[baseIndent:])
+ }
+ for len(content) > 0 && content[len(content)-1] == "" {
+ content = content[:len(content)-1]
+ }
+ if folded {
+ return strings.Join(strings.Fields(strings.Join(content, " ")), " "), i
+ }
+ return strings.Join(content, "\n"), i
+}
+
func parseValue(raw string) any {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
diff --git a/internal/frontmatter/frontmatter_test.go b/internal/frontmatter/frontmatter_test.go
index 2cbe5c6..f2d5b5c 100644
--- a/internal/frontmatter/frontmatter_test.go
+++ b/internal/frontmatter/frontmatter_test.go
@@ -109,3 +109,67 @@ func TestCSVRespectsQuotes(t *testing.T) {
t.Errorf("CSV: got %v want %v", got, want)
}
}
+
+func TestParseCRLFFrontmatter(t *testing.T) {
+ in := "---\r\ninclusion: always\r\nname: api-design\r\n---\r\n\r\n# Body\r\n"
+ fm := Parse(in)
+ if got := fm.AsString("inclusion", ""); got != "always" {
+ t.Errorf("CRLF inclusion = %q, want always", got)
+ }
+ if got := fm.AsString("name", ""); got != "api-design" {
+ t.Errorf("CRLF name = %q, want api-design", got)
+ }
+}
+
+func TestParseBOMFrontmatter(t *testing.T) {
+ bom := string(rune(0xFEFF))
+ in := bom + "---\ninclusion: always\n---\n# Body\n"
+ fm := Parse(in)
+ if got := fm.AsString("inclusion", ""); got != "always" {
+ t.Errorf("BOM-prefixed frontmatter did not parse: inclusion = %q", got)
+ }
+}
+
+func TestParseStripsInlineComment(t *testing.T) {
+ in := "---\ninclusion: always # standard mode\nname: thing\n---\n"
+ fm := Parse(in)
+ if got := fm.AsString("inclusion", ""); got != "always" {
+ t.Errorf("inline comment not stripped: inclusion = %q", got)
+ }
+}
+
+func TestParseHashInsideQuotesKept(t *testing.T) {
+ in := "---\ndescription: \"has a # hash\"\n---\n"
+ fm := Parse(in)
+ if got := fm.AsString("description", ""); got != "has a # hash" {
+ t.Errorf("quoted hash mangled: description = %q", got)
+ }
+}
+
+func TestParseFoldedBlockScalar(t *testing.T) {
+ in := "---\ndescription: >\n first line\n second line\nname: thing\n---\n"
+ fm := Parse(in)
+ if got := fm.AsString("description", ""); got != "first line second line" {
+ t.Errorf("folded block scalar = %q, want %q", got, "first line second line")
+ }
+ if got := fm.AsString("name", ""); got != "thing" {
+ t.Errorf("field after block scalar lost: name = %q", got)
+ }
+}
+
+func TestParseLiteralBlockScalar(t *testing.T) {
+ in := "---\ndescription: |\n line one\n line two\n---\n"
+ fm := Parse(in)
+ if got := fm.AsString("description", ""); got != "line one\nline two" {
+ t.Errorf("literal block scalar = %q, want %q", got, "line one\nline two")
+ }
+}
+
+func TestParseEscapedQuotesInArray(t *testing.T) {
+ in := "---\ntools: [\"a, b\", c]\n---\n"
+ fm := Parse(in)
+ got := fm.AsStringSlice("tools")
+ if len(got) != 2 || got[0] != "a, b" || got[1] != "c" {
+ t.Errorf("array with embedded comma mis-split: %v", got)
+ }
+}
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index ceb934a..10681f5 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -14,6 +14,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/protonspy/csdd/internal/textutil"
)
// Manifest is the on-disk record at .claude/.csdd-manifest.json. Files maps a
@@ -68,12 +70,41 @@ func (m *Manifest) Save(path, version string, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
- return os.WriteFile(path, data, 0o644)
+ return writeFileAtomic(path, data, 0o644)
+}
+
+// writeFileAtomic writes data to a sibling temp file and renames it over path,
+// so a crash or concurrent reader never observes a half-written manifest. The
+// temp file lives in the same directory as path so the rename stays on one
+// filesystem (a cross-device rename would fail).
+func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
+ if err != nil {
+ return err
+ }
+ tmpName := tmp.Name()
+ defer func() { _ = os.Remove(tmpName) }() // no-op after a successful rename
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(perm); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpName, path)
}
// Hash is the canonical content hash used throughout the manifest and the update
// reconciler. The "sha256:" prefix makes the algorithm explicit on disk.
+// Content is line-ending normalized first so a file's hash is identical whether
+// git checked it out as LF or CRLF — otherwise every managed file on a Windows
+// autocrlf clone would be misread as user-edited by `csdd update`.
func Hash(content string) string {
- sum := sha256.Sum256([]byte(content))
+ sum := sha256.Sum256([]byte(textutil.NormalizeNewlines(content)))
return "sha256:" + hex.EncodeToString(sum[:])
}
diff --git a/internal/session/reports.go b/internal/session/reports.go
index 6e49a2c..7a8fe93 100644
--- a/internal/session/reports.go
+++ b/internal/session/reports.go
@@ -529,7 +529,7 @@ func parseLcov(root, path string) *Coverage {
if err != nil {
return nil
}
- defer f.Close()
+ defer func() { _ = f.Close() }()
cov := &Coverage{Format: "lcov", Source: rel(root, path), Files: []FileCoverage{}}
var cur *FileCoverage
@@ -648,7 +648,7 @@ func parseGoCover(root, path string) *Coverage {
if err != nil {
return nil
}
- defer f.Close()
+ defer func() { _ = f.Close() }()
cov := &Coverage{Format: "gocover", Source: rel(root, path), Files: []FileCoverage{}}
perFile := map[string]*FileCoverage{}
diff --git a/internal/session/session.go b/internal/session/session.go
index a28a1ad..cc6bfa5 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -160,12 +160,9 @@ func LoadSpecDetail(root, feature string) (SpecDetail, error) {
if fi, err := os.Stat(specDir); err != nil || !fi.IsDir() {
return SpecDetail{}, fmt.Errorf("spec not found: %s", feature)
}
- d := SpecDetail{SpecCard: buildCard(specsDir, feature)}
- if data, err := os.ReadFile(filepath.Join(specDir, "tasks.md")); err == nil {
- d.Phases, _ = ParseTasks(string(data))
- }
- s, _ := loadSpec(specDir)
- for _, is := range validationIssues(specDir, s) {
+ card, phases, issues, _ := buildCardFull(specsDir, feature)
+ d := SpecDetail{SpecCard: card, Phases: phases}
+ for _, is := range issues {
d.IssueList = append(d.IssueList, ValidationIssue{File: is.File, Line: is.Line, Msg: is.Msg})
}
d.Phases = orEmpty(d.Phases)
@@ -176,6 +173,16 @@ func LoadSpecDetail(root, feature string) (SpecDetail, error) {
}
func buildCard(specsDir, feature string) SpecCard {
+ card, _, _, _ := buildCardFull(specsDir, feature)
+ return card
+}
+
+// buildCardFull assembles a spec card and also returns the parsed task phases,
+// validation issues, and spec.json it computed along the way. LoadSpecDetail
+// reuses these instead of re-reading tasks.md, re-loading spec.json, and
+// re-running the validator a second time — the dashboard polls this path every
+// ~800ms, and on WSL's /mnt/c each avoided file read crosses the 9p boundary.
+func buildCardFull(specsDir, feature string) (SpecCard, []TaskPhase, []validator.Issue, specJSON) {
specDir := filepath.Join(specsDir, feature)
card := SpecCard{Feature: feature, Approvals: map[string]Approval{}}
s, ok := loadSpec(specDir)
@@ -203,13 +210,16 @@ func buildCard(specsDir, feature string) SpecCard {
}
sort.Strings(card.Artifacts)
}
+ var phases []TaskPhase
if data, err := os.ReadFile(filepath.Join(specDir, "tasks.md")); err == nil {
- _, stats := ParseTasks(string(data))
+ var stats TaskStats
+ phases, stats = ParseTasks(string(data))
card.Tasks = stats
}
- card.Issues = len(validationIssues(specDir, s))
+ issues := validationIssues(specDir, s)
+ card.Issues = len(issues)
card.Artifacts = orEmpty(card.Artifacts)
- return card
+ return card, phases, issues, s
}
// validationIssues runs the mechanical validator scoped to the spec's furthest
diff --git a/internal/session/tasks.go b/internal/session/tasks.go
index 3e6f42a..14e4856 100644
--- a/internal/session/tasks.go
+++ b/internal/session/tasks.go
@@ -3,20 +3,23 @@ package session
import (
"regexp"
"strings"
+
+ "github.com/protonspy/csdd/internal/textutil"
+ "github.com/protonspy/csdd/internal/validator"
)
-// Task display parser. This deliberately differs from validator.parseTasks:
-// the validator is concerned with correctness (it ignores the checkbox state
-// and phase grouping), while the dashboard needs the done-state, the phase a
-// task lives under, and the RED/GREEN TDD marker. The base regexes mirror the
-// validator's literals so the two stay behaviourally consistent.
+// Task display parser. The dashboard needs more than the validator's correctness
+// pass (done-state, the phase a task lives under, the RED/GREEN TDD marker), but
+// it shares the validator's *canonical* task-line and annotation grammar so the
+// two surfaces can never disagree on what counts as a task, boundary, or
+// requirement reference. Only the display-only regexes live here.
var (
- taskLineRe = regexp.MustCompile(`^(\s*)-\s+\[\s*([xX ]?)\s*\]\s+(\d+(?:\.\d+)?)\.?\s+(.*)$`)
+ taskLineRe = validator.TaskLineRe
+ reqAnnotRe = validator.ReqAnnotRe
+ boundaryRe = validator.BoundaryAnnotRe
+ dependsRe = validator.DependsAnnotRe
+ parallelRe = validator.ParallelRe
phaseHeadRe = regexp.MustCompile(`^##\s+Phase\s+\d+\s*:?\s*(.*)$`)
- reqAnnotRe = regexp.MustCompile(`_Requirements:\s*([\d,\.\s]+)_`)
- boundaryRe = regexp.MustCompile(`_Boundary:\s*([A-Za-z0-9_\-<>]+)_`)
- dependsRe = regexp.MustCompile(`_Depends:\s*([\d,\.\s]+)_`)
- parallelRe = regexp.MustCompile(`\(P\)`)
tddRe = regexp.MustCompile(`(?i)^(RED|GREEN)\b`)
)
@@ -52,6 +55,10 @@ type TaskStats struct {
// stats. Annotation lines (indented "_Requirements:_" etc.) following a task
// line are attributed to that task.
func ParseTasks(text string) ([]TaskPhase, TaskStats) {
+ // Normalize line endings and blank out fenced code so a "- [ ] 1. …" example
+ // inside a ``` block never inflates the board's Total/Done counts — matching
+ // exactly what the validator counts.
+ text = validator.MaskCodeFences(textutil.NormalizeNewlines(text))
var phases []TaskPhase
var stats TaskStats
curPhase := -1
diff --git a/internal/templater/templater.go b/internal/templater/templater.go
index 7b28704..2e35bb7 100644
--- a/internal/templater/templater.go
+++ b/internal/templater/templater.go
@@ -11,6 +11,8 @@ import (
"io/fs"
"strings"
"text/template"
+
+ "github.com/protonspy/csdd/internal/textutil"
)
//go:embed all:templates
@@ -33,7 +35,9 @@ func Render(efs fs.FS, path string, data any) (string, error) {
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("execute template %s: %w", path, err)
}
- return buf.String(), nil
+ // Emit LF so scaffolded files are byte-deterministic regardless of whether
+ // git checked the embedded templates out as LF or CRLF on the build machine.
+ return textutil.NormalizeNewlines(buf.String()), nil
}
// Static returns the raw text of a template without rendering, useful for
@@ -43,7 +47,7 @@ func Static(efs fs.FS, path string) (string, error) {
if err != nil {
return "", fmt.Errorf("read template %s: %w", path, err)
}
- return string(raw), nil
+ return textutil.NormalizeNewlines(string(raw)), nil
}
// RuleFiles enumerates every .claude/rules/ template, returning a
@@ -66,7 +70,7 @@ func RuleFiles(efs fs.FS) (map[string]string, error) {
}
// Strip the ".tmpl" suffix when emitting on disk.
name := strings.TrimSuffix(e.Name(), ".tmpl")
- out[name] = string(raw)
+ out[name] = textutil.NormalizeNewlines(string(raw))
}
return out, nil
}
@@ -89,7 +93,7 @@ func staticTree(efs fs.FS, dir string) (map[string]string, error) {
}
rel := strings.TrimPrefix(p, dir+"/")
rel = strings.TrimSuffix(rel, ".tmpl")
- out[rel] = string(raw)
+ out[rel] = textutil.NormalizeNewlines(string(raw))
return nil
})
if err != nil {
@@ -144,7 +148,7 @@ func WorkflowTemplateFiles(efs fs.FS) (map[string]string, error) {
if name == "spec.json" {
name = "init.json"
}
- out["specs/"+name] = string(raw)
+ out["specs/"+name] = textutil.NormalizeNewlines(string(raw))
}
steeringEntries, err := fs.ReadDir(efs, "templates/steering")
@@ -168,14 +172,14 @@ func WorkflowTemplateFiles(efs fs.FS) (map[string]string, error) {
if err != nil {
return nil, err
}
- out["steering/"+name] = string(raw)
+ out["steering/"+name] = textutil.NormalizeNewlines(string(raw))
}
customRef, err := fs.ReadFile(efs, "templates/steering-custom/custom.md.tmpl")
if err != nil {
return nil, err
}
- out["steering-custom/custom.md"] = string(customRef)
+ out["steering-custom/custom.md"] = textutil.NormalizeNewlines(string(customRef))
return out, nil
}
diff --git a/internal/templater/templates/agent/agent.md.tmpl b/internal/templater/templates/agent/agent.md.tmpl
index 6bf49c2..771eea1 100644
--- a/internal/templater/templates/agent/agent.md.tmpl
+++ b/internal/templater/templates/agent/agent.md.tmpl
@@ -1,35 +1,38 @@
----
-name: {{.Name}}
-description: {{.Description}}
-tools: {{.Tools}}
-{{- if .Model}}
-model: {{.Model}}
-{{- end}}
-{{- if .Effort}}
-effort: {{.Effort}}
-{{- end}}
----
-
-# {{.Title}}
-
-## Role
-[One paragraph: what this sub-agent does and the boundary it respects.]
-
-## Inputs
-- [What the orchestrator passes in.]
-
-## Operating Procedure
-1. [Step 1]
-2. [Step 2]
-3. [Step 3]
-
-## Output Format
-[Exact shape the orchestrator expects back. Templates outperform prose.]
-
-## Tool Scope and Least Privilege
-- Allowed tools: {{.Tools}}
-- Read-only outside this scope unless escalated by the orchestrator.
-
-## Done Criteria
-- [ ] [Concrete output produced]
-- [ ] [Self-check passed]
+---
+name: {{.Name}}
+description: {{.Description}}
+tools: {{.Tools}}
+{{- if .Model}}
+model: {{.Model}}
+{{- end}}
+{{- if .Effort}}
+effort: {{.Effort}}
+{{- end}}
+{{- if .Color}}
+color: {{.Color}}
+{{- end}}
+---
+
+# {{.Title}}
+
+## Role
+[One paragraph: what this sub-agent does and the boundary it respects.]
+
+## Inputs
+- [What the orchestrator passes in.]
+
+## Operating Procedure
+1. [Step 1]
+2. [Step 2]
+3. [Step 3]
+
+## Output Format
+[Exact shape the orchestrator expects back. Templates outperform prose.]
+
+## Tool Scope and Least Privilege
+- Allowed tools: {{.Tools}}
+- Read-only outside this scope unless escalated by the orchestrator.
+
+## Done Criteria
+- [ ] [Concrete output produced]
+- [ ] [Self-check passed]
diff --git a/internal/templater/templates/agents/code-reviewer.md.tmpl b/internal/templater/templates/agents/code-reviewer.md.tmpl
index d98be50..2e81d9f 100644
--- a/internal/templater/templates/agents/code-reviewer.md.tmpl
+++ b/internal/templater/templates/agents/code-reviewer.md.tmpl
@@ -2,6 +2,7 @@
name: code-reviewer
description: Read-only adversarial reviewer for a completed SDD/TDD task or a diff before opening a PR. Use after each task slice or before raising a pull request.
tools: Read, Grep, Glob
+color: red
---
You are a strict but pragmatic code reviewer. You review; you do not rewrite.
diff --git a/internal/templater/templates/agents/implementer.md.tmpl b/internal/templater/templates/agents/implementer.md.tmpl
index 878efa0..9cc7312 100644
--- a/internal/templater/templates/agents/implementer.md.tmpl
+++ b/internal/templater/templates/agents/implementer.md.tmpl
@@ -2,6 +2,10 @@
name: implementer
description: Implements a single task from specs/*/tasks.md inside its approved design.md boundary, following the spec's development_flow (tdd test-first by default; unit tests-after; tdd-e2e adds end-to-end) — runs the verification gate, records evidence, marks the task done, updates notes, and reports. Language-agnostic; specialize per stack via steering and skills.
tools: Read, Grep, Glob, Edit, Write, Bash
+skills:
+ - tdd-cycle
+ - verify-change
+color: green
---
You implement **one task at a time**, following the spec's `development_flow`, and
@@ -86,6 +90,10 @@ variants only add the stack-specific knowledge.
## Gotchas
+- When the `csdd` MCP server is connected, prefer the `csdd_spec_*` tools (e.g.
+ `csdd_spec_test_report`, `csdd_spec_diff_report`) over the `npx -y @protonspy/csdd`
+ forms shown above — identical gates and exit codes, typed arguments. Fall back to
+ `npx` only when the server is not connected.
- If no test framework exists, say so explicitly and stop — do not silently skip the test
(RED under `tdd`/`tdd-e2e`, the same-task unit test under `unit`).
- A passing suite is not proof of coverage; make sure the new behavior is exercised.
diff --git a/internal/templater/templates/agents/security-reviewer.md.tmpl b/internal/templater/templates/agents/security-reviewer.md.tmpl
index 24d69eb..a8b8f05 100644
--- a/internal/templater/templates/agents/security-reviewer.md.tmpl
+++ b/internal/templater/templates/agents/security-reviewer.md.tmpl
@@ -2,6 +2,7 @@
name: security-reviewer
description: Read-only security review of the current diff. Use whenever a change touches authentication, authorization, secrets, input handling, logging, or data exposure.
tools: Read, Grep, Glob
+color: orange
---
You perform a focused security review. You have no write access by design.
diff --git a/internal/templater/templates/agents/test-designer.md.tmpl b/internal/templater/templates/agents/test-designer.md.tmpl
index b7f7b7a..a059da3 100644
--- a/internal/templater/templates/agents/test-designer.md.tmpl
+++ b/internal/templater/templates/agents/test-designer.md.tmpl
@@ -2,6 +2,7 @@
name: test-designer
description: Read-only test-gap analyst. Use before implementing a task (to enumerate the tests it needs) or after (to find missing cases) — identifies edge cases, error paths, and regressions that lack coverage.
tools: Read, Grep, Glob
+color: cyan
---
You design tests; you do not write production code and you do not implement the tests yourself unless explicitly asked. Your output is a precise, ordered list of test cases.
diff --git a/internal/templater/templates/agents/wf-development.md.tmpl b/internal/templater/templates/agents/wf-development.md.tmpl
index 199db2e..b868dfd 100644
--- a/internal/templater/templates/agents/wf-development.md.tmpl
+++ b/internal/templater/templates/agents/wf-development.md.tmpl
@@ -1,7 +1,9 @@
---
name: wf-development
description: Downstream development workflow lead (wf:development). Orchestrates architecture → epics/stories → readiness → sprint → TDD implementation → review → retrospective on top of csdd's spec gates. Use after wf:product/discovery has produced an approved spec.
-tools: Read, Grep, Glob, Write, Edit, Bash, Skill
+tools: Read, Grep, Glob, Write, Edit, Bash, Skill, Agent
+effort: high
+color: blue
---
You are the Development Lead — the downstream half of the workflow pair
@@ -13,6 +15,14 @@ You orchestrate. The new skills add the BMAD planning layer (architecture,
epics/stories, readiness, sprint, retrospective); the *implementation and review*
reuse csdd's existing, validated machinery rather than reinventing it.
+**Run this as the main session agent** — launch it with `claude --agent wf-development`
+or set `"agent": "wf-development"` in `.claude/settings.json`; do not invoke it as a
+spawned sub-agent. Only the main session agent gets slash commands (`/clear`),
+interactive turns to pause at each gate for the human's approval, and the ability to
+spawn the fresh-context sub-agents (`implementer`, `code-reviewer`, `security-reviewer`,
+`test-designer`) this workflow drives. A spawned sub-agent runs straight to completion
+with no gate stops — exactly what this workflow forbids.
+
## Reuse, don't duplicate
This workflow stands on what csdd already ships:
@@ -20,8 +30,8 @@ This workflow stands on what csdd already ships:
- **Spec gates:** `npx -y @protonspy/csdd spec generate|validate|approve` for design and tasks.
- **Implementation:** the `tdd-cycle` skill — one red→green→refactor per task.
- **Verification:** the `verify-change` skill for the evidence block.
-- **Review:** the `code-reviewer` and `security-reviewer` sub-agents and the
- `pr-review` skill.
+- **Review:** the `test-designer` sub-agent for test-gap analysis, the
+ `code-reviewer` and `security-reviewer` sub-agents, and the `pr-review` skill.
- **Commit:** the `/csdd-commit` command and the pre-push test gate.
The `dev-*` skills below fill only the gaps BMAD covers that csdd did not.
@@ -52,14 +62,26 @@ The `dev-*` skills below fill only the gaps BMAD covers that csdd did not.
kebab-case (`feat/` feature, `fix/` bugfix, `chore/` small adjustment; also
`refactor/` `docs/` `test/` `perf/`). Then run `tdd-cycle` one task at a time,
updating `sprint-status.yaml` as tasks move. Never batch tasks "to save time".
+ **Parallelize across boundaries.** Tasks marked `(P)` in *different*
+ `_Boundary:_` groups have no ordering between them: dispatch one `implementer`
+ sub-agent per such task **concurrently** — each still owns exactly one task —
+ spawning each with worktree isolation (`isolation: worktree`) so simultaneous
+ writes don't collide, then integrating them back. Honor every `_Depends:_`
+ before starting a task, and keep
+ same-boundary tasks sequential. The rule is "one task per `implementer`", not
+ "one `implementer` at a time".
4. **Review every slice** with `code-reviewer` (and `security-reviewer` when auth
- / secrets / input handling is touched). Resolve blockers before the next task.
+ / secrets / input handling is touched); use `test-designer` to enumerate missing
+ cases before or after a slice. Resolve blockers before the next task.
5. **Close the epic** with `dev-retrospective` and feed durable lessons back into
steering. Once the feature's PR ships, run `/clear` before the next feature so
context does not accumulate across specs.
## Gotchas
+- When the `csdd` MCP server is connected, drive the gates with the `csdd_*` tools
+ (typed arguments, identical exit codes) and fall back to the `npx -y @protonspy/csdd`
+ forms shown above only when it is not — the phase gates are the same either way.
- You are downstream only. If a requirement is wrong or missing, do not patch it
in code — route it back to discovery; the PRD and EARS requirements are the
source of truth.
diff --git a/internal/templater/templates/agents/wf-product-discovery.md.tmpl b/internal/templater/templates/agents/wf-product-discovery.md.tmpl
index 881f667..3b6856c 100644
--- a/internal/templater/templates/agents/wf-product-discovery.md.tmpl
+++ b/internal/templater/templates/agents/wf-product-discovery.md.tmpl
@@ -2,6 +2,8 @@
name: wf-product-discovery
description: Upstream product-discovery workflow lead (wf:product/discovery). Orchestrates brief → research → PRFAQ → PRD → UX, then hands a validated PRD into csdd steering + a spec. Use to turn a raw idea into a decision-ready product definition.
tools: Read, Grep, Glob, Write, Edit, Bash, Skill
+effort: high
+color: purple
---
You are the Product Discovery Lead — the upstream half of the workflow pair
@@ -13,6 +15,13 @@ You orchestrate; the member skills do the focused work. You decide what runs
next based on how much is already known, you enforce the gates between phases,
and you keep every artifact traceable back to a real user problem.
+**Run this as the main session agent** — launch it with
+`claude --agent wf-product-discovery` or set `"agent": "wf-product-discovery"` in
+`.claude/settings.json`; do not invoke it as a spawned sub-agent. The phase gates
+below require the human to confirm before advancing and use slash commands, which only
+the main session agent can do — a spawned sub-agent would run straight through every
+phase with no stops.
+
## Workflow map
Each phase is a skill. Phases are optional except the PRD and the handoff — run
diff --git a/internal/templater/templates/hooks/block-destructive.sh.tmpl b/internal/templater/templates/hooks/block-destructive.sh.tmpl
index 734f6f2..4361b69 100644
--- a/internal/templater/templates/hooks/block-destructive.sh.tmpl
+++ b/internal/templater/templates/hooks/block-destructive.sh.tmpl
@@ -13,7 +13,15 @@ if command -v jq >/dev/null 2>&1; then
elif command -v python3 >/dev/null 2>&1; then
cmd="$(printf '%s' "$input" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_input",{}).get("command",""))' 2>/dev/null || true)"
else
- cmd="$input"
+ # No jq/python3: best-effort extract just the "command" string with sed rather
+ # than scanning the whole payload (matching the whole payload would let the
+ # denylist trip on file paths or a tool description that merely mentions, say,
+ # "rm -rf"). Heuristic only — it stops at the first embedded quote, so a
+ # command containing an escaped quote is matched by its (leading) prefix. If
+ # extraction yields nothing, fall back to the whole payload so we fail safe
+ # (may over-block) rather than letting a destructive command slip through.
+ cmd="$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)"
+ [ -n "$cmd" ] || cmd="$input"
fi
# Denylist of destructive patterns. Conservative on purpose: these need a human.
diff --git a/internal/templater/templates/hooks/format-after-edit.sh.tmpl b/internal/templater/templates/hooks/format-after-edit.sh.tmpl
index 94802be..2b670d9 100644
--- a/internal/templater/templates/hooks/format-after-edit.sh.tmpl
+++ b/internal/templater/templates/hooks/format-after-edit.sh.tmpl
@@ -1,8 +1,9 @@
#!/bin/bash
-# PostToolUse(Edit|Write|MultiEdit) hook: best-effort format the edited file.
+# PostToolUse(Edit|Write|NotebookEdit) hook: best-effort format the edited file.
# Reads tool_input.file_path from the JSON on stdin. Always exits 0 — formatting
-# is a convenience, never a gate. Unknown extensions and missing formatters are
-# silently skipped.
+# is a convenience, never a gate. Unknown extensions, missing formatters, AND
+# formatters that fail (e.g. on a syntactically-broken file mid-edit) are all
+# silently tolerated: each branch ends in `|| true` so `set -e` never aborts.
set -euo pipefail
input="$(cat)"
@@ -17,11 +18,11 @@ fi
[ -n "$file" ] && [ -f "$file" ] || exit 0
case "$file" in
- *.go) command -v gofmt >/dev/null 2>&1 && gofmt -w "$file" ;;
- *.rs) command -v rustfmt >/dev/null 2>&1 && rustfmt "$file" ;;
- *.py) command -v black >/dev/null 2>&1 && black -q "$file" ;;
+ *.go) command -v gofmt >/dev/null 2>&1 && gofmt -w "$file" || true ;;
+ *.rs) command -v rustfmt >/dev/null 2>&1 && rustfmt "$file" || true ;;
+ *.py) command -v black >/dev/null 2>&1 && black -q "$file" || true ;;
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
- command -v prettier >/dev/null 2>&1 && prettier --write "$file" ;;
+ command -v prettier >/dev/null 2>&1 && prettier --write "$file" || true ;;
esac
exit 0
diff --git a/internal/templater/templates/hooks/test-before-stop.sh.tmpl b/internal/templater/templates/hooks/test-before-stop.sh.tmpl
index 007828c..c5832b9 100644
--- a/internal/templater/templates/hooks/test-before-stop.sh.tmpl
+++ b/internal/templater/templates/hooks/test-before-stop.sh.tmpl
@@ -1,31 +1,26 @@
#!/bin/bash
-# Stop hook: remind the agent to verify before claiming completion.
+# Stop hook: nudge the agent to verify before it claims completion.
#
-# By default this is ADVISORY (exits 0) — running the full suite on every Stop
-# is slow and noisy. It prints the verification checklist so the reminder lands
-# in context. To make it a hard gate, uncomment the block below to run your
-# project's checks and `exit 2` on failure (exit 2 tells Claude Code to keep
-# working rather than stop).
-set -euo pipefail
-
-echo "Before reporting done, run the verify-change skill: tests, lint, typecheck, build — and paste the real output." >&2
-
-# Spec-boundary context hygiene: when the spec you're working on has all its
-# tasks checked, nudge the operator to /clear before the next feature so context
-# does not accumulate across specs. Claude cannot run /clear itself — this is a
-# reminder to the human. Keyed off the most recently modified specs/*/tasks.md.
-specs_dir="${CLAUDE_PROJECT_DIR:-$PWD}/specs"
-latest_tasks=$(ls -t "$specs_dir"/*/tasks.md 2>/dev/null | head -n1 || true)
-if [ -n "$latest_tasks" ]; then
- total=$(grep -cE '^[[:space:]]*- \[[ xX]\]' "$latest_tasks" 2>/dev/null || true)
- open=$(grep -cE '^[[:space:]]*- \[ \]' "$latest_tasks" 2>/dev/null || true)
- if [ "${total:-0}" -gt 0 ] && [ "${open:-0}" -eq 0 ]; then
- feature=$(basename "$(dirname "$latest_tasks")")
- echo "Spec '$feature' has all tasks checked — once its PR ships, run /clear to reset context before the next feature." >&2
- fi
-fi
+# Claude Code Stop-hook contract:
+# * exit 0 with NO stdout -> allow the stop (silent).
+# * print {"decision":"block","reason":"..."} to STDOUT and exit 0
+# -> block the stop; "reason" is fed back to Claude as its next turn.
+# * exit 2 with a message on STDERR -> also blocks the stop (hard gate).
+# A plain `echo ... >&2; exit 0` does NOT reach Claude for a Stop hook, so this
+# script emits the JSON decision form. To avoid an infinite stop-loop it fires
+# ONE-SHOT: the first Stop blocks with the reminder, the next Stop allows the
+# stop. Two independent loop-guards make that safe:
+# 1. stdin's `stop_hook_active` is true when Claude is already continuing
+# because a previous Stop hook blocked -> we never block again.
+# 2. a per-session sentinel marker records that we already nudged this round.
+set -uo pipefail
+
+input="$(cat)"
# --- Hard gate (opt-in): uncomment and adapt to your project ---------------
+# Runs your checks on every Stop and blocks with exit 2 (stderr is fed back to
+# Claude) until they pass. Enable this only if you want a strict gate; it
+# supersedes the advisory one-shot nudge below.
# if [ -f Makefile ] && grep -qE '^(test|verify):' Makefile; then
# make verify || make test || { echo "verification failed — not done yet" >&2; exit 2; }
# elif [ -f package.json ]; then
@@ -35,4 +30,70 @@ fi
# fi
# ---------------------------------------------------------------------------
+# Parse the two fields we care about from the stdin JSON (jq -> python3 -> crude
+# grep/sed fallback so the hook works with none of them installed).
+stop_active=false
+session_id=""
+if command -v jq >/dev/null 2>&1; then
+ stop_active="$(printf '%s' "$input" | jq -r '.stop_hook_active // false' 2>/dev/null || echo false)"
+ session_id="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)"
+elif command -v python3 >/dev/null 2>&1; then
+ stop_active="$(printf '%s' "$input" | python3 -c 'import sys,json;print(str(json.load(sys.stdin).get("stop_hook_active",False)).lower())' 2>/dev/null || echo false)"
+ session_id="$(printf '%s' "$input" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("session_id",""))' 2>/dev/null || true)"
+else
+ printf '%s' "$input" | grep -Eq '"stop_hook_active"[[:space:]]*:[[:space:]]*true' && stop_active=true
+ session_id="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 || true)"
+fi
+
+# Per-session marker kept in the temp dir (not the repo) so it auto-cleans and
+# never gets committed.
+marker="${TMPDIR:-/tmp}/csdd-stop-nudged-${session_id:-default}"
+
+# Loop-guard 1: Claude is already continuing from a previous Stop-hook block.
+# Never block again; clear any stale marker.
+if [ "$stop_active" = "true" ]; then
+ rm -f "$marker" 2>/dev/null || true
+ exit 0
+fi
+
+# Loop-guard 2: we already nudged on the previous Stop -> allow the stop now
+# and clear the marker.
+if [ -f "$marker" ]; then
+ rm -f "$marker" 2>/dev/null || true
+ exit 0
+fi
+
+# Build the reminder that will be fed back to Claude.
+reason="Before reporting done, run the verify-change skill: tests, lint, typecheck, build — and paste the real output."
+
+# Spec-boundary context hygiene: if the most recently modified specs/*/tasks.md
+# has every task checked, fold in a /clear reminder (Claude cannot run /clear
+# itself, but it will surface this to the operator). Keyed off the newest
+# specs/*/tasks.md.
+specs_dir="${CLAUDE_PROJECT_DIR:-$PWD}/specs"
+latest_tasks="$(ls -t "$specs_dir"/*/tasks.md 2>/dev/null | head -n1 || true)"
+if [ -n "$latest_tasks" ]; then
+ total="$(grep -cE '^[[:space:]]*- \[[ xX]\]' "$latest_tasks" 2>/dev/null || true)"
+ open="$(grep -cE '^[[:space:]]*- \[ \]' "$latest_tasks" 2>/dev/null || true)"
+ if [ "${total:-0}" -gt 0 ] && [ "${open:-0}" -eq 0 ]; then
+ feature="$(basename "$(dirname "$latest_tasks")")"
+ reason="$reason Also: spec '$feature' has all tasks checked — once its PR ships, remind the operator to run /clear before the next feature."
+ fi
+fi
+
+# Record the marker BEFORE emitting so that even a crash mid-emit lets the next
+# Stop through, then emit the one-shot block decision on stdout and exit 0.
+touch "$marker" 2>/dev/null || true
+
+if command -v jq >/dev/null 2>&1; then
+ jq -n --arg r "$reason" '{decision:"block", reason:$r}'
+elif command -v python3 >/dev/null 2>&1; then
+ printf '%s' "$reason" | python3 -c 'import sys,json;print(json.dumps({"decision":"block","reason":sys.stdin.read()}))'
+else
+ # No jq/python3: the reason built above contains no " or \ characters, so this
+ # hand-rolled JSON is safe. (A spec directory name containing a double quote
+ # could break it, but those are not valid feature names.)
+ printf '{"decision":"block","reason":"%s"}\n' "$reason"
+fi
+
exit 0
diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl
index 6bf3b0d..c3607ac 100644
--- a/internal/templater/templates/root/CLAUDE.md.tmpl
+++ b/internal/templater/templates/root/CLAUDE.md.tmpl
@@ -1,250 +1,250 @@
-# Project Entry Point
-
-This repository follows a Spec-Driven Development (SDD) + Test-Driven
-Development (TDD) workflow, native to **Claude Code**. Steering, specs, skills,
-and custom sub-agents are managed by the `csdd` CLI — or, preferably, its MCP
-tools (see *Driving csdd* below).
-
-**This file is the contract *and* the operational reference.** Everything an
-agent needs to drive `csdd` — the gates, command surface, and phrasing rules —
-lives here. Read it before you create, generate, approve, or edit *any* steering
-file, spec, skill, or agent. For the full CLI surface, run
-`npx @protonspy/csdd --help`.
-
-## STOP — hard rules, enforced not advisory
-
-Breaking one of these corrupts the contract layer that every human review and
-every gate depends on:
-
-1. **`csdd` is the only sanctioned author of managed files.** Create and change
- steering, specs, skills, and agents **only** through the `csdd_*` MCP tools or
- the `csdd` CLI — never by writing the files directly.
-2. **Never hand-edit `spec.json`.** It records phase approvals and
- `ready_for_implementation`. Writing to it directly — even to "unblock"
- yourself — bypasses the human gate and is a process violation. Phases are
- crossed *only* with `npx @protonspy/csdd spec approve