From 17789e4b9f0ca5c378a70a0d145711cd1639e9d7 Mon Sep 17 00:00:00 2001 From: warren Date: Sun, 5 Apr 2026 18:30:59 -0400 Subject: [PATCH] feat: workspace creation with seed files and collision handling Create function produces full workspace layout (task.yaml, CLAUDE.md, notes.md, context/, output/, logs/). Seed files only written if missing. Collision suffixing tested. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/seed/templates.go | 47 ++++++++++ internal/workspace/create.go | 120 ++++++++++++++++++++++++++ internal/workspace/create_test.go | 138 ++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 internal/seed/templates.go create mode 100644 internal/workspace/create.go create mode 100644 internal/workspace/create_test.go diff --git a/internal/seed/templates.go b/internal/seed/templates.go new file mode 100644 index 0000000..bbd54b7 --- /dev/null +++ b/internal/seed/templates.go @@ -0,0 +1,47 @@ +package seed + +import "fmt" + +// ClaudeMD returns the advisory CLAUDE.md content for a workspace. +func ClaudeMD(slug, category, workspacePath string) string { + return fmt.Sprintf(`# Task Workspace: %s + +This is a ctask-managed workspace. + +- **Category:** %s +- **Workspace:** %s + +## Scope + +This workspace is scoped to a single task. Keep work focused on the task described in notes.md. + +## Files + +- task.yaml — Task metadata (machine-managed, do not edit) +- notes.md — Task log (human/agent-managed) +- context/ — Reference documents (user-managed) +- output/ — Task deliverables and artifacts +- logs/ — Session logs (reserved for future use) +`, slug, category, workspacePath) +} + +// NotesMD returns the skeleton notes.md content. +func NotesMD(title string) string { + return fmt.Sprintf(`# %s + +## Purpose + + + +## Constraints + + + +## Actions + + + +## Results + +`, title) +} diff --git a/internal/workspace/create.go b/internal/workspace/create.go new file mode 100644 index 0000000..af0400f --- /dev/null +++ b/internal/workspace/create.go @@ -0,0 +1,120 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/warrenronsiek/ctask/internal/seed" +) + +// CreateOpts holds parameters for workspace creation. +type CreateOpts struct { + Root string + Title string + Category string + Mode string + Agent string +} + +// CreateResult holds the result of workspace creation. +type CreateResult struct { + Path string + Meta *TaskMeta +} + +// Create creates a new task workspace with seed files and metadata. +func Create(opts CreateOpts) (*CreateResult, error) { + title := opts.Title + if title == "" { + title = AutoTitle() + } + slug := Slugify(title) + if slug == "" { + slug = "task" + } + + now := time.Now().UTC() + date := now.Format("2006-01-02") + id := now.Format("20060102-150405") + + categoryDir := filepath.Join(opts.Root, opts.Category) + if err := os.MkdirAll(categoryDir, 0755); err != nil { + return nil, fmt.Errorf("creating category dir: %w", err) + } + + wsDir := ResolveDir(categoryDir, date, slug) + + // Extract actual slug from resolved dir name (may have -2, -3 suffix) + dirBase := filepath.Base(wsDir) + actualSlug := dirBase[len(date)+1:] // skip "YYYY-MM-DD_" + + if err := os.MkdirAll(wsDir, 0755); err != nil { + return nil, fmt.Errorf("creating workspace dir: %w", err) + } + + // Create 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) + } + } + + // Adjust title if slug was suffixed due to collision + actualTitle := title + if actualSlug != slug { + suffix := actualSlug[len(slug):] + actualTitle = title + suffix + } + + meta := &TaskMeta{ + ID: id, + Slug: actualSlug, + Title: actualTitle, + CreatedAt: now.Truncate(time.Second), + UpdatedAt: now.Truncate(time.Second), + Status: "active", + Category: opts.Category, + 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) + metaPath := filepath.Join(wsDir, "task.yaml") + if err := WriteMeta(metaPath, meta); err != nil { + return nil, fmt.Errorf("writing task.yaml: %w", err) + } + + 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) { + 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) + } + + notesPath := filepath.Join(wsDir, "notes.md") + if _, err := os.Stat(notesPath); os.IsNotExist(err) { + content := seed.NotesMD(title) + os.WriteFile(notesPath, []byte(content), 0644) + } +} + +// RelativePath returns a display-friendly relative path like "category/date_slug". +func RelativePath(root, wsPath string) string { + rel, err := filepath.Rel(root, wsPath) + if err != nil { + return wsPath + } + return strings.ReplaceAll(rel, string(filepath.Separator), "/") +} diff --git a/internal/workspace/create_test.go b/internal/workspace/create_test.go new file mode 100644 index 0000000..0e81f0a --- /dev/null +++ b/internal/workspace/create_test.go @@ -0,0 +1,138 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateWorkspace(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "test task", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Verify directory exists + if _, err := os.Stat(ws.Path); os.IsNotExist(err) { + t.Fatalf("workspace dir not created: %s", ws.Path) + } + + // Verify seed files + for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} { + p := filepath.Join(ws.Path, name) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("missing seed file: %s", name) + } + } + + // Verify subdirectories + for _, name := range []string{"context", "output", "logs"} { + p := filepath.Join(ws.Path, name) + info, err := os.Stat(p) + if os.IsNotExist(err) { + t.Errorf("missing dir: %s", name) + } else if !info.IsDir() { + t.Errorf("%s is not a directory", name) + } + } + + // Verify metadata + meta, err := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Slug != "test-task" { + t.Errorf("slug: got %q, want \"test-task\"", meta.Slug) + } + if meta.Status != "active" { + t.Errorf("status: got %q, want \"active\"", meta.Status) + } + if meta.Category != "general" { + t.Errorf("category: got %q, want \"general\"", meta.Category) + } +} + +func TestCreateWorkspaceNoTitle(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + meta, _ := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if len(meta.Slug) == 0 { + t.Error("expected auto-generated slug") + } + if meta.Slug[:5] != "task-" { + t.Errorf("auto slug should start with 'task-', got %q", meta.Slug) + } +} + +func TestCreateDoesNotOverwriteSeedFiles(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "overwrite test", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, _ := Create(opts) + + // Write custom content to notes.md + notesPath := filepath.Join(ws.Path, "notes.md") + os.WriteFile(notesPath, []byte("custom content"), 0644) + + // Call writeSeedFiles again (simulating what would happen on resume) + writeSeedFiles(ws.Path, "overwrite-test", "overwrite test", "general") + + data, _ := os.ReadFile(notesPath) + if string(data) != "custom content" { + t.Error("seed files should not overwrite existing files") + } +} + +func TestCreateCollisionSuffix(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "same name", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws1, _ := Create(opts) + ws2, _ := Create(opts) + + if ws1.Path == ws2.Path { + t.Error("two creates with same title should produce different paths") + } + + meta2, _ := ReadMeta(filepath.Join(ws2.Path, "task.yaml")) + if meta2.Slug != "same-name-2" { + t.Errorf("collision slug: got %q, want \"same-name-2\"", meta2.Slug) + } +}