From 7cfafdc285a0096ab43f2487c38e72072a56a5d6 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 19:47:33 -0400 Subject: [PATCH] feat(v0.5): scaffold project subdirectory and set launch_dir in task.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctask new --project now creates an empty subdirectory named after the final suffixed slug inside the workspace root, and sets meta.LaunchDir to that slug. Task workspaces are unchanged. Seeds do not target the subdirectory — the user populates it with their project code and their own CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/workspace/create.go | 13 +++++ internal/workspace/create_test.go | 80 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/internal/workspace/create.go b/internal/workspace/create.go index 9dae4ad..2c0fd5f 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -134,6 +134,19 @@ func Create(opts CreateOpts) (*CreateResult, error) { WorkspacePath: wsDir, ArchivedAt: nil, } + if opts.IsProject { + meta.LaunchDir = actualSlug + } + + // v0.5: project workspaces get an empty subdirectory named after the + // final suffixed slug. ctask does not seed any files inside it; the + // user populates it with their project code + their own CLAUDE.md. + if opts.IsProject { + projDir := filepath.Join(wsDir, actualSlug) + if err := os.MkdirAll(projDir, 0755); err != nil { + return nil, fmt.Errorf("creating project subdir: %w", err) + } + } // task.yaml is always written by ctask, AFTER the seed step, so seeds can // never inject a stale or malformed task.yaml. diff --git a/internal/workspace/create_test.go b/internal/workspace/create_test.go index ea00874..52ec018 100644 --- a/internal/workspace/create_test.go +++ b/internal/workspace/create_test.go @@ -309,3 +309,83 @@ func TestCreateCollisionSuffix(t *testing.T) { t.Errorf("collision slug: got %q, want \"same-name-2\"", meta2.Slug) } } + +func TestCreateProjectScaffoldsSubdirAndSetsLaunchDir(t *testing.T) { + root := t.TempDir() + res, err := Create(CreateOpts{ + Root: root, + Title: "litlink-v2", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + subdir := filepath.Join(res.Path, res.Meta.Slug) + info, err := os.Stat(subdir) + if err != nil { + t.Fatalf("project subdir missing: %v", err) + } + if !info.IsDir() { + t.Errorf("project subdir exists but is not a directory") + } + entries, _ := os.ReadDir(subdir) + if len(entries) != 0 { + t.Errorf("project subdir must be empty, got %d entries", len(entries)) + } + if res.Meta.LaunchDir != res.Meta.Slug { + t.Errorf("LaunchDir: got %q, want slug %q", res.Meta.LaunchDir, res.Meta.Slug) + } +} + +func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) { + root := t.TempDir() + first, err := Create(CreateOpts{ + Root: root, Title: "dup", Category: "projects", + Mode: "local", Agent: "claude", IsProject: true, + }) + if err != nil { + t.Fatalf("first Create: %v", err) + } + if first.Meta.Slug != "dup" { + t.Fatalf("first slug: got %q, want %q", first.Meta.Slug, "dup") + } + + second, err := Create(CreateOpts{ + Root: root, Title: "dup", Category: "projects", + Mode: "local", Agent: "claude", IsProject: true, + }) + if err != nil { + t.Fatalf("second Create: %v", err) + } + if second.Meta.Slug == "dup" { + t.Fatalf("expected suffixed slug, got %q", second.Meta.Slug) + } + subdir := filepath.Join(second.Path, second.Meta.Slug) + if _, err := os.Stat(subdir); err != nil { + t.Errorf("subdir %q missing: %v", second.Meta.Slug, err) + } + if second.Meta.LaunchDir != second.Meta.Slug { + t.Errorf("LaunchDir: got %q, want %q", second.Meta.LaunchDir, second.Meta.Slug) + } +} + +func TestCreateTaskDoesNotScaffoldSubdir(t *testing.T) { + root := t.TempDir() + res, err := Create(CreateOpts{ + Root: root, Title: "hello", Category: "general", + Mode: "local", Agent: "claude", IsProject: false, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + subdir := filepath.Join(res.Path, res.Meta.Slug) + if _, err := os.Stat(subdir); err == nil { + t.Errorf("task workspace should not have a slug-named subdir; found %s", subdir) + } + if res.Meta.LaunchDir != "" { + t.Errorf("LaunchDir: got %q, want empty", res.Meta.LaunchDir) + } +}