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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:30:59 -04:00
parent 7b75cb5f3d
commit 17789e4b9f
3 changed files with 305 additions and 0 deletions
+47
View File
@@ -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)
}
+120
View File
@@ -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), "/")
}
+138
View File
@@ -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)
}
}