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:
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user