diff --git a/internal/workspace/create.go b/internal/workspace/create.go index af0400f..5501f59 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -17,6 +17,18 @@ type CreateOpts struct { Category string Mode string Agent string + + // IsProject switches the built-in defaults to project-oriented templates, + // records type=project in task.yaml, and triggers project seed overlay. + IsProject bool + + // SeedDir is the absolute path to the general user seed directory. + // If empty or non-existent, no general seed overlay is applied. + SeedDir string + + // ProjectSeedDir is the absolute path to the project-specific seed directory. + // Only consulted when IsProject is true. + ProjectSeedDir string } // CreateResult holds the result of workspace creation. @@ -25,7 +37,15 @@ type CreateResult struct { Meta *TaskMeta } -// Create creates a new task workspace with seed files and metadata. +// Create creates a new task or project workspace with seed files and metadata. +// +// Layered seeding order (later layers overwrite earlier ones): +// 1. built-in defaults (task or project, depending on IsProject) +// 2. general user seed (opts.SeedDir), if it exists +// 3. project user seed (opts.ProjectSeedDir), if IsProject and it exists +// +// task.yaml is always written by ctask AFTER the seed copy step, so seeds +// cannot inject a stale task.yaml even by accident. func Create(opts CreateOpts) (*CreateResult, error) { title := opts.Title if title == "" { @@ -55,7 +75,7 @@ func Create(opts CreateOpts) (*CreateResult, error) { return nil, fmt.Errorf("creating workspace dir: %w", err) } - // Create subdirectories + // Standard subdirectories for _, sub := range []string{"context", "output", "logs"} { if err := os.MkdirAll(filepath.Join(wsDir, sub), 0755); err != nil { return nil, fmt.Errorf("creating %s dir: %w", sub, err) @@ -69,6 +89,28 @@ func Create(opts CreateOpts) (*CreateResult, error) { actualTitle = title + suffix } + taskType := "task" + if opts.IsProject { + taskType = "project" + } + + // Layer 1: built-in defaults (workspace is brand new, so this always writes them). + writeBuiltinDefaults(wsDir, actualTitle, opts.IsProject) + + // Layer 2: general user seed (overwrites built-in defaults; skips task.yaml/.ctask). + if opts.SeedDir != "" { + if err := seed.CopySeedDir(opts.SeedDir, wsDir); err != nil { + return nil, fmt.Errorf("applying general seed: %w", err) + } + } + + // Layer 3: project user seed (project mode only). + if opts.IsProject && opts.ProjectSeedDir != "" { + if err := seed.CopySeedDir(opts.ProjectSeedDir, wsDir); err != nil { + return nil, fmt.Errorf("applying project seed: %w", err) + } + } + meta := &TaskMeta{ ID: id, Slug: actualSlug, @@ -77,16 +119,15 @@ func Create(opts CreateOpts) (*CreateResult, error) { UpdatedAt: now.Truncate(time.Second), Status: "active", Category: opts.Category, + Type: taskType, Mode: opts.Mode, Agent: opts.Agent, WorkspacePath: wsDir, ArchivedAt: nil, } - // Write seed files (only if they don't exist) - writeSeedFiles(wsDir, actualSlug, actualTitle, opts.Category) - - // Write task.yaml (always write on new creation) + // task.yaml is always written by ctask, AFTER the seed step, so seeds can + // never inject a stale or malformed task.yaml. metaPath := filepath.Join(wsDir, "task.yaml") if err := WriteMeta(metaPath, meta); err != nil { return nil, fmt.Errorf("writing task.yaml: %w", err) @@ -95,19 +136,22 @@ func Create(opts CreateOpts) (*CreateResult, error) { return &CreateResult{Path: wsDir, Meta: meta}, nil } -// writeSeedFiles writes CLAUDE.md and notes.md if they don't already exist. -func writeSeedFiles(wsDir, slug, title, category string) { +// writeBuiltinDefaults writes the built-in CLAUDE.md and notes.md for a new workspace. +// These files are unconditionally replaceable by the seed overlay layers. +func writeBuiltinDefaults(wsDir, title string, isProject bool) { claudePath := filepath.Join(wsDir, "CLAUDE.md") - if _, err := os.Stat(claudePath); os.IsNotExist(err) { - content := seed.ClaudeMD(slug, category, wsDir) - os.WriteFile(claudePath, []byte(content), 0644) + var claudeBody string + if isProject { + claudeBody = seed.ClaudeMDProject() + } else { + // The slug/category/path args are kept for API compatibility but are + // not interpolated by the v0.3 template. + claudeBody = seed.ClaudeMD("", "", wsDir) } + _ = os.WriteFile(claudePath, []byte(claudeBody), 0644) notesPath := filepath.Join(wsDir, "notes.md") - if _, err := os.Stat(notesPath); os.IsNotExist(err) { - content := seed.NotesMD(title) - os.WriteFile(notesPath, []byte(content), 0644) - } + _ = os.WriteFile(notesPath, []byte(seed.NotesMD(title)), 0644) } // RelativePath returns a display-friendly relative path like "category/date_slug". diff --git a/internal/workspace/create_test.go b/internal/workspace/create_test.go index 0e81f0a..f245546 100644 --- a/internal/workspace/create_test.go +++ b/internal/workspace/create_test.go @@ -3,6 +3,7 @@ package workspace import ( "os" "path/filepath" + "strings" "testing" ) @@ -87,29 +88,156 @@ func TestCreateWorkspaceNoTitle(t *testing.T) { } } -func TestCreateDoesNotOverwriteSeedFiles(t *testing.T) { +func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { root := t.TempDir() + opts := CreateOpts{ + Root: root, + Title: "billing", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, err := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + content := string(body) + if !strings.Contains(content, "# Project Workspace Guidelines") { + t.Errorf("expected project CLAUDE.md, got:\n%s", content) + } + if ws.Meta.Type != "project" { + t.Errorf("Type: got %q, want \"project\"", ws.Meta.Type) + } +} +func TestCreateTaskWritesTaskClaudeMD(t *testing.T) { + root := t.TempDir() opts := CreateOpts{ Root: root, - Title: "overwrite test", + Title: "fix bug", Category: "general", Mode: "local", Agent: "claude", } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if !strings.Contains(string(body), "# Workspace Guidelines") { + t.Errorf("expected task CLAUDE.md, got:\n%s", string(body)) + } + if ws.Meta.Type != "task" { + t.Errorf("Type: got %q, want \"task\"", ws.Meta.Type) + } +} - ws, _ := Create(opts) +func TestCreateAppliesGeneralSeed(t *testing.T) { + root := t.TempDir() + seedDir := t.TempDir() - // Write custom content to notes.md - notesPath := filepath.Join(ws.Path, "notes.md") - os.WriteFile(notesPath, []byte("custom content"), 0644) + if err := os.WriteFile(filepath.Join(seedDir, "CLAUDE.md"), []byte("user claude override"), 0644); err != nil { + t.Fatalf("write seed CLAUDE.md: %v", err) + } + if err := os.WriteFile(filepath.Join(seedDir, ".cursorrules"), []byte("cursor rules"), 0644); err != nil { + t.Fatalf("write .cursorrules: %v", err) + } - // Call writeSeedFiles again (simulating what would happen on resume) - writeSeedFiles(ws.Path, "overwrite-test", "overwrite test", "general") + opts := CreateOpts{ + Root: root, + Title: "seeded", + Category: "general", + Mode: "local", + Agent: "claude", + SeedDir: seedDir, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if string(body) != "user claude override" { + t.Errorf("seed should have overwritten CLAUDE.md, got %q", string(body)) + } + if _, err := os.Stat(filepath.Join(ws.Path, ".cursorrules")); err != nil { + t.Errorf(".cursorrules should exist: %v", err) + } +} - data, _ := os.ReadFile(notesPath) - if string(data) != "custom content" { - t.Error("seed files should not overwrite existing files") +func TestCreateProjectAppliesGeneralThenProjectSeed(t *testing.T) { + root := t.TempDir() + general := t.TempDir() + project := t.TempDir() + + if err := os.WriteFile(filepath.Join(general, "CLAUDE.md"), []byte("general"), 0644); err != nil { + t.Fatalf("write general: %v", err) + } + if err := os.WriteFile(filepath.Join(general, "general-only.txt"), []byte("g"), 0644); err != nil { + t.Fatalf("write general-only: %v", err) + } + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("project"), 0644); err != nil { + t.Fatalf("write project: %v", err) + } + if err := os.WriteFile(filepath.Join(project, "project-only.txt"), []byte("p"), 0644); err != nil { + t.Fatalf("write project-only: %v", err) + } + + opts := CreateOpts{ + Root: root, + Title: "billing", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + SeedDir: general, + ProjectSeedDir: project, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if string(body) != "project" { + t.Errorf("project seed should win, got %q", string(body)) + } + if _, err := os.Stat(filepath.Join(ws.Path, "general-only.txt")); err != nil { + t.Errorf("general-only.txt should exist: %v", err) + } + if _, err := os.Stat(filepath.Join(ws.Path, "project-only.txt")); err != nil { + t.Errorf("project-only.txt should exist: %v", err) + } +} + +func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) { + root := t.TempDir() + seedDir := t.TempDir() + if err := os.WriteFile(filepath.Join(seedDir, "task.yaml"), []byte("id: bogus"), 0644); err != nil { + t.Fatalf("write seed task.yaml: %v", err) + } + opts := CreateOpts{ + Root: root, + Title: "no clobber", + Category: "general", + Mode: "local", + Agent: "claude", + SeedDir: seedDir, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + meta, err := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Slug != "no-clobber" { + t.Errorf("task.yaml should be ctask-generated, got slug %q", meta.Slug) } }