feat(v0.3): support project mode and layered seed overlay in Create

CreateOpts gains IsProject, SeedDir, and ProjectSeedDir. The new
seeding flow is:

  1. write built-in defaults (task or project CLAUDE.md + notes.md)
  2. apply general seed if SeedDir != "" (overlay)
  3. apply project seed if IsProject && ProjectSeedDir != "" (overlay)
  4. write task.yaml (last, so seeds can never inject metadata)

The obsolete TestCreateDoesNotOverwriteSeedFiles test is removed:
its v0.2 invariant ("don't overwrite existing files") no longer
holds because v0.3 always lays defaults onto a fresh dir and seeds
are expected to overwrite.
This commit is contained in:
2026-04-10 14:37:09 -04:00
parent 72be64cc1a
commit e09eac62d1
2 changed files with 198 additions and 26 deletions
+59 -15
View File
@@ -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".
+139 -11
View File
@@ -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)
}
}