From 53c23ff8a172377627375a2e2b48ee6b042ba060 Mon Sep 17 00:00:00 2001 From: prode Date: Mon, 8 Jun 2026 18:49:01 -0300 Subject: [PATCH 1/2] feat(update): preserve model and effort fields during updates for managed agents and skills --- internal/cli/update.go | 176 +++++++++++++++++++++++++++++------- internal/cli/update_test.go | 99 ++++++++++++++++++++ 2 files changed, 244 insertions(+), 31 deletions(-) diff --git a/internal/cli/update.go b/internal/cli/update.go index dd5937b..73e62df 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -8,8 +8,10 @@ import ( "os" "path/filepath" "sort" + "strings" "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" @@ -22,23 +24,27 @@ import ( // .mcp.json, settings.json, CLAUDE.md, and any custom (non-shipped) skill or // agent — are deliberately NOT collected here, so update can never clobber them. type managedFile struct { - Rel string // workspace-relative, forward-slash: manifest key + display name - Abs string // absolute on-disk path - Content string // the content this csdd version ships for the file - Exec bool // chmod 0755 after writing (hook scripts) + Rel string // workspace-relative, forward-slash: manifest key + display name + Abs string // absolute on-disk path + Content string // the content this csdd version ships for the file + Exec bool // chmod 0755 after writing (hook scripts) + PreserveFrontmatter []string // local scalar overrides update carries forward } +var managedExecutionOverrideKeys = []string{"model", "effort"} + // collectManagedFiles enumerates every pure-csdd artifact `csdd init` scaffolds // from the embedded template tree: generation rules, versioned templates, the // shipped skills/agents/commands/hooks, the canonical guide, and csdd.md. func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) { var out []managedFile - add := func(abs, content string, exec bool) { + add := func(abs, content string, exec bool, preserveFrontmatter []string) { out = append(out, managedFile{ - Rel: filepath.ToSlash(workspace.Relative(root, abs)), - Abs: abs, - Content: content, - Exec: exec, + Rel: filepath.ToSlash(workspace.Relative(root, abs)), + Abs: abs, + Content: content, + Exec: exec, + PreserveFrontmatter: preserveFrontmatter, }) } @@ -47,7 +53,7 @@ func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) return nil, err } for name, c := range rules { - add(filepath.Join(paths.Rules(root), name), c, false) + add(filepath.Join(paths.Rules(root), name), c, false, nil) } versioned, err := templater.WorkflowTemplateFiles(templates) @@ -55,18 +61,19 @@ func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) return nil, err } for rel, c := range versioned { - add(filepath.Join(paths.Templates(root), filepath.FromSlash(rel)), c, false) + add(filepath.Join(paths.Templates(root), filepath.FromSlash(rel)), c, false, nil) } trees := []struct { - base string - fn func(fs.FS) (map[string]string, error) - exec bool + base string + fn func(fs.FS) (map[string]string, error) + exec bool + preserveFrontmatter []string }{ - {paths.Skills(root), templater.SkillFiles, false}, - {paths.Agents(root), templater.AgentFiles, false}, - {paths.Commands(root), templater.CommandFiles, false}, - {paths.Hooks(root), templater.HookFiles, true}, + {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}, } for _, t := range trees { entries, err := t.fn(templates) @@ -74,7 +81,7 @@ func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) return nil, err } for rel, c := range entries { - add(filepath.Join(t.base, filepath.FromSlash(rel)), c, t.exec) + add(filepath.Join(t.base, filepath.FromSlash(rel)), c, t.exec, t.preserveFrontmatter) } } @@ -82,13 +89,13 @@ func collectManagedFiles(root string, templates embed.FS) ([]managedFile, error) if err != nil { return nil, err } - add(filepath.Join(root, "docs", "guides", "claude-code-sdd.md"), guide, false) + add(filepath.Join(root, "docs", "guides", "claude-code-sdd.md"), guide, false, nil) csddmd, err := templater.Static(templates, "templates/root/csdd.md.tmpl") if err != nil { return nil, err } - add(filepath.Join(root, "csdd.md"), csddmd, false) + add(filepath.Join(root, "csdd.md"), csddmd, false, nil) // Deterministic order so dry-run previews and reports are stable. sort.Slice(out, func(i, j int) bool { return out[i].Rel < out[j].Rel }) @@ -115,7 +122,7 @@ func recordManifest(root string, templates embed.FS, now time.Time, skipped map[ continue } } - m.Files[f.Rel] = manifest.Hash(f.Content) + m.Files[f.Rel] = managedBaselineHash(f, f.Content) } return m.Save(paths.Manifest(root), version, now) } @@ -247,12 +254,12 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd res.firstRun = !existed for _, f := range files { - shipped := manifest.Hash(f.Content) + shipped := managedBaselineHash(f, f.Content) diskBytes, rerr := os.ReadFile(f.Abs) if os.IsNotExist(rerr) { if !opts.dryRun { - if err := writeManaged(f); err != nil { + if err := writeManaged(f, f.Content); err != nil { return res, err } } @@ -264,16 +271,18 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd } diskHash := manifest.Hash(string(diskBytes)) - if diskHash == shipped { + diskBaselineHash := managedBaselineHash(f, string(diskBytes)) + writeContent := managedWriteContent(f, string(diskBytes)) + if diskHash == manifest.Hash(writeContent) || diskBaselineHash == shipped { res.changes = append(res.changes, fileChange{rel: f.Rel, kind: kindCurrent}) continue } - if known, ok := base.Files[f.Rel]; ok && diskHash == known { + if known, ok := base.Files[f.Rel]; ok && diskBaselineHash == known { // Disk matches the last baseline csdd wrote: the user never edited it, // so refreshing in place loses nothing. if !opts.dryRun { - if err := writeManaged(f); err != nil { + if err := writeManaged(f, writeContent); err != nil { return res, err } } @@ -301,7 +310,7 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd } } if !opts.dryRun { - if err := writeManaged(f); err != nil { + if err := writeManaged(f, writeContent); err != nil { return res, err } } @@ -322,13 +331,40 @@ func updateWorkspace(opts updateOptions, templates embed.FS, now time.Time) (upd return res, nil } -// writeManaged writes a managed file's shipped content, creating parent dirs and +// managedBaselineHash returns the hash used for manifest and pristine checks. +// For managed agents and skills, model/effort are local execution overrides: +// update must preserve them, but they should not make an otherwise-pristine +// shipped artifact look user-edited. +func managedBaselineHash(f managedFile, content string) string { + return manifest.Hash(stripFrontmatterFields(content, f.PreserveFrontmatter)) +} + +// managedWriteContent overlays preserved frontmatter scalar values from the +// existing file onto this version's shipped content. +func managedWriteContent(f managedFile, existing string) string { + if len(f.PreserveFrontmatter) == 0 { + return f.Content + } + fm := frontmatter.Parse(existing) + values := map[string]string{} + for _, key := range f.PreserveFrontmatter { + if value := fm.AsString(key, ""); value != "" { + values[key] = value + } + } + if len(values) == 0 { + return f.Content + } + return upsertFrontmatterFields(f.Content, f.PreserveFrontmatter, values) +} + +// writeManaged writes a managed file's effective content, creating parent dirs and // restoring the executable bit for hook scripts. -func writeManaged(f managedFile) error { +func writeManaged(f managedFile, content string) error { if err := os.MkdirAll(filepath.Dir(f.Abs), 0o755); err != nil { return err } - if err := os.WriteFile(f.Abs, []byte(f.Content), 0o644); err != nil { + if err := os.WriteFile(f.Abs, []byte(content), 0o644); err != nil { return err } if f.Exec { @@ -337,6 +373,84 @@ func writeManaged(f managedFile) error { return nil } +func stripFrontmatterFields(content string, keys []string) string { + if len(keys) == 0 { + return content + } + lines := strings.Split(content, "\n") + end := frontmatterEnd(lines) + if end < 0 { + return content + } + keySet := stringSet(keys) + out := make([]string, 0, len(lines)) + out = append(out, lines[0]) + for _, line := range lines[1:end] { + if key, ok := frontmatterLineKey(line); ok && keySet[key] { + continue + } + out = append(out, line) + } + out = append(out, lines[end:]...) + return strings.Join(out, "\n") +} + +func upsertFrontmatterFields(content string, order []string, values map[string]string) string { + lines := strings.Split(content, "\n") + end := frontmatterEnd(lines) + if end < 0 { + return content + } + keySet := stringSet(order) + out := make([]string, 0, len(lines)+len(values)) + out = append(out, lines[0]) + for _, line := range lines[1:end] { + if key, ok := frontmatterLineKey(line); ok && keySet[key] { + continue + } + out = append(out, line) + } + for _, key := range order { + if value, ok := values[key]; ok { + out = append(out, key+": "+value) + } + } + out = append(out, lines[end:]...) + return strings.Join(out, "\n") +} + +func frontmatterEnd(lines []string) int { + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return -1 + } + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + return i + } + } + return -1 +} + +func frontmatterLineKey(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "", false + } + idx := strings.Index(line, ":") + if idx < 0 { + return "", false + } + return strings.TrimSpace(line[:idx]), true +} + +func stringSet(values []string) map[string]bool { + out := make(map[string]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} + // nextOldPath returns the first free -N.old (N counting up from 1), so a // file conflicting across several updates accrues -1.old, -2.old, … rather than // overwriting an earlier backup. It only stats paths; it never writes. diff --git a/internal/cli/update_test.go b/internal/cli/update_test.go index a482b6e..88dbc3f 100644 --- a/internal/cli/update_test.go +++ b/internal/cli/update_test.go @@ -113,6 +113,105 @@ func TestUpdatePristineOutdatedUpdatesInPlace(t *testing.T) { } } +func TestUpdatePreservesManagedAgentModelEffort(t *testing.T) { + dir := freshWorkspace(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---", + 1, + ) + if err := os.WriteFile(agent, []byte(withOverrides), 0o644); err != nil { + t.Fatal(err) + } + + code, out, _ := run(t, "update", "--root", dir) + if code != 0 { + t.Fatalf("update failed: code=%d\n%s", code, out) + } + got := readFile(t, agent) + for _, want := range []string{"model: opus", "effort: high"} { + if !strings.Contains(got, want) { + t.Errorf("update lost managed agent override %q:\n%s", want, got) + } + } + if olds := oldBackups(t, dir); len(olds) != 0 { + t.Errorf("model/effort-only changes should not create .old backups: %v", olds) + } + if !strings.Contains(out, "0 conflict(s)") { + t.Errorf("model/effort-only changes should not be conflicts:\n%s", out) + } +} + +func TestUpdatePreservesManagedSkillModelEffort(t *testing.T) { + dir := freshWorkspace(t) + skill := filepath.Join(dir, ".claude", "skills", "verify-change", "SKILL.md") + withOverrides := strings.Replace( + readFile(t, skill), + "description: Run the project's executable checks (tests, lint, typecheck, build) and produce evidence. Use before reporting a task complete or before opening a PR.\n---", + "description: Run the project's executable checks (tests, lint, typecheck, build) and produce evidence. Use before reporting a task complete or before opening a PR.\nmodel: sonnet\neffort: high\n---", + 1, + ) + if err := os.WriteFile(skill, []byte(withOverrides), 0o644); err != nil { + t.Fatal(err) + } + + code, out, _ := run(t, "update", "--root", dir) + if code != 0 { + t.Fatalf("update failed: code=%d\n%s", code, out) + } + got := readFile(t, skill) + for _, want := range []string{"model: sonnet", "effort: high"} { + if !strings.Contains(got, want) { + t.Errorf("update lost managed skill override %q:\n%s", want, got) + } + } + if olds := oldBackups(t, dir); len(olds) != 0 { + t.Errorf("model/effort-only changes should not create .old backups: %v", olds) + } +} + +func TestUpdateCarriesManagedAgentModelEffortAcrossTemplateRefresh(t *testing.T) { + dir := freshWorkspace(t) + agent := filepath.Join(dir, ".claude", "agents", "implementer.md") + relKey := ".claude/agents/implementer.md" + + oldShipped := "---\nname: implementer\ndescription: old\ntools: Read\n---\nOLD BODY\n" + oldWithOverrides := "---\nname: implementer\ndescription: old\ntools: Read\nmodel: sonnet\neffort: max\n---\nOLD BODY\n" + if err := os.WriteFile(agent, []byte(oldWithOverrides), 0o644); err != nil { + t.Fatal(err) + } + m, _, err := manifest.Load(paths.Manifest(dir)) + if err != nil { + t.Fatal(err) + } + m.Files[relKey] = manifest.Hash(oldShipped) + if err := m.Save(paths.Manifest(dir), "test", time.Now()); err != nil { + t.Fatal(err) + } + + code, out, _ := run(t, "update", "--root", dir) + if code != 0 { + t.Fatalf("update failed: code=%d\n%s", code, out) + } + got := readFile(t, agent) + for _, want := range []string{"model: sonnet", "effort: max", "You implement **one task at a time**"} { + if !strings.Contains(got, want) { + t.Errorf("refreshed agent missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "OLD BODY") { + t.Errorf("pristine outdated agent should be refreshed, got:\n%s", got) + } + if _, err := os.Stat(agent + "-1.old"); !os.IsNotExist(err) { + t.Errorf("metadata-only overrides should update in place without .old backup (err=%v)", err) + } + if !strings.Contains(out, "1 updated") { + t.Errorf("template refresh should be reported as update:\n%s", out) + } +} + func TestUpdateForceSkipsBackup(t *testing.T) { dir := freshWorkspace(t) rule := filepath.Join(dir, ".claude", "rules", "ears-format.md") From c537b8998d5a59a8a806ea18ebbea345750f1fec Mon Sep 17 00:00:00 2001 From: prode Date: Tue, 9 Jun 2026 12:00:01 -0300 Subject: [PATCH 2/2] docs(baseline): make CLAUDE.md enforce the SDD flow instead of suggesting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped CLAUDE.md treated "read csdd.md" as a soft suggestion and omitted the load-bearing guardrails, so an agent that skipped csdd.md lost every hard rule — hand-editing spec.json, skipping phase gates, authoring managed files directly. Move the guardrails into the always-loaded CLAUDE.md itself: - STOP gate that makes reading csdd.md a precondition for any artifact - Hard rules: csdd is the only author; never hand-edit spec.json / frontmatter / annotations / .mcp.json; never skip a phase gate; surface blocks instead of routing around them - Explicit ordered development cycle with generate -> human approve -> next phase, and that only csdd flips ready_for_implementation - Reinforced Non-negotiables with the spec.json / approve rules Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templater/templates/root/CLAUDE.md.tmpl | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/internal/templater/templates/root/CLAUDE.md.tmpl b/internal/templater/templates/root/CLAUDE.md.tmpl index 3be6a32..16b2613 100644 --- a/internal/templater/templates/root/CLAUDE.md.tmpl +++ b/internal/templater/templates/root/CLAUDE.md.tmpl @@ -5,9 +5,39 @@ 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). -**Read [`csdd.md`](./csdd.md) before producing any artifact.** It is the -operational guide that tells you how to drive `csdd` without reading source. -Everything below is a quick map; `csdd.md` is the contract. +## STOP — read this before touching any artifact + +> **`csdd.md` is the contract; this file is the summary.** Before you create, +> generate, approve, or edit *any* steering file, spec, skill, or agent — +> **read [`csdd.md`](./csdd.md) first.** Do not infer the workflow from the file +> contents or from these bullets; the gates, exit codes, and approval mechanics +> live in `csdd.md`. If you have not read it this session, read it now. + +### Hard rules — enforced, not advisory + +These are not style preferences. Breaking one 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 `csdd spec approve --phase `, which a + human authorizes. +3. **Never hand-edit frontmatter, task annotations, or `.mcp.json`.** If a + generated file is wrong, regenerate it from the template and edit the *body*, + then re-validate — do not patch the machine-managed parts by hand. +4. **Never skip a phase gate.** `requirements → design → tasks → implementation` + is strictly ordered; you cannot generate a phase until the prior one is + human-approved. `--force` is allowed **only** when the user explicitly + authorizes a fast-track / Quick Plan. +5. **When a rule blocks you, stop and surface it** — report the blocked item to + the human. Do not route around the gate. + +`csdd.md` is the authority on all five. When in doubt, re-read it rather than +guessing. ## Driving csdd — prefer the MCP tools for the dev flow @@ -53,19 +83,31 @@ between the markers. - `.github/pull_request_template.md` — evidence-bearing PR body. - `.mcp.json` — Model Context Protocol servers. -## Workflow at a glance +## The development cycle (follow it in order) -1. **Start each feature on a clean context:** run `/clear`, then `npx @protonspy/csdd spec init ` and iterate requirements → design → tasks. -2. Each phase requires explicit human approval recorded in `spec.json`. -3. `ready_for_implementation` flips to `true` only after all three phases are approved. -4. **Branch before the first task:** create the spec's branch — `git switch -c /` in kebab-case (`feat/` feature, `fix/` bugfix, `chore/` small adjustment; also `refactor/` `docs/` `test/` `perf/`). The PreToolUse hook blocks commits on the default branch to enforce this. -5. Implementation runs one task per iteration with fresh-context sub-agents (implementer → reviewer → debugger), TDD red→green per task. +Each phase is **generated by csdd**, then **human-approved via `csdd spec approve`** — +never by editing `spec.json`. You cannot generate a phase until the prior one is +approved. + +``` +requirements ──approve──▶ design ──approve──▶ tasks ──approve──▶ implementation + (human) (human) (human) ready_for_implementation: true +``` + +1. **Start each feature on a clean context:** run `/clear`, then `npx @protonspy/csdd spec init `. +2. **Generate → review → approve, one phase at a time:** + `csdd spec generate --artifact requirements` → human reviews → `csdd 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/` feature, `fix/` bugfix, `chore/` small adjustment; also `refactor/` `docs/` `test/` `perf/`). The PreToolUse hook blocks commits on the default branch to enforce this. +5. Implementation runs one task per iteration with fresh-context sub-agents (implementer → reviewer → debugger), TDD red→green per task. Stay inside the approved design boundary. 6. The `pr-review` skill commits a slice only after review is clean, then pushes — the `pre-push` hook runs the test gate and blocks a red push. 7. **Close each feature with a `/clear`:** once its PR ships, reset the context before the next spec so it does not accumulate across features. The Stop hook reminds you when a spec's tasks are all checked. 8. Steering is updated only when a new pattern emerges that the agent could not derive from the code. ## Non-negotiables +- **`csdd` authors managed files; you never write them by hand** — not `spec.json`, not frontmatter, not task annotations, not `.mcp.json`. +- **Phase gates are crossed with `csdd spec approve`, by a human** — never by editing `spec.json` or skipping ahead. - EARS phrasing for every requirement. - `_Requirements:_`, `_Boundary:_`, and `_Depends:_` annotations on tasks. - Test-first: every behavior task pairs a failing test (RED) with the minimal code to pass (GREEN) before moving on.