diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9d61a9d..40d39f8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,23 +25,37 @@ "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=delegated", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - // --- Add language runtimes / tools here (optional) ------------------------- - // Devcontainer features layer on top of the image without editing the Dockerfile. - // Examples (uncomment what you need): - // "features": { - // "ghcr.io/devcontainers/features/python:1": { "version": "3.13" }, - // "ghcr.io/devcontainers/features/go:1": {}, - // "ghcr.io/devcontainers/features/rust:1": {} - // }, + // --- Language runtimes / tools -------------------------------------------- + // Devcontainer features layer on top of the base image without editing the + // Dockerfile. Go is this repo's primary toolchain: pin it to the version CI + // builds/tests with (.github/workflows/ci.yml) plus the golangci-lint the lint + // job runs. "1.25" resolves to the latest 1.25.x patch. (Node comes from the + // base node:24 image, so it needs no feature here.) + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "1.25", + "golangciLintVersion": "2.12.2" + } + }, "customizations": { "vscode": { "extensions": [ "anthropic.claude-code", - "eamodio.gitlens" + "eamodio.gitlens", + "golang.go" ], "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" + "terminal.integrated.defaultProfile.linux": "zsh", + "go.useLanguageServer": true, + // Keep files gofmt-clean and imports tidy — the CI lint job gates on + // `gofmt -l`, so match it on save. + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + } } } }, @@ -65,6 +79,13 @@ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" }, + // Runs once at create, while egress is still wide open (before postStartCommand + // programs the firewall): warm the module cache, install the local csdd binary + // onto PATH (${GOPATH}/bin) so `csdd` works in the sandbox, and grab + // govulncheck (matching CI's lint job). The web dashboard stays a placeholder + // until `make web-build`. Best-effort: a hiccup here must never block creation. + "postCreateCommand": "go mod download && go install golang.org/x/vuln/cmd/govulncheck@latest || true", + // Program the egress firewall on every start (needs the caps above). Wait for // it before handing over the container so nothing escapes the default-deny. "postStartCommand": "sudo /usr/local/bin/init-firewall.sh", diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index 97d315c..4e98e45 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -82,7 +82,10 @@ for domain in \ "statsig.com" \ "marketplace.visualstudio.com" \ "vscode.blob.core.windows.net" \ - "update.code.visualstudio.com"; do + "update.code.visualstudio.com" \ + "proxy.golang.org" \ + "sum.golang.org" \ + "vuln.go.dev"; do echo "Resolving $domain..." ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}') if [ -z "$ips" ]; then diff --git a/README.md b/README.md index 98f51d0..efd3a8f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ csdd spec generate my-feature --artifact requirements # → validate → appro | Command | What it does | |---|---| -| `csdd init [--with-baseline]` | bootstrap the workspace (steering · skills · agents · commands · hooks · docs) | +| `csdd init [--with-baseline] [--hooks] [--prepush]` | bootstrap the workspace (steering · skills · agents · commands · docs); the Claude Code hooks and the pre-push gate are opt-in via `--hooks`/`--prepush` (or `--include hooks,pre-push`) | | `csdd update [--dry-run]` · `clean` · `destroy` | upgrade managed artifacts (keeping your edits as `.old`) · sweep `.old` backups · tear the workspace back down | | `csdd copy /` | cherry-pick one shipped artifact (skill · agent · rule · command · hook · template · steering) — the complement of `init --exclude` | | `/csdd-setup-init` · `/csdd-setup-update` | adapt / refresh the workflow for your stack — Claude Code slash commands | @@ -487,7 +487,7 @@ CLAUDE.md # entry point + steering imports + knowledge-base workflow .claude/agents/*.md # sub-agents (implementer, code-reviewer, …) .claude/skills// # skill bundles .claude/commands/ # slash commands (/csdd-setup-init, /csdd-commit, /prd, /csdd-graph-query, …) -.claude/hooks/ # deterministic automation (format-after-edit, pre-push test gate, …) +.claude/hooks/ # deterministic automation (format-after-edit, …) — opt-in via `init --hooks` .csdd/ # csdd's operational state (the workspace marker + manifest.json) — the csdd analog of .git/ specs// # SDD contracts docs/ # knowledge base: plans/ · adr/ · wiki/ · graph/ · raw/ · glossary.md · stack.md · product/ diff --git a/internal/cli/cli_extra_test.go b/internal/cli/cli_extra_test.go index aa8cb71..3fd448e 100644 --- a/internal/cli/cli_extra_test.go +++ b/internal/cli/cli_extra_test.go @@ -767,6 +767,109 @@ func TestInitScaffoldsClaudeCodeArtifacts(t *testing.T) { } } +// TestInitDefaultOmitsOptInComponents asserts a bare `csdd init` leaves out the +// opt-in components (Claude Code hooks + the pre-push gate) and that the default +// settings.json therefore carries no hook wiring. +func TestInitDefaultOmitsOptInComponents(t *testing.T) { + dir := t.TempDir() + if code, _, errOut := run(t, "init", "--root", dir); code != 0 { + t.Fatalf("init failed (code=%d): %s", code, errOut) + } + for _, p := range []string{ + ".claude/hooks", + ".claude/hooks/block-destructive.sh", + ".githooks/pre-push", + } { + if _, err := os.Stat(filepath.Join(dir, p)); err == nil { + t.Errorf("default init should NOT scaffold opt-in component %s", p) + } + } + var parsed map[string]any + raw := readFile(t, filepath.Join(dir, ".claude", "settings.json")) + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + t.Fatalf("default settings.json is not valid JSON: %v", err) + } + if _, ok := parsed["hooks"]; ok { + t.Errorf("default settings.json must not wire hooks:\n%s", raw) + } + if _, ok := parsed["permissions"]; !ok { + t.Errorf("default settings.json should keep the permissions block:\n%s", raw) + } +} + +// TestInitOptInHooksAndPrePush covers the --include spelling (with the "prepush" +// alias) scaffolding the hook scripts and pre-push gate as executables and +// wiring the hooks into settings.json, which must stay valid JSON. +func TestInitOptInHooksAndPrePush(t *testing.T) { + dir := t.TempDir() + code, out, errOut := run(t, "init", "--root", dir, "--include", "hooks,prepush") + if code != 0 { + t.Fatalf("init --include failed (code=%d): %s", code, errOut) + } + if !strings.Contains(out, "included (opt-in): hooks, pre-push") { + t.Errorf("expected an opt-in summary, got:\n%s", out) + } + for _, exe := range []string{ + ".claude/hooks/block-destructive.sh", + ".claude/hooks/format-after-edit.sh", + ".claude/hooks/test-before-stop.sh", + ".githooks/pre-push", + } { + info, err := os.Stat(filepath.Join(dir, exe)) + if err != nil { + t.Errorf("--include should have scaffolded %s: %v", exe, err) + continue + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + t.Errorf("%s must be executable, mode=%v", exe, info.Mode()) + } + } + var parsed map[string]any + raw := readFile(t, filepath.Join(dir, ".claude", "settings.json")) + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + t.Fatalf("merged settings.json is not valid JSON: %v", err) + } + if _, ok := parsed["hooks"]; !ok { + t.Errorf("opted-in settings.json must wire the hooks:\n%s", raw) + } + if _, ok := parsed["permissions"]; !ok { + t.Errorf("merged settings.json dropped the permissions block:\n%s", raw) + } + if !strings.Contains(raw, "block-destructive.sh") { + t.Errorf("opted-in settings.json must reference the hook scripts:\n%s", raw) + } +} + +// TestInitHooksBooleanFlag asserts the --hooks convenience flag scaffolds the +// hooks (and wires settings.json) without dragging in the pre-push gate. +func TestInitHooksBooleanFlag(t *testing.T) { + dir := t.TempDir() + if code, _, errOut := run(t, "init", "--root", dir, "--hooks"); code != 0 { + t.Fatalf("init --hooks failed (code=%d): %s", code, errOut) + } + if _, err := os.Stat(filepath.Join(dir, ".claude/hooks/block-destructive.sh")); err != nil { + t.Errorf("--hooks should scaffold the hook scripts: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".githooks/pre-push")); err == nil { + t.Error("--hooks alone must NOT scaffold the pre-push gate") + } +} + +// TestInitRejectsExcludedOptInKeys makes sure the now opt-in keys are no longer +// accepted by --exclude (they are off by default; --include turns them on). +func TestInitRejectsExcludedOptInKeys(t *testing.T) { + for _, key := range []string{"hooks", "pre-push"} { + dir := t.TempDir() + code, _, errOut := run(t, "init", "--root", dir, "--exclude", key) + if code == 0 { + t.Errorf("--exclude %s should be rejected now that it is opt-in", key) + } + if !strings.Contains(errOut, "unknown --exclude component") { + t.Errorf("--exclude %s: expected an unknown-component error, got: %s", key, errOut) + } + } +} + // TestInitWithBaselineImportsSteering asserts CLAUDE.md imports the baseline // steering files as always-on @-references. func TestInitWithBaselineImportsSteering(t *testing.T) { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c42e698..e52fd14 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -44,7 +44,9 @@ func run(t *testing.T, args ...string) (int, string, string) { func freshWorkspace(t *testing.T) string { t.Helper() dir := t.TempDir() - if code, _, errOut := run(t, "init", "--root", dir, "--with-baseline"); code != 0 { + // A complete workspace for tests exercises every tree, so opt into the + // hooks and pre-push components that a bare `csdd init` now leaves out. + if code, _, errOut := run(t, "init", "--root", dir, "--with-baseline", "--hooks", "--prepush"); code != 0 { t.Fatalf("init failed (code=%d): %s", code, errOut) } return dir @@ -186,7 +188,8 @@ func TestInitExcludeAgentsSkills(t *testing.T) { t.Errorf("--exclude should have skipped %s", p) } } - // Still present: the rest of the full workspace. + // Still present: the rest of the full workspace. (hooks and pre-push are + // opt-in, so they are absent here — see TestInitDefaultOmitsOptInComponents.) for _, p := range []string{ "CLAUDE.md", ".mcp.json", @@ -194,8 +197,6 @@ func TestInitExcludeAgentsSkills(t *testing.T) { ".claude/rules/ears-format.md", ".claude/templates/specs/requirements.md", ".claude/commands", - ".claude/hooks", - ".githooks/pre-push", "specs", } { if _, err := os.Stat(filepath.Join(dir, p)); err != nil { @@ -208,10 +209,10 @@ func TestInitExcludeAgentsSkills(t *testing.T) { // comma lists, and that both forms compose. func TestInitExcludeRepeatableFlag(t *testing.T) { dir := t.TempDir() - if code, _, errOut := run(t, "init", "--root", dir, "--exclude", "agents", "--exclude", "hooks,commands"); code != 0 { + if code, _, errOut := run(t, "init", "--root", dir, "--exclude", "agents", "--exclude", "rules,commands"); code != 0 { t.Fatalf("init failed (code=%d): %s", code, errOut) } - for _, p := range []string{".claude/agents", ".claude/hooks", ".claude/commands"} { + for _, p := range []string{".claude/agents", ".claude/rules", ".claude/commands"} { if _, err := os.Stat(filepath.Join(dir, p)); err == nil { t.Errorf("repeated/comma --exclude should have skipped %s", p) } diff --git a/internal/cli/copy_test.go b/internal/cli/copy_test.go index 7c507bb..412cb3b 100644 --- a/internal/cli/copy_test.go +++ b/internal/cli/copy_test.go @@ -12,8 +12,9 @@ import ( func bareWorkspace(t *testing.T) string { t.Helper() dir := t.TempDir() + // hooks are opt-in (absent unless --include hooks), so they need no exclude. if code, _, errOut := run(t, "init", "--root", dir, - "--exclude", "skills,agents,rules,commands,hooks,templates,steering"); code != 0 { + "--exclude", "skills,agents,rules,commands,templates,steering"); code != 0 { t.Fatalf("init --exclude failed (code=%d): %s", code, errOut) } return dir diff --git a/internal/cli/init.go b/internal/cli/init.go index ae281aa..6bbc958 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -2,6 +2,7 @@ package cli import ( "embed" + "encoding/json" "flag" "fmt" "io/fs" @@ -21,10 +22,14 @@ func runInit(args []string, templates embed.FS) int { fs := flag.NewFlagSet("init", flag.ContinueOnError) var root string var withBaseline, noMCP bool - var exclude stringSliceFlag + var exclude, include stringSliceFlag + var withHooks, withPrePush bool fs.StringVar(&root, "root", "", "Target directory (default: cwd).") fs.BoolVar(&withBaseline, "with-baseline", false, "Also scaffold product.md, tech.md, structure.md.") fs.Var(&exclude, "exclude", "Scaffold components to skip (comma-separated, repeatable), e.g. --exclude agents,skills. Valid: "+strings.Join(excludableKeys(), ", ")+".") + fs.Var(&include, "include", "Opt-in components to scaffold — OFF by default (comma-separated, repeatable): "+strings.Join(includableKeys(), ", ")+".") + fs.BoolVar(&withHooks, "hooks", false, "Scaffold the Claude Code hooks (.claude/hooks/) and wire them in settings.json (alias for --include hooks).") + fs.BoolVar(&withPrePush, "prepush", false, "Scaffold the .githooks/pre-push test gate (alias for --include pre-push).") fs.BoolVar(&noMCP, "no-mcp", false, "Do not register the csdd MCP server in .mcp.json.") if err := fs.Parse(args); err != nil { return failOnFlagParse(err) @@ -38,7 +43,19 @@ func runInit(args []string, templates embed.FS) int { render.Err(err.Error()) return 1 } - opts := initOptions{withBaseline: withBaseline, exclude: exSet} + incSet, err := parseInclusions(include.values) + if err != nil { + render.Err(err.Error()) + return 1 + } + // The dedicated booleans are just conveniences for the common opt-ins. + if withHooks { + incSet["hooks"] = true + } + if withPrePush { + incSet["pre-push"] = true + } + opts := initOptions{withBaseline: withBaseline, exclude: exSet, include: incSet} if root == "" { var err error root, err = filepath.Abs(".") @@ -66,9 +83,12 @@ func runInit(args []string, templates embed.FS) int { if len(exSet) > 0 { render.Info("excluded: " + strings.Join(excludedList(exSet), ", ")) } + if len(incSet) > 0 { + render.Info("included (opt-in): " + strings.Join(includedList(incSet), ", ")) + } // Post-scaffold chores only fire for the components that were actually laid - // down, so `--exclude mcp` / `--exclude pre-push` don't advertise things that - // aren't there. + // down, so `--exclude mcp` / a not-opted-in pre-push don't advertise things + // that aren't there. if !noMCP && !opts.skip("mcp") { if added, err := ensureCsddMCPServer(root); err != nil { render.Warn("could not register csdd MCP server: " + err.Error()) @@ -80,6 +100,9 @@ func runInit(args []string, templates embed.FS) int { if !opts.skip("pre-push") { render.Info("Enable the pre-push test gate: `git config core.hooksPath .githooks`") } + if opts.skip("hooks") && opts.skip("pre-push") { + render.Info("Claude Code hooks and the pre-push gate are opt-in — add them with `--hooks --prepush` (or `--include hooks,pre-push`).") + } if !opts.withBaseline && !opts.skip("steering") { render.Info("Run `" + prog() + " steering init` to scaffold standard steering files.") } @@ -148,18 +171,37 @@ var excludable = []struct{ key, desc string }{ {"mcp", ".mcp.json + csdd MCP server registration"}, {"settings", ".claude/settings.json"}, {"pr-template", ".github/pull_request_template.md"}, - {"pre-push", ".githooks/pre-push test gate"}, {"rules", ".claude/rules/"}, {"templates", ".claude/templates/"}, {"steering", ".claude/steering/"}, {"agents", ".claude/agents/"}, {"skills", ".claude/skills/"}, {"commands", ".claude/commands/"}, - {"hooks", ".claude/hooks/"}, {"specs", "specs/"}, {"knowledge", "docs/ knowledge base (plans/raw/wiki/graph) + docs/stack.md tech contract"}, } +// includable is the set of OPT-IN components: off by default, scaffolded only +// when named via --include (or the --hooks/--prepush conveniences). They carry +// side effects a fresh clone usually doesn't want unprompted — Claude Code hooks +// that gate every tool call, and a git hook that runs the whole suite on push. +var includable = []struct{ key, desc string }{ + {"hooks", ".claude/hooks/ + settings.json wiring"}, + {"pre-push", ".githooks/pre-push test gate"}, +} + +// includeAliases maps user-friendly spellings to the canonical component key. +var includeAliases = map[string]string{"prepush": "pre-push"} + +// includableKeys returns the valid --include component keys in canonical order. +func includableKeys() []string { + keys := make([]string, len(includable)) + for i, comp := range includable { + keys[i] = comp.key + } + return keys +} + // excludableKeys returns the valid --exclude component keys in canonical order. func excludableKeys() []string { keys := make([]string, len(excludable)) @@ -204,18 +246,97 @@ func excludedList(set map[string]bool) []string { return out } +// parseInclusions flattens the (repeatable, comma-separated) --include values +// into a validated set of opt-in component keys, folding aliases (e.g. "prepush" +// → "pre-push") to their canonical form. Unknown keys are rejected loudly. +func parseInclusions(raw []string) (map[string]bool, error) { + valid := map[string]bool{} + for _, k := range includableKeys() { + valid[k] = true + } + set := map[string]bool{} + for _, chunk := range raw { + for _, part := range strings.Split(chunk, ",") { + key := strings.ToLower(strings.TrimSpace(part)) + if key == "" { + continue + } + if canon, ok := includeAliases[key]; ok { + key = canon + } + if !valid[key] { + return nil, fmt.Errorf("unknown --include component %q; valid: %s", key, strings.Join(includableKeys(), ", ")) + } + set[key] = true + } + } + return set, nil +} + +// includedList returns the included keys present in set, in canonical order. +func includedList(set map[string]bool) []string { + var out []string + for _, k := range includableKeys() { + if set[k] { + out = append(out, k) + } + } + return out +} + // initOptions controls what `csdd init` scaffolds. type initOptions struct { // withBaseline also writes the standard steering files (product/tech/…) and // imports them into CLAUDE.md. withBaseline bool - // exclude is the set of scaffold components (see excludable) to skip, so csdd + // exclude is the set of opt-out components (see excludable) to skip, so csdd // can be dropped into a project that already has parts of a Claude Code setup. exclude map[string]bool + // include is the set of opt-in components (see includable) to scaffold; these + // are OFF unless explicitly requested. + include map[string]bool } -// skip reports whether the given scaffold component was excluded. -func (o initOptions) skip(component string) bool { return o.exclude[component] } +// optInComponents are scaffolded only when named in initOptions.include. +var optInComponents = map[string]bool{"hooks": true, "pre-push": true} + +// skip reports whether the given scaffold component should be left out: opt-in +// components are skipped unless explicitly included; every other component is on +// unless it was excluded. +func (o initOptions) skip(component string) bool { + if optInComponents[component] { + return !o.include[component] + } + return o.exclude[component] +} + +// settingsWithHooks merges the Claude Code hook wiring (PreToolUse/PostToolUse/ +// Stop → the .claude/hooks scripts) into the base settings.json. The base ships +// without any hooks block so a default workspace never points at hook scripts it +// didn't scaffold; the wiring lives in its own template and is folded in only +// when the hooks component is opted into. Top-level keys are re-emitted sorted, +// which is fine for a generated file. +func settingsWithHooks(templates embed.FS, base string) (string, error) { + hooks, err := templater.Static(templates, "templates/root/settings-hooks.json.tmpl") + if err != nil { + return "", err + } + var b, h map[string]any + if err := json.Unmarshal([]byte(base), &b); err != nil { + return "", fmt.Errorf("parse settings.json template: %w", err) + } + if err := json.Unmarshal([]byte(hooks), &h); err != nil { + return "", fmt.Errorf("parse settings-hooks.json template: %w", err) + } + for k, v := range h { + b[k] = v + } + out, err := json.MarshalIndent(b, "", " ") + if err != nil { + return "", err + } + return string(out) + "\n", nil +} // initWorkspace is exported semantically so the TUI can call the same logic. // It creates the standard Claude Code layout idempotently, honoring opts.exclude, @@ -274,6 +395,15 @@ func initWorkspace(root string, opts initOptions, templates embed.FS) (initCount if err != nil { return c, err } + // The Claude Code hooks are opt-in: only wire them into settings.json when + // the hook scripts are actually being scaffolded, so a default workspace + // never references commands that aren't on disk. + if rf.comp == "settings" && !opts.skip("hooks") { + content, err = settingsWithHooks(templates, content) + if err != nil { + return c, err + } + } created, err := workspace.SafeWrite(rf.path, content) if err != nil { return c, err diff --git a/internal/cli/update.go b/internal/cli/update.go index 4ffd9cb..03b5435 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -73,7 +73,17 @@ func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) {paths.Skills(root), templater.SkillFiles, false, managedExecutionOverrideKeys}, {paths.Agents(root), templater.AgentFiles, false, managedExecutionOverrideKeys}, {paths.Commands(root), templater.CommandFiles, false, nil}, - {paths.Hooks(root), templater.HookFiles, true, nil}, + } + // Hooks are opt-in at init time, so only manage/refresh them when the + // workspace actually has them — otherwise `csdd update` would silently re-add + // hooks a project deliberately omitted. + if pathExists(paths.Hooks(root)) { + trees = append(trees, struct { + base string + fn func(fs.FS) (map[string]string, error) + exec bool + preserveFrontmatter []string + }{paths.Hooks(root), templater.HookFiles, true, nil}) } for _, t := range trees { entries, err := t.fn(templates) diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl index b322dec..3cdd8f3 100644 --- a/internal/templater/templates/root/CLAUDE.md.tmpl +++ b/internal/templater/templates/root/CLAUDE.md.tmpl @@ -146,9 +146,9 @@ requirements ──approve──▶ design ──approve──▶ tasks ──ap 1. **Start each feature on a clean context:** `/clear`, then `npx @protonspy/csdd spec init `. 2. **Generate → review → approve, one phase at a time:** `spec generate --artifact requirements` → human reviews → `spec approve --phase requirements`; then `design`, then `tasks`. The gate refuses the next `generate` until the current phase is approved. 3. `ready_for_implementation` flips to `true` only after all three phases are approved — and only csdd flips it. Do not set it yourself. -4. **Branch before the first task:** `git switch -c /` in kebab-case (`feat/`, `fix/`, `chore/`, `refactor/`, `docs/`, `test/`, `perf/`). The PreToolUse hook blocks commits on the default branch. +4. **Branch before the first task:** `git switch -c /` in kebab-case (`feat/`, `fix/`, `chore/`, `refactor/`, `docs/`, `test/`, `perf/`). With the Claude Code hooks enabled (`csdd init --hooks`), a PreToolUse hook enforces this by blocking commits on the default branch. 5. Implementation runs one task per iteration with fresh-context sub-agents (`test-designer` enumerates the cases → `implementer` builds them → `code-reviewer`, plus `security-reviewer` when auth/secrets/input are touched), TDD red→green per task. Stay inside the approved design boundary. Independent `(P)` tasks in *different* `_Boundary:_` groups may run as parallel `implementer` sub-agents in isolated git worktrees; same-boundary tasks stay ordered and every `_Depends:_` is honored first. -6. Commit a slice only after review is clean, then push — the `pre-push` hook runs the test gate and blocks a red push. +6. Commit a slice only after review is clean, then push — with the pre-push gate installed (`csdd init --prepush`, then `git config core.hooksPath .githooks`), it runs the test gate and blocks a red push. 7. **Close each feature with a `/clear`** once its PR ships, so context does not accumulate across features. 8. Steering is updated only when a new pattern emerges that the agent could not derive from the code. @@ -254,7 +254,7 @@ between the markers. - `.claude/skills//` — executable workflow skills. - `.claude/agents/.md` — custom sub-agents with least-privilege tool scope. - `.claude/commands/.md` — slash commands (`/csdd-commit` generates the commit message from the diff + active spec). -- `.claude/hooks/` + `.claude/settings.json` — deterministic automation (format on edit, command safety, stop reminder). -- `.githooks/pre-push` — the test gate at push time. Enable once: `git config core.hooksPath .githooks`. +- `.claude/settings.json` — permission guards; and, when `.claude/hooks/` is opted in (`csdd init --hooks`), the deterministic automation wiring (format on edit, command safety, stop reminder). +- `.githooks/pre-push` — the test gate at push time (opt-in: `csdd init --prepush`). Enable once: `git config core.hooksPath .githooks`. - `.github/pull_request_template.md` — evidence-bearing PR body. - `.mcp.json` — Model Context Protocol servers. diff --git a/internal/templater/templates/root/pre-push.tmpl b/internal/templater/templates/root/pre-push.tmpl index 69d4bea..975aea5 100644 --- a/internal/templater/templates/root/pre-push.tmpl +++ b/internal/templater/templates/root/pre-push.tmpl @@ -19,6 +19,12 @@ elif [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; then python -m pytest elif [ -f Cargo.toml ]; then cargo test +elif [ -f pom.xml ]; then + # Maven — prefer the project's wrapper when it's checked in. + if [ -x ./mvnw ]; then ./mvnw test; else mvn test; fi +elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then + # Gradle — prefer the project's wrapper when it's checked in. + if [ -x ./gradlew ]; then ./gradlew test; else gradle test; fi else echo "pre-push: no known test command detected — adapt .githooks/pre-push for this project." >&2 exit 0 diff --git a/internal/templater/templates/root/settings-hooks.json.tmpl b/internal/templater/templates/root/settings-hooks.json.tmpl new file mode 100644 index 0000000..2be8d42 --- /dev/null +++ b/internal/templater/templates/root/settings-hooks.json.tmpl @@ -0,0 +1,27 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-destructive.sh" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-after-edit.sh" } + ] + } + ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/test-before-stop.sh" } + ] + } + ] + } +} diff --git a/internal/templater/templates/root/settings.json.tmpl b/internal/templater/templates/root/settings.json.tmpl index d8d1b3b..bf9e5c2 100644 --- a/internal/templater/templates/root/settings.json.tmpl +++ b/internal/templater/templates/root/settings.json.tmpl @@ -1,29 +1,4 @@ { - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-destructive.sh" } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Edit|Write|NotebookEdit", - "hooks": [ - { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format-after-edit.sh" } - ] - } - ], - "Stop": [ - { - "hooks": [ - { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/test-before-stop.sh" } - ] - } - ] - }, "permissions": { "deny": [ "Read(./.env)", diff --git a/internal/templater/templates/sandbox/devcontainer.json.tmpl b/internal/templater/templates/sandbox/devcontainer.json.tmpl index 3272095..7d0d4ce 100644 --- a/internal/templater/templates/sandbox/devcontainer.json.tmpl +++ b/internal/templater/templates/sandbox/devcontainer.json.tmpl @@ -17,8 +17,10 @@ "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/node:1": {}{{.ExtraFeatures}} }, - // Install Claude Code (Node feature provides the runtime). - "postCreateCommand": "npm install -g @anthropic-ai/claude-code", + // Install Claude Code + the csdd CLI (the Node feature provides the runtime). + // csdd ships as an npm package wrapping the Go binary, so `csdd …` is on PATH + // without `npx`. Runs at create, before the firewall, while egress is open. + "postCreateCommand": "npm install -g @anthropic-ai/claude-code @protonspy/csdd", // Bring up the default-deny egress firewall on every start; it self-verifies // and exits non-zero if isolation is not enforced. "postStartCommand": "sudo /usr/local/bin/init-firewall.sh"