feat(v0.3): add SkipCategoryDir for CTASK_PROJECT_ROOT semantics

When SkipCategoryDir is true, Create places the workspace directly
under Root and does not append a Category subdirectory. This is the
mechanism that prevents doubled paths like
~/projects/projects/<slug> when the user sets CTASK_PROJECT_ROOT
without an explicit -c flag. The Category value is still recorded
on TaskMeta so list/info/filter still work.
This commit is contained in:
2026-04-10 14:38:01 -04:00
parent e09eac62d1
commit 3adfe62410
2 changed files with 55 additions and 1 deletions
+10 -1
View File
@@ -29,6 +29,12 @@ type CreateOpts struct {
// ProjectSeedDir is the absolute path to the project-specific seed directory.
// Only consulted when IsProject is true.
ProjectSeedDir string
// SkipCategoryDir, when true, places the workspace directly under Root
// without inserting a Category subdirectory. Used for project mode with
// CTASK_PROJECT_ROOT set and no explicit -c flag. Category is still
// recorded in TaskMeta.
SkipCategoryDir bool
}
// CreateResult holds the result of workspace creation.
@@ -60,7 +66,10 @@ func Create(opts CreateOpts) (*CreateResult, error) {
date := now.Format("2006-01-02")
id := now.Format("20060102-150405")
categoryDir := filepath.Join(opts.Root, opts.Category)
categoryDir := opts.Root
if !opts.SkipCategoryDir {
categoryDir = filepath.Join(opts.Root, opts.Category)
}
if err := os.MkdirAll(categoryDir, 0755); err != nil {
return nil, fmt.Errorf("creating category dir: %w", err)
}
+45
View File
@@ -241,6 +241,51 @@ func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) {
}
}
func TestCreateSkipCategoryDirPlacesUnderRoot(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "billing service",
Category: "projects", // recorded in metadata
Mode: "local",
Agent: "claude",
IsProject: true,
SkipCategoryDir: true,
}
ws, err := Create(opts)
if err != nil {
t.Fatalf("Create: %v", err)
}
parent := filepath.Dir(ws.Path)
if parent != root {
t.Errorf("workspace parent: got %q, want %q", parent, root)
}
if ws.Meta.Category != "projects" {
t.Errorf("Category metadata: got %q, want \"projects\"", ws.Meta.Category)
}
}
func TestCreateExplicitCategoryAppendsSubdir(t *testing.T) {
root := t.TempDir()
opts := CreateOpts{
Root: root,
Title: "billing service",
Category: "backend",
Mode: "local",
Agent: "claude",
IsProject: true,
// SkipCategoryDir: false (default)
}
ws, err := Create(opts)
if err != nil {
t.Fatalf("Create: %v", err)
}
parent := filepath.Dir(ws.Path)
if parent != filepath.Join(root, "backend") {
t.Errorf("workspace parent: got %q, want %q", parent, filepath.Join(root, "backend"))
}
}
func TestCreateCollisionSuffix(t *testing.T) {
root := t.TempDir()