feat(v0.5): scaffold project subdirectory and set launch_dir in task.yaml

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 19:47:33 -04:00
parent dcb161022c
commit 7cfafdc285
2 changed files with 93 additions and 0 deletions
+13
View File
@@ -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.
+80
View File
@@ -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)
}
}