From 8cda541f2cb6f26f31d8b0e58ada8ab2deb01644 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 14:40:06 -0400 Subject: [PATCH] feat(v0.3): add --project flag, CTASK_TYPE env, project root semantics ctask new gains --project, which: - records type=project on the workspace - defaults the category to "projects" - applies general + project seed overlays - uses CTASK_PROJECT_ROOT when set, with no doubled "projects/" path unless the user explicitly passes -c - exports CTASK_TYPE=project into the child session EnvVars now takes a taskType arg and exports CTASK_TYPE. Empty type defaults to "task" for safety. resume/open also pass EffectiveType so the env var is correct on resume of a v0.2 workspace. Git init for project mode is wired in the next commit. --- cmd/new.go | 57 +++++++++++++++++++++++++++------- cmd/open.go | 2 +- cmd/resume.go | 2 +- internal/config/config.go | 7 ++++- internal/config/config_test.go | 17 +++++++++- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/cmd/new.go b/cmd/new.go index 9bcd471..baf9f31 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -12,8 +12,8 @@ import ( var newCmd = &cobra.Command{ Use: "new [title]", - Short: "Create a new task workspace and launch the agent", - Long: "Create a new task workspace and launch the agent. If title is omitted, generates task-HHMMSS.", + Short: "Create a new task or project workspace and launch the agent", + Long: "Create a new task or project workspace and launch the agent. If title is omitted, generates task-HHMMSS.", Args: cobra.MaximumNArgs(1), SilenceUsage: true, RunE: runNew, @@ -25,10 +25,12 @@ var ( newShell bool newAgent string newNoLaunch bool + newProject bool ) func init() { - newCmd.Flags().StringVarP(&newCategory, "category", "c", "general", "Workspace category subdirectory") + newCmd.Flags().StringVarP(&newCategory, "category", "c", "", "Workspace category subdirectory (default: \"general\" for tasks, \"projects\" for projects)") + newCmd.Flags().BoolVar(&newProject, "project", false, "Create a project workspace (longer-lived; uses CTASK_PROJECT_ROOT if set, runs git init)") newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (deferred)") newCmd.Flags().BoolVar(&newShell, "shell", false, "Open interactive shell instead of agent") newCmd.Flags().StringVarP(&newAgent, "agent", "a", "", "Command to exec as the agent") @@ -42,7 +44,6 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } - root := config.ResolveRoot() agent := newAgent if agent == "" { agent = config.ResolveAgent() @@ -53,13 +54,45 @@ func runNew(cmd *cobra.Command, args []string) error { title = args[0] } - ws, err := workspace.Create(workspace.CreateOpts{ - Root: root, - Title: title, - Category: newCategory, - Mode: "local", - Agent: agent, - }) + // Determine category default and explicit-ness. + categoryExplicit := cmd.Flags().Changed("category") + category := newCategory + if !categoryExplicit { + if newProject { + category = "projects" + } else { + category = "general" + } + } + + // Determine workspace root + skip-category behavior. + taskRoot := config.ResolveRoot() + root := taskRoot + skipCategoryDir := false + if newProject { + if override := config.ResolveProjectRoot(); override != "" { + root = override + if !categoryExplicit { + skipCategoryDir = true + } + } + } + + opts := workspace.CreateOpts{ + Root: root, + Title: title, + Category: category, + Mode: "local", + Agent: agent, + IsProject: newProject, + SeedDir: config.ResolveSeedDir(), + SkipCategoryDir: skipCategoryDir, + } + if newProject { + opts.ProjectSeedDir = config.ResolveProjectSeedDir() + } + + ws, err := workspace.Create(opts) if err != nil { return err } @@ -71,7 +104,7 @@ func runNew(cmd *cobra.Command, args []string) error { return nil } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) return session.Run(session.LaunchOpts{ WsDir: ws.Path, diff --git a/cmd/open.go b/cmd/open.go index 2cc4fba..48f8aae 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -38,7 +38,7 @@ func runOpen(cmd *cobra.Command, args []string) error { return fmt.Errorf("updating metadata: %w", err) } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) return session.Run(session.LaunchOpts{ WsDir: ws.Path, diff --git a/cmd/resume.go b/cmd/resume.go index 016ef2c..fcd63f9 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -60,7 +60,7 @@ func doResume(query string, container, useShell bool, agentOverride string) erro agent = ws.Meta.Agent } - envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) return session.Run(session.LaunchOpts{ WsDir: ws.Path, diff --git a/internal/config/config.go b/internal/config/config.go index e7d2bae..62a0d3f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,13 +60,18 @@ func ResolveProjectRoot() string { } // EnvVars returns the environment variables to export into child sessions. -func EnvVars(slug, mode, root, workspace, category string) map[string]string { +// taskType must be "task" or "project"; an empty value defaults to "task". +func EnvVars(slug, mode, root, workspace, category, taskType string) map[string]string { + if taskType == "" { + taskType = "task" + } return map[string]string{ "CTASK_TASK": slug, "CTASK_MODE": mode, "CTASK_ROOT": root, "CTASK_WORKSPACE": workspace, "CTASK_CATEGORY": category, + "CTASK_TYPE": taskType, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dc8e316..e15b6cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -145,13 +145,14 @@ func TestResolveProjectRootOverride(t *testing.T) { } func TestEnvVars(t *testing.T) { - vars := EnvVars("my-slug", "local", "/abs/root", "/abs/root/cat/ws", "general") + vars := EnvVars("my-slug", "local", "/abs/root", "/abs/root/cat/ws", "general", "task") expected := map[string]string{ "CTASK_TASK": "my-slug", "CTASK_MODE": "local", "CTASK_ROOT": "/abs/root", "CTASK_WORKSPACE": "/abs/root/cat/ws", "CTASK_CATEGORY": "general", + "CTASK_TYPE": "task", } for k, v := range expected { if vars[k] != v { @@ -159,3 +160,17 @@ func TestEnvVars(t *testing.T) { } } } + +func TestEnvVarsProjectType(t *testing.T) { + vars := EnvVars("p", "local", "/r", "/r/p", "projects", "project") + if vars["CTASK_TYPE"] != "project" { + t.Errorf("CTASK_TYPE: got %q, want \"project\"", vars["CTASK_TYPE"]) + } +} + +func TestEnvVarsEmptyTypeDefaultsToTask(t *testing.T) { + vars := EnvVars("p", "local", "/r", "/r/p", "general", "") + if vars["CTASK_TYPE"] != "task" { + t.Errorf("CTASK_TYPE empty fallback: got %q, want \"task\"", vars["CTASK_TYPE"]) + } +}