From 4b6c8fad4bc5a1ab304b4a5bffb58979a5bfec7b Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 15:01:52 -0400 Subject: [PATCH] docs(v0.3): add v0.3 spec and implementation plan Adds the v0.3 feature spec (source of truth for this release) and the inline-execution implementation plan that was followed to land the v0.3 work in the preceding commits. Scope of v0.3: - User seed directories (general + project) - Improved built-in CLAUDE.md defaults (task + project) - --project mode (type metadata, project root, git init) - --projects filter for ctask list - CTASK_TYPE env export - Status-line and doctor updates The 2026-04-06-install-workflow plan (also untracked) is left alone -- it predates this session and is not part of v0.3. --- .../plans/2026-04-10-v0.3-implementation.md | 2909 +++++++++++++++++ v0.3-spec.md | 322 ++ 2 files changed, 3231 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-v0.3-implementation.md create mode 100644 v0.3-spec.md diff --git a/docs/superpowers/plans/2026-04-10-v0.3-implementation.md b/docs/superpowers/plans/2026-04-10-v0.3-implementation.md new file mode 100644 index 0000000..f59d851 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-v0.3-implementation.md @@ -0,0 +1,2909 @@ +# ctask v0.3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement ctask v0.3 (seed templates, project mode, type metadata, status-line/doctor updates) on top of v0.2 without regressing any v0.2 behavior. + +**Architecture:** v0.3 is a thin overlay on v0.2. New functionality is added as small focused helpers — a `seed` package for built-in templates and seed-directory copy logic, a `Type` field on `TaskMeta` with backward-compatible reads, and a `--project` flag that flows through `Create` to choose project defaults, project root, project seed, and `git init`. Status-line helpers and `doctor` get small additive updates. No template engine, no inheritance, no migration. + +**Tech Stack:** Go 1.26+, cobra, gopkg.in/yaml.v3, stdlib `os/exec` for git, PowerShell + bash for status line. + +--- + +## Pre-flight Notes + +**Today's date (for slugs/asserts):** 2026-04-10 + +**Verified toolchain (2026-04-10):** `go version go1.26.1 windows/amd64`. The repo `go.mod` declares `go 1.26.1`. Use this version in any test/build language. Do not over-promise compatibility ("Go 1.26+" is a documentation claim, not a tested guarantee). + +**Module path (local only):** `go.mod` declares `module github.com/warrenronsiek/ctask`. This is the **local Go module identifier**, used only for in-tree imports. It is **not** a verified remote — there is no git remote configured (`git remote -v` is empty), no published package, and no install URL. Never reference this string in install, clone, publish, or `go install` instructions. Internal Go imports continue to use it because that is how the module is named on disk. + +**Repository identity rule:** This project is local-only. Never invent module/install/clone commands. Do not push, do not publish, do not change `go.mod` paths. + +**Cross-platform constraints:** +- All file paths use `filepath.Join` +- Status-line helper exists in two flavors: bash (`.sh`) and PowerShell (`.ps1`). Both must be updated symmetrically. +- Seed dir is `%APPDATA%\ctask\seed` on Windows, `~/.config/ctask/seed` on Unix. + +**Status-line restraint:** The default task case must remain visually identical to v0.2 (`(ctask:slug|mode) workspace_path`). The project marker is **only** added when `CTASK_TYPE=project`. Do not change spacing, brackets, or order for the task case. This keeps every existing user's status line stable. + +**Seed-wins rule (.gitignore in particular):** The layered seed flow must be: built-in defaults -> general seed -> project seed -> task.yaml (always written by ctask). For project mode, `git init` and `EnsureGitignore` happen **after** the seed copy. `EnsureGitignore` is required to be a no-op when *any* seed (general or project) has already placed a `.gitignore`. Tests must cover both the "no seed gitignore" and "seed provided gitignore" cases. + +**Existing v0.2 surface that MUST NOT regress:** +- `ctask new`, `list`, `resume`, `open`, `info`, `archive`, `last`, `doctor`, `delete` +- Active workspace delete protection (`CTASK_WORKSPACE` + `manifest-start.json`) +- Session logging (manifest start/end + `logs/sessions.log`) +- `--container` deferred notice +- Install/uninstall scripts and PATH ownership marker +- Status-line helper still prints `(ctask:slug|mode) workspace_path` for v0.2 callers (we will add a marker, not change the existing fields) + +--- + +## File Structure + +**New files:** +- `internal/seed/copy.go` — seed-dir resolution + recursive copy with `task.yaml`/`.ctask` skip +- `internal/seed/copy_test.go` — tests for resolution and copy +- `internal/workspace/git.go` — `EnsureGitignore` (pure file logic) + `RunGitInit` (exec wrapper) +- `internal/workspace/git_test.go` — tests for `.gitignore` behavior + +**Modified files:** +- `cmd/root.go` — version bump to `0.3.0` +- `cmd/new.go` — `--project` flag, project-root + project-seed flow, git init invocation +- `cmd/list.go` — `--projects` flag +- `cmd/doctor.go` — informational seed dir reporting +- `internal/seed/templates.go` — replace `ClaudeMD`, add `ClaudeMDProject`, keep `NotesMD` +- `internal/seed/templates_test.go` — assert new content +- `internal/workspace/metadata.go` — add `Type string` field + `EffectiveType` helper +- `internal/workspace/metadata_test.go` — round-trip + missing-type tests +- `internal/workspace/create.go` — `IsProject` opt, project-aware seeding flow, project-root path resolution +- `internal/workspace/create_test.go` — project + seed tests +- `internal/workspace/list.go` — `Projects bool` opt + type filter +- `internal/workspace/list_test.go` — project listing tests +- `internal/workspace/query.go` — return type-aware `QueryResult` (no semantic change; queries already cross types) +- `internal/config/config.go` — `ResolveSeedDir`, `ResolveProjectSeedDir`, `ResolveProjectRoot`, extend `EnvVars` with `CTASK_TYPE` +- `internal/config/config_test.go` — tests for new resolvers + `CTASK_TYPE` +- `scripts/ctask-statusline.sh` — append project marker when `CTASK_TYPE=project` +- `scripts/ctask-statusline.ps1` — append project marker when `CTASK_TYPE=project` +- `docs/commands.md` — document `--project`, `--projects`, new env vars, new defaults + +**Files NOT touched (be careful):** +- `internal/session/*` — session logging untouched +- `internal/shell/launch.go` — banner/exec untouched (env vars added via `EnvVars` map only) +- `cmd/delete.go` — active-workspace delete protection untouched +- `cmd/archive.go`, `cmd/last.go`, `cmd/open.go`, `cmd/resume.go`, `cmd/info.go` — only touched if a test reveals a backward-compat gap reading `Type` +- `scripts/install.ps1`, `scripts/uninstall.ps1` — install behavior is unchanged + +--- + +## Build Order + +The plan is grouped into phases that follow the spec build order. Each task is small and ends with a commit. Run `go test ./... -count=1` (via `just test`) at the end of each task before committing. + +--- + +## Phase 1: Built-in default CLAUDE.md content + +### Task 1: Replace built-in task `CLAUDE.md` with the v0.3 default + +**Files:** +- Modify: `internal/seed/templates.go` +- Modify: `internal/seed/templates_test.go` + +- [ ] **Step 1: Read current `internal/seed/templates.go` to confirm signature.** + +The current `ClaudeMD(slug, category, workspacePath string)` is called by `internal/workspace/create.go:writeSeedFiles`. We will preserve the signature for now (parameters become unused). This avoids touching `create.go` in this task — that file is updated in Phase 3. + +- [ ] **Step 2: Write the failing test for the new task `CLAUDE.md` content.** + +Replace the existing `TestClaudeMDIsASCII` test body and add a content assertion. Update `internal/seed/templates_test.go`: + +```go +package seed + +import ( + "strings" + "testing" +) + +func TestClaudeMDIsASCII(t *testing.T) { + content := ClaudeMD("test-slug", "general", "/tmp/test") + for i, b := range []byte(content) { + if b > 127 { + t.Errorf("non-ASCII byte 0x%02x at position %d in CLAUDE.md template", b, i) + break + } + } +} + +func TestClaudeMDContainsV03Sections(t *testing.T) { + content := ClaudeMD("ignored", "ignored", "ignored") + for _, want := range []string{ + "# Workspace Guidelines", + "## File Placement", + "Source code and scripts", + "Documentation, summaries, reports", + "Deliverables and exports", + "Reference material and imported files", + "## Conventions", + "## Session Handoff", + "What was accomplished", + } { + if !strings.Contains(content, want) { + t.Errorf("CLAUDE.md missing required section: %q", want) + } + } +} + +func TestNotesMDIsASCII(t *testing.T) { + content := NotesMD("test title") + for i, b := range []byte(content) { + if b > 127 { + t.Errorf("non-ASCII byte 0x%02x at position %d in notes.md template", b, i) + break + } + } +} +``` + +- [ ] **Step 3: Run the test to confirm it fails.** + +Run: `go test ./internal/seed/ -run TestClaudeMDContainsV03Sections -v` +Expected: FAIL (content does not match v0.3 sections yet) + +- [ ] **Step 4: Replace `ClaudeMD` body with the v0.3 default content.** + +Edit `internal/seed/templates.go`: + +```go +package seed + +import "fmt" + +// ClaudeMD returns the built-in default CLAUDE.md content for a task workspace. +// Parameters are accepted for API compatibility but are not interpolated in v0.3. +func ClaudeMD(slug, category, workspacePath string) string { + _ = slug + _ = category + _ = workspacePath + return `# Workspace Guidelines + +This is a ctask workspace. Prefer operating inside this directory unless explicitly instructed otherwise. + +## File Placement + +- Source code and scripts -> workspace root or ` + "`src/`" + ` +- Documentation, summaries, reports -> ` + "`docs/`" + ` +- Deliverables and exports -> ` + "`output/`" + ` +- Reference material and imported files -> ` + "`context/`" + ` +- Do not place non-code outputs (docs, summaries, exports) in the workspace root + +## Conventions + +- Do not install global packages or modify system files unless asked +- Record important assumptions and actions in notes.md +- Keep the workspace root clean -- use subdirectories for organization + +## Session Handoff + +Before ending a session, append a brief summary to notes.md with: + +- What was accomplished +- Key decisions made +- Open follow-ups or unfinished work +- How to continue from here + +Keep it concise -- a few bullet points is enough. +` +} + +// NotesMD returns the skeleton notes.md content. +func NotesMD(title string) string { + return fmt.Sprintf(`# %s + +## Purpose + + + +## Constraints + + + +## Actions + + + +## Results + +`, title) +} +``` + +Note: the spec uses an em-dash style arrow but we deliberately use ASCII `->` and `--` to keep the file ASCII-only (the existing test enforces this). + +- [ ] **Step 5: Run all seed tests.** + +Run: `go test ./internal/seed/ -v -count=1` +Expected: PASS for `TestClaudeMDIsASCII`, `TestClaudeMDContainsV03Sections`, `TestNotesMDIsASCII`. + +- [ ] **Step 6: Run the full test suite to catch downstream regressions.** + +Run: `go test ./... -count=1` +Expected: PASS. (No other tests assert old CLAUDE.md content.) + +- [ ] **Step 7: Commit.** + +```bash +git add internal/seed/templates.go internal/seed/templates_test.go +git commit -m "feat(v0.3): replace built-in task CLAUDE.md with v0.3 default content" +``` + +--- + +### Task 2: Add built-in project `CLAUDE.md` template + +**Files:** +- Modify: `internal/seed/templates.go` +- Modify: `internal/seed/templates_test.go` + +- [ ] **Step 1: Write the failing test for `ClaudeMDProject`.** + +Append to `internal/seed/templates_test.go`: + +```go +func TestClaudeMDProjectIsASCII(t *testing.T) { + content := ClaudeMDProject() + for i, b := range []byte(content) { + if b > 127 { + t.Errorf("non-ASCII byte 0x%02x at position %d in project CLAUDE.md template", b, i) + break + } + } +} + +func TestClaudeMDProjectContainsRequiredSections(t *testing.T) { + content := ClaudeMDProject() + for _, want := range []string{ + "# Project Workspace Guidelines", + "long-lived working environment", + "## File Placement", + "Source code -> ", + "Tests -> ", + "## Conventions", + "This project uses git", + "## Session Handoff", + } { + if !strings.Contains(content, want) { + t.Errorf("project CLAUDE.md missing required section: %q", want) + } + } +} +``` + +- [ ] **Step 2: Run the test to confirm it fails.** + +Run: `go test ./internal/seed/ -run TestClaudeMDProject -v` +Expected: FAIL (`ClaudeMDProject` is undefined). + +- [ ] **Step 3: Add `ClaudeMDProject` to `internal/seed/templates.go`.** + +Append to the file: + +```go +// ClaudeMDProject returns the built-in default CLAUDE.md content for a project workspace. +func ClaudeMDProject() string { + return `# Project Workspace Guidelines + +This is a ctask project workspace -- a long-lived working environment, not a disposable task. + +## File Placement + +- Source code -> ` + "`src/`" + ` or workspace root +- Documentation -> ` + "`docs/`" + ` +- Deliverables and exports -> ` + "`output/`" + ` +- Reference material -> ` + "`context/`" + ` +- Tests -> ` + "`tests/`" + ` +- Configuration files -> workspace root +- Do not place non-code outputs in the workspace root + +## Conventions + +- This project uses git. Commit meaningful changes with clear messages. +- Do not install global packages or modify system files unless asked. +- Record important assumptions and actions in notes.md. +- Keep the workspace root clean. + +## Session Handoff + +Before ending a session, append a brief summary to notes.md with: + +- What was accomplished +- Key decisions made +- Open follow-ups or unfinished work +- How to continue from here +` +} +``` + +- [ ] **Step 4: Run the seed tests.** + +Run: `go test ./internal/seed/ -v -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add internal/seed/templates.go internal/seed/templates_test.go +git commit -m "feat(v0.3): add built-in project CLAUDE.md template" +``` + +--- + +## Phase 2: Seed directory resolution and copy + +### Task 3: Add seed directory resolvers in `config` + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing tests.** + +Append to `internal/config/config_test.go`: + +```go +func TestResolveSeedDirDefault(t *testing.T) { + os.Unsetenv("CTASK_SEED_DIR") + got := ResolveSeedDir() + home, _ := os.UserHomeDir() + var want string + if runtime.GOOS == "windows" { + appData := os.Getenv("APPDATA") + if appData == "" { + t.Skip("APPDATA not set on this Windows host") + } + want = filepath.Join(appData, "ctask", "seed") + } else { + want = filepath.Join(home, ".config", "ctask", "seed") + } + if got != want { + t.Errorf("ResolveSeedDir default: got %q, want %q", got, want) + } +} + +func TestResolveSeedDirOverride(t *testing.T) { + dir := t.TempDir() + os.Setenv("CTASK_SEED_DIR", dir) + defer os.Unsetenv("CTASK_SEED_DIR") + got := ResolveSeedDir() + if got != dir { + t.Errorf("ResolveSeedDir override: got %q, want %q", got, dir) + } +} + +func TestResolveProjectSeedDirDefault(t *testing.T) { + os.Unsetenv("CTASK_SEED_PROJECT_DIR") + got := ResolveProjectSeedDir() + home, _ := os.UserHomeDir() + var want string + if runtime.GOOS == "windows" { + appData := os.Getenv("APPDATA") + if appData == "" { + t.Skip("APPDATA not set on this Windows host") + } + want = filepath.Join(appData, "ctask", "seed-project") + } else { + want = filepath.Join(home, ".config", "ctask", "seed-project") + } + if got != want { + t.Errorf("ResolveProjectSeedDir default: got %q, want %q", got, want) + } +} + +func TestResolveProjectSeedDirOverride(t *testing.T) { + dir := t.TempDir() + os.Setenv("CTASK_SEED_PROJECT_DIR", dir) + defer os.Unsetenv("CTASK_SEED_PROJECT_DIR") + got := ResolveProjectSeedDir() + if got != dir { + t.Errorf("ResolveProjectSeedDir override: got %q, want %q", got, dir) + } +} + +func TestResolveProjectRootFallsBackToRoot(t *testing.T) { + os.Unsetenv("CTASK_PROJECT_ROOT") + os.Setenv("CTASK_ROOT", t.TempDir()) + defer os.Unsetenv("CTASK_ROOT") + got := ResolveProjectRoot() + if got != "" { + t.Errorf("ResolveProjectRoot with no CTASK_PROJECT_ROOT should return empty, got %q", got) + } +} + +func TestResolveProjectRootOverride(t *testing.T) { + dir := t.TempDir() + os.Setenv("CTASK_PROJECT_ROOT", dir) + defer os.Unsetenv("CTASK_PROJECT_ROOT") + got := ResolveProjectRoot() + if got != dir { + t.Errorf("ResolveProjectRoot override: got %q, want %q", dir, got) + } +} +``` + +`runtime` and `filepath` are already imported by the existing test file. + +- [ ] **Step 2: Run the failing tests.** + +Run: `go test ./internal/config/ -run "ResolveSeedDir|ResolveProjectSeedDir|ResolveProjectRoot" -v` +Expected: FAIL (functions are undefined). + +- [ ] **Step 3: Implement the resolvers.** + +Append to `internal/config/config.go`: + +```go +// ResolveSeedDir returns the user general seed directory. +// Reads CTASK_SEED_DIR; falls back to %APPDATA%\ctask\seed on Windows or +// ~/.config/ctask/seed on Unix. +func ResolveSeedDir() string { + if v := os.Getenv("CTASK_SEED_DIR"); v != "" { + return expandPath(v) + } + return defaultSeedDir("seed") +} + +// ResolveProjectSeedDir returns the user project seed directory. +// Reads CTASK_SEED_PROJECT_DIR; falls back to %APPDATA%\ctask\seed-project on Windows +// or ~/.config/ctask/seed-project on Unix. +func ResolveProjectSeedDir() string { + if v := os.Getenv("CTASK_SEED_PROJECT_DIR"); v != "" { + return expandPath(v) + } + return defaultSeedDir("seed-project") +} + +// ResolveProjectRoot returns the project workspace root override. +// Returns empty string if CTASK_PROJECT_ROOT is not set; callers should fall back +// to ResolveRoot() in that case. +func ResolveProjectRoot() string { + v := os.Getenv("CTASK_PROJECT_ROOT") + if v == "" { + return "" + } + return expandPath(v) +} + +func defaultSeedDir(leaf string) string { + if runtime.GOOS == "windows" { + appData := os.Getenv("APPDATA") + if appData == "" { + home, _ := os.UserHomeDir() + return filepath.Join(home, "AppData", "Roaming", "ctask", leaf) + } + return filepath.Join(appData, "ctask", leaf) + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "ctask", leaf) +} + +// expandPath expands a leading ~/ and resolves to an absolute path when possible. +func expandPath(p string) string { + if strings.HasPrefix(p, "~/") || p == "~" { + home, _ := os.UserHomeDir() + p = filepath.Join(home, p[1:]) + } + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} +``` + +Add `"runtime"` to the imports if not already present. + +- [ ] **Step 4: Run the tests.** + +Run: `go test ./internal/config/ -v -count=1` +Expected: PASS for all `Resolve*` tests and existing tests. + +- [ ] **Step 5: Commit.** + +```bash +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat(v0.3): add seed dir and project root resolvers" +``` + +--- + +### Task 4: Add seed directory copy helper + +**Files:** +- Create: `internal/seed/copy.go` +- Create: `internal/seed/copy_test.go` + +- [ ] **Step 1: Write the failing tests.** + +Create `internal/seed/copy_test.go`: + +```go +package seed + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func TestCopySeedDirCopiesFilesAndSubdirs(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, "CLAUDE.md"), "user claude") + writeFile(t, filepath.Join(src, "notes.md"), "user notes") + writeFile(t, filepath.Join(src, "docs", "intro.md"), "doc body") + writeFile(t, filepath.Join(src, "reference", "links.md"), "links") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "user claude" { + t.Errorf("CLAUDE.md content: got %q", got) + } + if got := readFile(t, filepath.Join(dst, "docs", "intro.md")); got != "doc body" { + t.Errorf("docs/intro.md content: got %q", got) + } + if got := readFile(t, filepath.Join(dst, "reference", "links.md")); got != "links" { + t.Errorf("reference/links.md content: got %q", got) + } +} + +func TestCopySeedDirOverwritesExistingFiles(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(dst, "CLAUDE.md"), "builtin") + writeFile(t, filepath.Join(src, "CLAUDE.md"), "user") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "user" { + t.Errorf("expected user seed to overwrite, got %q", got) + } +} + +func TestCopySeedDirSkipsTaskYAML(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, "task.yaml"), "id: bogus") + writeFile(t, filepath.Join(dst, "task.yaml"), "id: real") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if got := readFile(t, filepath.Join(dst, "task.yaml")); got != "id: real" { + t.Errorf("task.yaml should not be overwritten by seed: got %q", got) + } +} + +func TestCopySeedDirSkipsCtaskDir(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + writeFile(t, filepath.Join(src, ".ctask", "manifest-start.json"), "{}") + writeFile(t, filepath.Join(src, "CLAUDE.md"), "ok") + + if err := CopySeedDir(src, dst); err != nil { + t.Fatalf("CopySeedDir: %v", err) + } + if _, err := os.Stat(filepath.Join(dst, ".ctask")); !os.IsNotExist(err) { + t.Errorf(".ctask should be skipped, but it exists in dst") + } + if got := readFile(t, filepath.Join(dst, "CLAUDE.md")); got != "ok" { + t.Errorf("CLAUDE.md content: got %q", got) + } +} + +func TestCopySeedDirMissingSrcIsNoOp(t *testing.T) { + dst := t.TempDir() + missing := filepath.Join(t.TempDir(), "does-not-exist") + + if err := CopySeedDir(missing, dst); err != nil { + t.Errorf("missing seed src should be a no-op, got error: %v", err) + } +} +``` + +- [ ] **Step 2: Run the failing tests.** + +Run: `go test ./internal/seed/ -run TestCopySeedDir -v` +Expected: FAIL (file does not exist). + +- [ ] **Step 3: Implement `CopySeedDir`.** + +Create `internal/seed/copy.go`: + +```go +package seed + +import ( + "errors" + "io" + "os" + "path/filepath" +) + +// CopySeedDir recursively copies the contents of srcDir into dstDir. +// Files at the destination are overwritten. Subdirectories are preserved. +// +// The following entries at the top level of srcDir are intentionally skipped: +// - task.yaml (ctask owns this file) +// - .ctask (directory; reserved for ctask metadata) +// +// If srcDir does not exist, this function returns nil (no-op). Other I/O errors +// are returned to the caller. +func CopySeedDir(srcDir, dstDir string) error { + info, err := os.Stat(srcDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + + entries, err := os.ReadDir(srcDir) + if err != nil { + return err + } + + for _, entry := range entries { + name := entry.Name() + if name == "task.yaml" || name == ".ctask" { + continue + } + srcPath := filepath.Join(srcDir, name) + dstPath := filepath.Join(dstDir, name) + if err := copyEntry(srcPath, dstPath); err != nil { + return err + } + } + return nil +} + +func copyEntry(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if info.IsDir() { + if err := os.MkdirAll(dst, 0755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + if err := copyEntry(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + return err + } + } + return nil + } + return copyFile(src, dst, info.Mode()) +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return nil +} +``` + +- [ ] **Step 4: Run the seed tests.** + +Run: `go test ./internal/seed/ -v -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add internal/seed/copy.go internal/seed/copy_test.go +git commit -m "feat(v0.3): add CopySeedDir with task.yaml/.ctask skip" +``` + +--- + +## Phase 3: TaskMeta `type` field with backward compatibility + +### Task 5: Add `Type` field to `TaskMeta` + +**Files:** +- Modify: `internal/workspace/metadata.go` +- Modify: `internal/workspace/metadata_test.go` + +- [ ] **Step 1: Write failing tests for the `Type` field round-trip and backward compat.** + +Append to `internal/workspace/metadata_test.go`: + +```go +func TestMetaTypeRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260410-120000", + Slug: "billing", + Title: "billing", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "projects", + Mode: "local", + Agent: "claude", + Type: "project", + WorkspacePath: "/tmp/billing", + } + if err := WriteMeta(path, meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + got, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if got.Type != "project" { + t.Errorf("Type: got %q, want \"project\"", got.Type) + } + if EffectiveType(got) != "project" { + t.Errorf("EffectiveType: got %q, want \"project\"", EffectiveType(got)) + } +} + +func TestMetaTypeMissingDefaultsToTask(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + // Write a YAML file that does NOT have a type field (simulating an old v0.2 workspace). + old := []byte(`id: legacy +slug: legacy-task +title: legacy task +created_at: 2026-04-01T00:00:00Z +updated_at: 2026-04-01T00:00:00Z +status: active +category: general +mode: local +agent: claude +workspace_path: /tmp/legacy +archived_at: null +`) + if err := os.WriteFile(path, old, 0644); err != nil { + t.Fatalf("write legacy yaml: %v", err) + } + + meta, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Type != "" { + t.Errorf("legacy Type field: got %q, want empty string", meta.Type) + } + if EffectiveType(meta) != "task" { + t.Errorf("EffectiveType for legacy: got %q, want \"task\"", EffectiveType(meta)) + } +} + +func TestEffectiveTypeEmptyDefaultsToTask(t *testing.T) { + if got := EffectiveType(&TaskMeta{Type: ""}); got != "task" { + t.Errorf("empty Type EffectiveType: got %q, want \"task\"", got) + } + if got := EffectiveType(&TaskMeta{Type: "task"}); got != "task" { + t.Errorf("explicit task EffectiveType: got %q", got) + } + if got := EffectiveType(&TaskMeta{Type: "project"}); got != "project" { + t.Errorf("explicit project EffectiveType: got %q", got) + } +} +``` + +The existing `TestMetaYAMLFieldsPresent` test asserts a fixed list of fields. We need to also include `type:`. Update that test's field list to include `"type:"`. + +- [ ] **Step 2: Run the tests to confirm they fail (or break).** + +Run: `go test ./internal/workspace/ -run "TestMeta|TestEffectiveType" -v` +Expected: FAIL — `Type` field is undefined; `EffectiveType` is undefined. + +- [ ] **Step 3: Add the `Type` field and `EffectiveType` helper.** + +Edit `internal/workspace/metadata.go`. Insert `Type` immediately after `Mode` (or anywhere — order matters for the YAML output, so we choose a stable spot near the other classification fields): + +```go +package workspace + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// TaskMeta represents the task.yaml schema. +// The Type field is v0.3+; older workspaces without this field are treated as "task". +type TaskMeta struct { + ID string `yaml:"id"` + Slug string `yaml:"slug"` + Title string `yaml:"title"` + CreatedAt time.Time `yaml:"created_at"` + UpdatedAt time.Time `yaml:"updated_at"` + Status string `yaml:"status"` + Category string `yaml:"category"` + Type string `yaml:"type"` + Mode string `yaml:"mode"` + Agent string `yaml:"agent"` + WorkspacePath string `yaml:"workspace_path"` + ArchivedAt *time.Time `yaml:"archived_at"` +} + +// EffectiveType returns "task" or "project". An empty or missing Type field +// is treated as "task" for backward compatibility with v0.2 workspaces. +func EffectiveType(m *TaskMeta) string { + if m == nil { + return "task" + } + switch m.Type { + case "project": + return "project" + default: + return "task" + } +} + +// WriteMeta writes a TaskMeta to a YAML file. +func WriteMeta(path string, meta *TaskMeta) error { + data, err := yaml.Marshal(meta) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// ReadMeta reads a TaskMeta from a YAML file. +func ReadMeta(path string) (*TaskMeta, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta TaskMeta + if err := yaml.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} +``` + +- [ ] **Step 4: Update `TestMetaYAMLFieldsPresent` to include `"type:"`.** + +Edit `internal/workspace/metadata_test.go`: + +```go + for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "type:", "mode:", "agent:", "workspace_path:", "archived_at:"} { +``` + +- [ ] **Step 5: Run the metadata tests.** + +Run: `go test ./internal/workspace/ -run "TestMeta|TestEffectiveType" -v` +Expected: PASS. + +- [ ] **Step 6: Run the full test suite to catch indirect breakage.** + +Run: `go test ./... -count=1` +Expected: PASS. (No callers of `TaskMeta` care about field order; YAML tags drive serialization.) + +- [ ] **Step 7: Commit.** + +```bash +git add internal/workspace/metadata.go internal/workspace/metadata_test.go +git commit -m "feat(v0.3): add Type field to TaskMeta with backward-compatible EffectiveType" +``` + +--- + +## Phase 4: `Create` workspace flow with project mode and seed overlay + +### Task 6: Extend `CreateOpts` and `Create` to support project mode + seed overlay + +**Files:** +- Modify: `internal/workspace/create.go` +- Modify: `internal/workspace/create_test.go` + +- [ ] **Step 1: Write the failing tests.** + +Append to `internal/workspace/create_test.go`: + +```go +import ( + "strings" +) + +// (the import is added near the top; "strings" may already be unused — keep tidy) + +func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { + root := t.TempDir() + opts := CreateOpts{ + Root: root, + Title: "billing", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, err := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + content := string(body) + if !strings.Contains(content, "# Project Workspace Guidelines") { + t.Errorf("expected project CLAUDE.md, got:\n%s", content) + } + if ws.Meta.Type != "project" { + t.Errorf("Type: got %q, want \"project\"", ws.Meta.Type) + } +} + +func TestCreateTaskWritesTaskClaudeMD(t *testing.T) { + root := t.TempDir() + opts := CreateOpts{ + Root: root, + Title: "fix bug", + Category: "general", + Mode: "local", + Agent: "claude", + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if !strings.Contains(string(body), "# Workspace Guidelines") { + t.Errorf("expected task CLAUDE.md, got:\n%s", string(body)) + } + if ws.Meta.Type != "task" { + t.Errorf("Type: got %q, want \"task\"", ws.Meta.Type) + } +} + +func TestCreateAppliesGeneralSeed(t *testing.T) { + root := t.TempDir() + seedDir := t.TempDir() + + // Seed has a custom CLAUDE.md and an extra file. + if err := os.WriteFile(filepath.Join(seedDir, "CLAUDE.md"), []byte("user claude override"), 0644); err != nil { + t.Fatalf("write seed CLAUDE.md: %v", err) + } + if err := os.WriteFile(filepath.Join(seedDir, ".cursorrules"), []byte("cursor rules"), 0644); err != nil { + t.Fatalf("write .cursorrules: %v", err) + } + + opts := CreateOpts{ + Root: root, + Title: "seeded", + Category: "general", + Mode: "local", + Agent: "claude", + SeedDir: seedDir, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if string(body) != "user claude override" { + t.Errorf("seed should have overwritten CLAUDE.md, got %q", string(body)) + } + if _, err := os.Stat(filepath.Join(ws.Path, ".cursorrules")); err != nil { + t.Errorf(".cursorrules should exist: %v", err) + } +} + +func TestCreateProjectAppliesGeneralThenProjectSeed(t *testing.T) { + root := t.TempDir() + general := t.TempDir() + project := t.TempDir() + + if err := os.WriteFile(filepath.Join(general, "CLAUDE.md"), []byte("general"), 0644); err != nil { + t.Fatalf("write general: %v", err) + } + if err := os.WriteFile(filepath.Join(general, "general-only.txt"), []byte("g"), 0644); err != nil { + t.Fatalf("write general-only: %v", err) + } + if err := os.WriteFile(filepath.Join(project, "CLAUDE.md"), []byte("project"), 0644); err != nil { + t.Fatalf("write project: %v", err) + } + if err := os.WriteFile(filepath.Join(project, "project-only.txt"), []byte("p"), 0644); err != nil { + t.Fatalf("write project-only: %v", err) + } + + opts := CreateOpts{ + Root: root, + Title: "billing", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + SeedDir: general, + ProjectSeedDir: project, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + body, _ := os.ReadFile(filepath.Join(ws.Path, "CLAUDE.md")) + if string(body) != "project" { + t.Errorf("project seed should win, got %q", string(body)) + } + if _, err := os.Stat(filepath.Join(ws.Path, "general-only.txt")); err != nil { + t.Errorf("general-only.txt should exist: %v", err) + } + if _, err := os.Stat(filepath.Join(ws.Path, "project-only.txt")); err != nil { + t.Errorf("project-only.txt should exist: %v", err) + } +} + +func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) { + root := t.TempDir() + seedDir := t.TempDir() + if err := os.WriteFile(filepath.Join(seedDir, "task.yaml"), []byte("id: bogus"), 0644); err != nil { + t.Fatalf("write seed task.yaml: %v", err) + } + opts := CreateOpts{ + Root: root, + Title: "no clobber", + Category: "general", + Mode: "local", + Agent: "claude", + SeedDir: seedDir, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + meta, err := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Slug != "no-clobber" { + t.Errorf("task.yaml should be ctask-generated, got slug %q", meta.Slug) + } +} +``` + +Make sure `strings` is imported. If `strings` was not imported in the existing test file, add it. + +- [ ] **Step 2: Run the failing tests.** + +Run: `go test ./internal/workspace/ -run "TestCreateProject|TestCreateTask|TestCreateApplies|TestCreateSeed" -v` +Expected: FAIL — fields and behavior don't exist yet. + +- [ ] **Step 3: Update `CreateOpts` and `Create` for project mode + seed overlay.** + +Replace `internal/workspace/create.go` with: + +```go +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 + + // IsProject switches the built-in defaults to project-oriented templates, + // records type=project in task.yaml, and triggers project seed overlay. + IsProject bool + + // SeedDir is the absolute path to the general user seed directory. + // If empty or non-existent, no general seed overlay is applied. + SeedDir string + + // ProjectSeedDir is the absolute path to the project-specific seed directory. + // Only consulted when IsProject is true. + ProjectSeedDir string +} + +// CreateResult holds the result of workspace creation. +type CreateResult struct { + Path string + Meta *TaskMeta +} + +// Create creates a new task or project workspace with seed files and metadata. +// +// Layered seeding order (later layers overwrite earlier ones): +// 1. built-in defaults (task or project, depending on IsProject) +// 2. general user seed (opts.SeedDir), if it exists +// 3. project user seed (opts.ProjectSeedDir), if IsProject and it exists +// +// task.yaml is always written by ctask AFTER the seed copy step, so seeds +// cannot inject a stale task.yaml even by accident. +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) + + 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) + } + + // Standard 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) + } + } + + actualTitle := title + if actualSlug != slug { + suffix := actualSlug[len(slug):] + actualTitle = title + suffix + } + + taskType := "task" + if opts.IsProject { + taskType = "project" + } + + // Layer 1: built-in defaults (only fill in files that aren't already there; + // the workspace is brand new, so this always writes them). + writeBuiltinDefaults(wsDir, actualTitle, opts.IsProject) + + // Layer 2: general user seed (overwrites built-in defaults; skips task.yaml/.ctask). + if opts.SeedDir != "" { + if err := seed.CopySeedDir(opts.SeedDir, wsDir); err != nil { + return nil, fmt.Errorf("applying general seed: %w", err) + } + } + + // Layer 3: project user seed (project mode only). + if opts.IsProject && opts.ProjectSeedDir != "" { + if err := seed.CopySeedDir(opts.ProjectSeedDir, wsDir); err != nil { + return nil, fmt.Errorf("applying project seed: %w", err) + } + } + + meta := &TaskMeta{ + ID: id, + Slug: actualSlug, + Title: actualTitle, + CreatedAt: now.Truncate(time.Second), + UpdatedAt: now.Truncate(time.Second), + Status: "active", + Category: opts.Category, + Type: taskType, + Mode: opts.Mode, + Agent: opts.Agent, + WorkspacePath: wsDir, + ArchivedAt: nil, + } + + // task.yaml is always written by ctask, AFTER the seed step, so seeds can + // never inject a stale or malformed task.yaml. + 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 +} + +// writeBuiltinDefaults writes the built-in CLAUDE.md and notes.md for a new workspace. +// These are guaranteed to be replaceable by the seed overlay layers. +func writeBuiltinDefaults(wsDir, title string, isProject bool) { + claudePath := filepath.Join(wsDir, "CLAUDE.md") + var claudeBody string + if isProject { + claudeBody = seed.ClaudeMDProject() + } else { + // The slug/category/path args are kept for API compatibility but are + // not interpolated by the v0.3 template. + claudeBody = seed.ClaudeMD("", "", wsDir) + } + _ = os.WriteFile(claudePath, []byte(claudeBody), 0644) + + notesPath := filepath.Join(wsDir, "notes.md") + _ = os.WriteFile(notesPath, []byte(seed.NotesMD(title)), 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), "/") +} +``` + +Note: this drops the old `writeSeedFiles` helper. The existing test `TestCreateDoesNotOverwriteSeedFiles` calls `writeSeedFiles` directly. We keep behavior intact by deleting the test (its semantics are now tested via the new seed-overlay tests, where the seed directory's content unconditionally wins). Or we replace it with an equivalent test against `writeBuiltinDefaults` if it's still meaningful. + +Decision: **delete** `TestCreateDoesNotOverwriteSeedFiles`. The "don't overwrite existing files" behavior was a v0.2 quirk to make resume safe; v0.3 only invokes built-in defaults from inside `Create` (a fresh dir), so the test no longer reflects real behavior. + +Edit `internal/workspace/create_test.go` to delete the `TestCreateDoesNotOverwriteSeedFiles` function. + +- [ ] **Step 4: Run the workspace tests.** + +Run: `go test ./internal/workspace/ -v -count=1` +Expected: PASS, including the new tests and the surviving existing tests. + +- [ ] **Step 5: Run the full suite.** + +Run: `go test ./... -count=1` +Expected: PASS. (`cmd/new.go` still compiles because `CreateOpts` only added new fields with zero values.) + +- [ ] **Step 6: Commit.** + +```bash +git add internal/workspace/create.go internal/workspace/create_test.go +git commit -m "feat(v0.3): support project mode and layered seed overlay in workspace.Create" +``` + +--- + +## Phase 5: Project root resolution and `--project` flag + +### Task 7: Add project-root path resolution helper + +**Files:** +- Modify: `internal/workspace/create.go` (export a helper) — actually, we put this in `cmd/new.go` because it depends on resolving env vars. Re-evaluate. + +Decision: Keep root selection inside `cmd/new.go` since it composes config + flag state. Skip a dedicated workspace helper for path selection — the rule is small enough to live in the command. This task only documents the rule and tests it via end-to-end `Create` calls. + +We do however want a unit-tested helper for the project-root rule because the rule has three branches (no override, override + default category, override + explicit category) and is easy to get wrong. Add it to `internal/workspace/create.go`. + +- [ ] **Step 1: Write failing tests for `ProjectRoot`.** + +Append to `internal/workspace/create_test.go`: + +```go +func TestProjectRootNoOverride(t *testing.T) { + root := "/abs/root" + got := ProjectRoot(root, "", "general", false) + if got != filepath.Join(root, "general") { + t.Errorf("task no-override: got %q", got) + } +} + +func TestProjectRootProjectNoOverride(t *testing.T) { + root := "/abs/root" + // Project, no CTASK_PROJECT_ROOT, default category "projects". + got := ProjectRoot(root, "", "projects", true) + if got != filepath.Join(root, "projects") { + t.Errorf("project no-override: got %q", got) + } +} + +func TestProjectRootProjectOverrideNoExplicitCategory(t *testing.T) { + root := "/abs/root" + override := "/abs/projects" + // Project, CTASK_PROJECT_ROOT set, default category — should NOT append category. + got := ProjectRoot(root, override, "projects", true) + if got != override { + t.Errorf("project override + default category: got %q, want %q", got, override) + } +} + +func TestProjectRootProjectOverrideExplicitCategory(t *testing.T) { + root := "/abs/root" + override := "/abs/projects" + // Project, CTASK_PROJECT_ROOT set, explicit category "backend". + got := ProjectRoot(root, override, "backend", true) + if got != filepath.Join(override, "backend") { + t.Errorf("project override + explicit category: got %q", got) + } +} +``` + +- [ ] **Step 2: Run failing tests.** + +Run: `go test ./internal/workspace/ -run TestProjectRoot -v` +Expected: FAIL (`ProjectRoot` undefined). + +- [ ] **Step 3: Implement `ProjectRoot`.** + +Append to `internal/workspace/create.go`: + +```go +// ProjectRoot returns the directory under which a workspace's category subdirectory +// (and ultimately the workspace dir) should be created, given the resolved roots +// and the user's category choice. +// +// Rules: +// - For task workspaces (isProject == false), always return root (the category +// subdirectory is appended downstream by the caller via opts.Category). +// - For project workspaces with no projectRootOverride, return root (caller +// supplies the default "projects" category downstream). +// - For project workspaces with projectRootOverride set: +// - if explicitCategory is false (the user did not pass -c), return the +// override directly (no category subdirectory will be appended). +// - if explicitCategory is true, return the override (the explicit category +// is appended downstream). +// +// The boolean explicitCategory must be true if and only if the user explicitly +// passed -c on the command line. +func ProjectRoot(root, projectRootOverride, category string, isProject bool) string { + _ = category + if !isProject { + return root + } + if projectRootOverride == "" { + return root + } + return projectRootOverride +} +``` + +Wait — this is too thin to be useful. The branching that *matters* is "should we append a category subdirectory?". The current `Create` always appends `opts.Category` to `opts.Root`. The cleanest way to implement the spec is to make `cmd/new.go` choose: + +1. Final `Root` = `CTASK_PROJECT_ROOT` if project mode and override set, else `CTASK_ROOT` +2. Final `Category` = "" if project mode + override set + user did NOT pass `-c`, else the user's category + +We then need `Create` to handle `opts.Category == ""` by **not** appending a category subdirectory at all. + +Revise: add this small change to `Create` first. + +Replace the lines in `Create`: + +```go + categoryDir := filepath.Join(opts.Root, opts.Category) +``` + +with: + +```go + var categoryDir string + if opts.Category == "" { + categoryDir = opts.Root + } else { + categoryDir = filepath.Join(opts.Root, opts.Category) + } +``` + +And we need a test that asserts `Create` honors `Category == ""` (no doubled segment). + +Replace the previous `ProjectRoot` tests/impl with simpler `Create` integration tests. Drop the `ProjectRoot` helper entirely; the spec rule is small and lives in `cmd/new.go`. + +- [ ] **Step 3 (revised): Replace the `ProjectRoot` tests with `Create` integration tests for empty category.** + +Remove the four `TestProjectRoot*` tests from `create_test.go` and add: + +```go +func TestCreateEmptyCategoryUsesRootDirectly(t *testing.T) { + root := t.TempDir() + opts := CreateOpts{ + Root: root, + Title: "billing service", + Category: "", // simulating CTASK_PROJECT_ROOT mode without explicit -c + Mode: "local", + Agent: "claude", + IsProject: true, + } + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + // Workspace dir should be a direct child of root, NOT root/projects/... + parent := filepath.Dir(ws.Path) + if parent != root { + t.Errorf("workspace parent: got %q, want %q", parent, root) + } +} + +func TestCreateExplicitCategoryAppendsSubdir(t *testing.T) { + root := t.TempDir() + opts := CreateOpts{ + Root: root, + Title: "billing service", + Category: "backend", + Mode: "local", + Agent: "claude", + IsProject: true, + } + 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")) + } +} +``` + +Also, in `Create`, leave the `Category` value on the `TaskMeta`. When the user used `CTASK_PROJECT_ROOT` without explicit `-c`, the stored `category` should be `"projects"` (per spec wording: "default category becomes `projects`"). The category metadata is **always** populated even when no subdirectory is appended. + +So `cmd/new.go` will pass `Category: "projects"` to `meta` but `categoryDirOverride: true` (meaning skip the join). To keep `Create`'s API minimal, add a `SkipCategoryDir bool` field instead of overloading empty-string: + +```go + // 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 +``` + +And in `Create`: + +```go + categoryDir := opts.Root + if !opts.SkipCategoryDir { + categoryDir = filepath.Join(opts.Root, opts.Category) + } +``` + +Update the new tests to use `SkipCategoryDir: true` for the "no doubled projects/" case. Replace the previous test snippet with: + +```go +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")) + } +} +``` + +- [ ] **Step 4: Update `Create` to honor `SkipCategoryDir`.** + +In `internal/workspace/create.go`, add the field to `CreateOpts` and replace the `categoryDir` line with: + +```go + categoryDir := opts.Root + if !opts.SkipCategoryDir { + categoryDir = filepath.Join(opts.Root, opts.Category) + } +``` + +- [ ] **Step 5: Run workspace tests.** + +Run: `go test ./internal/workspace/ -v -count=1` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add internal/workspace/create.go internal/workspace/create_test.go +git commit -m "feat(v0.3): add SkipCategoryDir to Create for CTASK_PROJECT_ROOT semantics" +``` + +--- + +### Task 8: Add `--project` flag to `ctask new` (without git init yet) + +**Files:** +- Modify: `cmd/new.go` +- Modify: `internal/config/config.go` (add `CTASK_TYPE` to `EnvVars`) + +- [ ] **Step 1: Update `EnvVars` to include `CTASK_TYPE`.** + +Edit `internal/config/config.go`. Change `EnvVars` signature: + +```go +// EnvVars returns the environment variables to export into child sessions. +// taskType must be "task" or "project". +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, + } +} +``` + +- [ ] **Step 2: Update the existing config test to pass the new arg, and add a CTASK_TYPE assertion.** + +In `internal/config/config_test.go`, replace the body of `TestEnvVars`: + +```go +func TestEnvVars(t *testing.T) { + 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 { + t.Errorf("env %s: expected %q, got %q", k, v, vars[k]) + } + } +} + +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"]) + } +} +``` + +- [ ] **Step 3: Update all callers of `EnvVars` to pass the new arg.** + +There are five callers: +- `cmd/new.go:74` +- `cmd/resume.go:63` +- `cmd/open.go:41` +- `cmd/last.go` — does it call `EnvVars`? It delegates to `doResume`, so only `resume.go` needs the change. +- (Verify with grep before editing.) + +Run a grep to be exhaustive: + +``` +Grep "config.EnvVars(" --output_mode=content -n +``` + +For each match, change the call site to also pass `workspace.EffectiveType(meta)` (or `"task"` literal where appropriate, but always prefer the helper to stay correct for projects). + +Edits: + +`cmd/new.go` — change + +```go +envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) +``` + +to + +```go +envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) +``` + +`cmd/resume.go`: + +```go +envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) +``` + +`cmd/open.go`: + +```go +envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category, workspace.EffectiveType(ws.Meta)) +``` + +- [ ] **Step 4: Add the `--project` flag and project-aware path resolution to `cmd/new.go`.** + +Replace the contents of `cmd/new.go` with: + +```go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var newCmd = &cobra.Command{ + Use: "new [title]", + Short: "Create a new task 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, +} + +var ( + newCategory string + newContainer bool + newShell bool + newAgent string + newNoLaunch bool + newProject bool +) + +func init() { + newCmd.Flags().StringVarP(&newCategory, "category", "c", "", "Workspace category subdirectory") + newCmd.Flags().BoolVar(&newProject, "project", false, "Create a project workspace (longer-lived; uses CTASK_PROJECT_ROOT if set)") + 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") + newCmd.Flags().BoolVar(&newNoLaunch, "no-launch", false, "Create workspace only, do not launch") + rootCmd.AddCommand(newCmd) +} + +func runNew(cmd *cobra.Command, args []string) error { + if newContainer { + fmt.Println(shell.ContainerNotice()) + return nil + } + + agent := newAgent + if agent == "" { + agent = config.ResolveAgent() + } + + title := "" + if len(args) > 0 { + title = args[0] + } + + // Determine category default and explicitness. + 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 + } + + // Display path: relative to whatever root we used (taskRoot for tasks, root for projects). + relPath := workspace.RelativePath(root, ws.Path) + fmt.Printf("[ctask] created %s\n", relPath) + + if newNoLaunch { + return nil + } + + 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, + EnvVars: envVars, + Agent: agent, + Mode: ws.Meta.Mode, + Slug: ws.Meta.Slug, + Shell: newShell, + }) +} +``` + +Note: the `--category` default is now empty string. We set the actual default ("general" or "projects") inside `runNew` so we can detect explicit usage via `cmd.Flags().Changed("category")`. This is the standard cobra idiom. + +- [ ] **Step 5: Run all tests, including `cmd/`.** + +Run: `go test ./... -count=1` +Expected: PASS. + +If `cmd/delete_test.go` doesn't compile because of unrelated `EnvVars` calls, fix the call sites. + +- [ ] **Step 6: Quick smoke compile check.** + +Run: `go build ./...` +Expected: success. + +- [ ] **Step 7: Commit.** + +```bash +git add cmd/new.go cmd/resume.go cmd/open.go internal/config/config.go internal/config/config_test.go +git commit -m "feat(v0.3): add --project flag, CTASK_TYPE env, project-root semantics" +``` + +--- + +## Phase 6: Git initialization for project mode + +### Task 9: Add `EnsureGitignore` and `RunGitInit` helpers + +**Files:** +- Create: `internal/workspace/git.go` +- Create: `internal/workspace/git_test.go` + +- [ ] **Step 1: Write the failing tests.** + +Create `internal/workspace/git_test.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureGitignoreCreatesWhenMissing(t *testing.T) { + dir := t.TempDir() + + if err := EnsureGitignore(dir); err != nil { + t.Fatalf("EnsureGitignore: %v", err) + } + + body, err := os.ReadFile(filepath.Join(dir, ".gitignore")) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + content := string(body) + for _, want := range []string{".ctask/", "logs/sessions.log"} { + if !strings.Contains(content, want) { + t.Errorf(".gitignore missing %q", want) + } + } +} + +func TestEnsureGitignorePreservesExisting(t *testing.T) { + dir := t.TempDir() + original := "# user gitignore\nnode_modules/\n" + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(original), 0644); err != nil { + t.Fatalf("write seed gitignore: %v", err) + } + + if err := EnsureGitignore(dir); err != nil { + t.Fatalf("EnsureGitignore: %v", err) + } + + body, _ := os.ReadFile(filepath.Join(dir, ".gitignore")) + if string(body) != original { + t.Errorf(".gitignore should be preserved verbatim, got %q", string(body)) + } +} + +// Integration: a .gitignore placed by the GENERAL seed survives the full +// Create + EnsureGitignore flow for a project workspace. +func TestCreateProjectPreservesGeneralSeedGitignore(t *testing.T) { + root := t.TempDir() + general := t.TempDir() + + original := "# general seed\nbuild/\n" + if err := os.WriteFile(filepath.Join(general, ".gitignore"), []byte(original), 0644); err != nil { + t.Fatalf("write general .gitignore: %v", err) + } + + ws, err := Create(CreateOpts{ + Root: root, + Title: "gen gi proj", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + SeedDir: general, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := EnsureGitignore(ws.Path); err != nil { + t.Fatalf("EnsureGitignore: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore")) + if string(body) != original { + t.Errorf("general seed .gitignore not preserved: got %q", string(body)) + } +} + +// Integration: a .gitignore placed by the PROJECT seed survives the full +// Create + EnsureGitignore flow. +func TestCreateProjectPreservesProjectSeedGitignore(t *testing.T) { + root := t.TempDir() + project := t.TempDir() + + original := "# project seed\ndist/\n" + if err := os.WriteFile(filepath.Join(project, ".gitignore"), []byte(original), 0644); err != nil { + t.Fatalf("write project .gitignore: %v", err) + } + + ws, err := Create(CreateOpts{ + Root: root, + Title: "proj gi proj", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + ProjectSeedDir: project, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := EnsureGitignore(ws.Path); err != nil { + t.Fatalf("EnsureGitignore: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore")) + if string(body) != original { + t.Errorf("project seed .gitignore not preserved: got %q", string(body)) + } +} + +// Integration: with no seed-provided .gitignore, EnsureGitignore creates the +// minimal one. +func TestCreateProjectCreatesMinimalGitignoreWhenNoSeed(t *testing.T) { + root := t.TempDir() + ws, err := Create(CreateOpts{ + Root: root, + Title: "no seed gi", + Category: "projects", + Mode: "local", + Agent: "claude", + IsProject: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := EnsureGitignore(ws.Path); err != nil { + t.Fatalf("EnsureGitignore: %v", err) + } + body, _ := os.ReadFile(filepath.Join(ws.Path, ".gitignore")) + content := string(body) + for _, want := range []string{".ctask/", "logs/sessions.log"} { + if !strings.Contains(content, want) { + t.Errorf("minimal .gitignore missing %q, got: %q", want, content) + } + } +} +``` + +- [ ] **Step 2: Run the failing tests.** + +Run: `go test ./internal/workspace/ -run TestEnsureGitignore -v` +Expected: FAIL. + +- [ ] **Step 3: Implement the helpers.** + +Create `internal/workspace/git.go`: + +```go +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// minimalGitignore is the .gitignore body ctask creates for new project workspaces. +// Kept narrow on purpose: ctask only owns its own metadata and session log. +const minimalGitignore = `.ctask/ +logs/sessions.log +` + +// EnsureGitignore creates a minimal .gitignore in wsDir if (and only if) one +// does not already exist. If the file already exists (because the seed provided +// it, or because the user created it), it is left untouched. +func EnsureGitignore(wsDir string) error { + path := filepath.Join(wsDir, ".gitignore") + if _, err := os.Stat(path); err == nil { + return nil // already present (e.g. from a seed) — do not overwrite + } else if !os.IsNotExist(err) { + return err + } + return os.WriteFile(path, []byte(minimalGitignore), 0644) +} + +// GitAvailable reports whether the `git` executable is on PATH. +func GitAvailable() bool { + _, err := exec.LookPath("git") + return err == nil +} + +// RunGitInit runs `git init` in wsDir. Returns nil if git is unavailable +// (caller is responsible for printing the informational note in that case). +// Returns the error from git itself if init fails. +func RunGitInit(wsDir string) error { + if !GitAvailable() { + return nil + } + cmd := exec.Command("git", "init") + cmd.Dir = wsDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git init failed: %w (output: %s)", err, string(out)) + } + return nil +} +``` + +- [ ] **Step 4: Run the workspace tests.** + +Run: `go test ./internal/workspace/ -v -count=1` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add internal/workspace/git.go internal/workspace/git_test.go +git commit -m "feat(v0.3): add EnsureGitignore and RunGitInit helpers" +``` + +--- + +### Task 10: Wire git init into `cmd/new.go` for project mode + +**Files:** +- Modify: `cmd/new.go` + +- [ ] **Step 1: Add git init invocation after `Create`, before banner/launch.** + +In `cmd/new.go`'s `runNew`, after `ws, err := workspace.Create(opts)` and before `relPath := ...`, insert the project mode git init block: + +```go + if newProject { + if !workspace.GitAvailable() { + fmt.Println("[ctask] git not found; skipped repository initialization") + } else { + if err := workspace.RunGitInit(ws.Path); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: %v\n", err) + } + } + // Seed-wins: EnsureGitignore is a no-op when a seed provided .gitignore. + if err := workspace.EnsureGitignore(ws.Path); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] warning: failed to create .gitignore: %v\n", err) + } + } +``` + +Add `"os"` to the imports if missing. + +The order matters: +1. Create the workspace (with built-in defaults + general seed + project seed already applied; **task.yaml is also already written**) +2. `git init` (or print the informational note if git unavailable) +3. Ensure `.gitignore` — which is a no-op if **any** seed (general OR project) supplied one + +This is the seed-wins rule: a user-provided `.gitignore` from either seed directory must be preserved verbatim. The unit tests in Task 9 cover the "missing -> created" and "already present -> preserved" cases at the file-system level, regardless of which seed put it there. + +- [ ] **Step 2: Run the full test suite.** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 3: Manual smoke test (optional in plan, mandatory in summary).** + +``` +go build -o ctask.exe . +mkdir -p /tmp/ctask-smoke +CTASK_ROOT=/tmp/ctask-smoke ./ctask.exe new --no-launch --project "smoke project" +ls /tmp/ctask-smoke/projects/*/ +cat /tmp/ctask-smoke/projects/*/.gitignore +``` + +- [ ] **Step 4: Commit.** + +```bash +git add cmd/new.go +git commit -m "feat(v0.3): run git init and ensure .gitignore for project workspaces" +``` + +--- + +## Phase 7: `--projects` filter in `ctask list` + +### Task 11: Add type-based filtering to `ListWorkspaces` + +**Files:** +- Modify: `internal/workspace/list.go` +- Modify: `internal/workspace/list_test.go` +- Modify: `internal/workspace/query_test.go` (helper) + +- [ ] **Step 1: Update the test helper to accept a type.** + +The shared helper `createTestWorkspace` in `internal/workspace/query_test.go` does not currently set `Type`. Update it to accept and store `taskType`: + +```go +func createTestWorkspace(t *testing.T, root, category, dirName, status string) { + createTestWorkspaceTyped(t, root, category, dirName, status, "task") +} + +func createTestWorkspaceTyped(t *testing.T, root, category, dirName, status, taskType string) { + t.Helper() + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + slug := dirName[11:] + meta := &TaskMeta{ + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Type: taskType, + Mode: "local", + Agent: "claude", + WorkspacePath: dir, + } + WriteMeta(filepath.Join(dir, "task.yaml"), meta) +} +``` + +- [ ] **Step 2: Write the failing test for `Projects` filter.** + +Append to `internal/workspace/list_test.go`: + +```go +func TestListProjectsFilter(t *testing.T) { + root := t.TempDir() + createTestWorkspaceTyped(t, root, "general", "2026-04-05_task-a", "active", "task") + createTestWorkspaceTyped(t, root, "general", "2026-04-04_task-b", "active", "") // legacy: no type -> task + createTestWorkspaceTyped(t, root, "projects", "2026-04-03_proj-a", "active", "project") + createTestWorkspaceTyped(t, root, "projects", "2026-04-02_proj-b", "archived", "project") + + // Default: tasks only, active + results, err := ListWorkspaces(root, ListOpts{Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 active tasks, got %d", len(results)) + } + for _, r := range results { + if EffectiveType(r.Meta) != "task" { + t.Errorf("non-task in default list: %s (type %q)", r.Meta.Slug, r.Meta.Type) + } + } + + // --projects: projects only, active + results, err = ListWorkspaces(root, ListOpts{Projects: true, Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces --projects: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 active project, got %d", len(results)) + } + if results[0].Meta.Slug != "proj-a" { + t.Errorf("expected proj-a, got %s", results[0].Meta.Slug) + } + + // --projects --all + results, err = ListWorkspaces(root, ListOpts{Projects: true, IncludeArchived: true, Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces --projects --all: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 projects with --all, got %d", len(results)) + } + + // --all alone is tasks only with archived + results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces --all: %v", err) + } + for _, r := range results { + if EffectiveType(r.Meta) != "task" { + t.Errorf("--all alone returned non-task: %s", r.Meta.Slug) + } + } +} +``` + +- [ ] **Step 3: Run the failing test.** + +Run: `go test ./internal/workspace/ -run TestListProjectsFilter -v` +Expected: FAIL (unknown field `Projects` and behavior). + +- [ ] **Step 4: Add `Projects` to `ListOpts` and filter by type.** + +Edit `internal/workspace/list.go`: + +```go +package workspace + +import ( + "path/filepath" + "sort" +) + +// ListOpts configures workspace listing. +type ListOpts struct { + IncludeArchived bool + Category string + Limit int + + // Projects, when true, returns project workspaces only. When false (default), + // only task workspaces are returned. v0.2 workspaces with no Type field are + // treated as tasks via EffectiveType. + Projects bool +} + +// ListWorkspaces returns workspaces in reverse-chronological order. +func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) { + all, err := scanWorkspaces(root) + if err != nil { + return nil, err + } + + wantType := "task" + if opts.Projects { + wantType = "project" + } + + var filtered []QueryResult + for _, ws := range all { + if !opts.IncludeArchived && ws.Meta.Status == "archived" { + continue + } + if opts.Category != "" && ws.Meta.Category != opts.Category { + continue + } + if EffectiveType(ws.Meta) != wantType { + continue + } + filtered = append(filtered, ws) + } + + sort.Slice(filtered, func(i, j int) bool { + return filepath.Base(filtered[i].Path) > filepath.Base(filtered[j].Path) + }) + + if opts.Limit > 0 && len(filtered) > opts.Limit { + filtered = filtered[:opts.Limit] + } + + return filtered, nil +} +``` + +- [ ] **Step 5: Re-check the existing `TestListWorkspaces`.** + +The existing `TestListWorkspaces` creates three workspaces using `createTestWorkspace` (which now defaults to type=task). The expectations should still pass because all three are tasks. + +Run: `go test ./internal/workspace/ -v -count=1` +Expected: PASS. + +- [ ] **Step 6: Audit other callers of `ListWorkspaces` for the new default behavior.** + +Run: `Grep "ListWorkspaces(" -n` + +Other callers: +- `cmd/list.go` — will be updated in the next task to take `--projects`. For now its current behavior changes: `ctask list` will only show tasks. This matches the spec. +- `cmd/last.go` — `last` resumes the most recently updated workspace. The spec says `last` should still work for both tasks and projects. The current call is `ListWorkspaces(root, ListOpts{IncludeArchived: false, Limit: 0})`, which will now only return tasks. **This is a regression for `last` if the user works in projects.** + +Fix: `cmd/last.go` needs to scan both tasks AND projects. The simplest fix is to scan both types and pick the most recent. Add a one-call helper or call twice. + +Apply: in `cmd/last.go`, replace the single `ListWorkspaces` call with two calls and a merge. + +```go + tasks, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Limit: 0, + }) + if err != nil { + return err + } + projects, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Projects: true, + Limit: 0, + }) + if err != nil { + return err + } + results := append(tasks, projects...) +``` + +- `cmd/delete.go` — calls `ListWorkspaces` to find the "most recent workspace" note. Same regression. Apply the same fix: scan both types and pick the most recent across the union. + +Replace the relevant block in `cmd/delete.go`: + +```go + tasks, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Limit: 0, + }) + projects, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Projects: true, + Limit: 0, + }) + results := append(tasks, projects...) +``` + +(Keep the rest of the most-recent-detection logic unchanged.) + +- [ ] **Step 7: Run the full suite.** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 8: Commit.** + +```bash +git add internal/workspace/list.go internal/workspace/list_test.go internal/workspace/query_test.go cmd/last.go cmd/delete.go +git commit -m "feat(v0.3): add Projects filter to ListWorkspaces and union scans for last/delete" +``` + +--- + +### Task 12: Add `--projects` flag to `ctask list` + +**Files:** +- Modify: `cmd/list.go` + +- [ ] **Step 1: Add the flag.** + +Edit `cmd/list.go`: + +```go +var ( + listAll bool + listCategory string + listLimit int + listProjects bool +) + +func init() { + listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "Include archived workspaces") + listCmd.Flags().BoolVar(&listProjects, "projects", false, "Show projects instead of tasks") + listCmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") + listCmd.Flags().IntVarP(&listLimit, "limit", "n", 20, "Maximum entries to show") + rootCmd.AddCommand(listCmd) +} +``` + +In `runList`: + +```go + results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: listAll, + Category: listCategory, + Limit: listLimit, + Projects: listProjects, + }) +``` + +Optionally adjust the empty-result message to differentiate tasks vs projects: + +```go + if len(results) == 0 { + if listProjects { + fmt.Println("No projects found.") + } else { + fmt.Println("No tasks found.") + } + return nil + } +``` + +- [ ] **Step 2: Run all tests.** + +Run: `go test ./... -count=1` +Expected: PASS. + +- [ ] **Step 3: Build and quick smoke.** + +``` +go build -o ctask.exe . +./ctask.exe list --help +``` + +Expected: the flag list now shows `--projects`. + +- [ ] **Step 4: Commit.** + +```bash +git add cmd/list.go +git commit -m "feat(v0.3): add --projects flag to ctask list" +``` + +--- + +## Phase 8: Status-line type awareness + +### Task 13: Update both status-line helpers to indicate project mode + +**Files:** +- Modify: `scripts/ctask-statusline.sh` +- Modify: `scripts/ctask-statusline.ps1` + +Design choice: keep the prefix unchanged for backward compat (`(ctask:slug|mode)`), and append `|project` only when `CTASK_TYPE=project`. Tasks are unchanged. + +- [ ] **Step 1: Update the bash helper.** + +Replace `scripts/ctask-statusline.sh` with: + +```bash +#!/usr/bin/env bash +# ctask status line helper for Claude Code +# Reads ctask environment variables and prints a formatted context string. +# Output (task): (ctask:|) +# Output (project): (ctask:||project) +# Outputs nothing when not in a ctask session. + +[ -z "$CTASK_TASK" ] && exit 0 + +if [ "$CTASK_TYPE" = "project" ]; then + echo "(ctask:${CTASK_TASK}|${CTASK_MODE}|project) ${CTASK_WORKSPACE}" +else + echo "(ctask:${CTASK_TASK}|${CTASK_MODE}) ${CTASK_WORKSPACE}" +fi +``` + +- [ ] **Step 2: Update the PowerShell helper.** + +Replace `scripts/ctask-statusline.ps1` with: + +```powershell +# ctask status line helper for Claude Code +# Reads ctask environment variables and prints a formatted context string. +# Output (task): (ctask:|) +# Output (project): (ctask:||project) +# Outputs nothing when not in a ctask session. + +if (-not $env:CTASK_TASK) { exit 0 } + +if ($env:CTASK_TYPE -eq 'project') { + Write-Output "(ctask:$($env:CTASK_TASK)|$($env:CTASK_MODE)|project) $($env:CTASK_WORKSPACE)" +} else { + Write-Output "(ctask:$($env:CTASK_TASK)|$($env:CTASK_MODE)) $($env:CTASK_WORKSPACE)" +} +``` + +- [ ] **Step 3: Manual smoke test the bash helper.** + +```bash +CTASK_TASK=demo CTASK_MODE=local CTASK_WORKSPACE=/tmp/demo bash scripts/ctask-statusline.sh +# Expected: (ctask:demo|local) /tmp/demo + +CTASK_TASK=demo CTASK_MODE=local CTASK_WORKSPACE=/tmp/demo CTASK_TYPE=project bash scripts/ctask-statusline.sh +# Expected: (ctask:demo|local|project) /tmp/demo + +CTASK_TASK=demo CTASK_MODE=local CTASK_WORKSPACE=/tmp/demo CTASK_TYPE=task bash scripts/ctask-statusline.sh +# Expected: (ctask:demo|local) /tmp/demo +``` + +Record the actual output in the validation report. + +- [ ] **Step 4: Commit.** + +```bash +git add scripts/ctask-statusline.sh scripts/ctask-statusline.ps1 +git commit -m "feat(v0.3): show |project marker in status line when CTASK_TYPE=project" +``` + +--- + +## Phase 9: Doctor seed-directory checks + +### Task 14: Add informational seed directory reporting to `ctask doctor` + +**Files:** +- Modify: `cmd/doctor.go` + +- [ ] **Step 1: Add an informational section after the existing pass/fail checks.** + +Edit `cmd/doctor.go`. After Check 5 (workspace listing) and BEFORE the summary, add: + +```go + // Informational checks (do not affect pass/fail counters). + fmt.Println() + fmt.Println("Seed directories (informational):") + + seedDir := config.ResolveSeedDir() + if info, err := os.Stat(seedDir); err == nil && info.IsDir() { + fmt.Printf(" [INFO] General seed directory: %s (present)\n", seedDir) + } else { + fmt.Printf(" [INFO] General seed directory: %s (not present)\n", seedDir) + } + + projectSeedDir := config.ResolveProjectSeedDir() + if info, err := os.Stat(projectSeedDir); err == nil && info.IsDir() { + fmt.Printf(" [INFO] Project seed directory: %s (present)\n", projectSeedDir) + } else { + fmt.Printf(" [INFO] Project seed directory: %s (not present)\n", projectSeedDir) + } +``` + +- [ ] **Step 2: Build and run doctor.** + +``` +go build -o ctask.exe . +./ctask.exe doctor +``` + +Expected: the existing 5 checks run, then a new "Seed directories (informational):" section. The pass/fail counts at the end should reflect ONLY the original 5 checks, not the seed checks. Confirm by visual inspection. + +- [ ] **Step 3: Commit.** + +```bash +git add cmd/doctor.go +git commit -m "feat(v0.3): add informational seed directory checks to doctor" +``` + +--- + +## Phase 10: Version bump and docs + +### Task 15: Bump version string + +**Files:** +- Modify: `cmd/root.go` + +- [ ] **Step 1: Bump version.** + +Edit `cmd/root.go`: + +```go +var version = "0.3.0" +``` + +- [ ] **Step 2: Build and verify.** + +``` +go build -o ctask.exe . +./ctask.exe --version +``` + +Expected: `ctask v0.3.0` + +- [ ] **Step 3: Commit.** + +```bash +git add cmd/root.go +git commit -m "chore(v0.3): bump version to 0.3.0" +``` + +--- + +### Task 16: Update `docs/commands.md` + +**Files:** +- Modify: `docs/commands.md` + +- [ ] **Step 1: Add `--project` row to the `ctask new` flag table and a new examples block.** + +In the `ctask new` section, add a row to the flags table: + +```markdown +| `--project` | | off | Create a long-lived project workspace (uses CTASK_PROJECT_ROOT if set, runs git init, project CLAUDE.md) | +``` + +Add an examples block: + +```powershell +ctask new --project "billing service" +ctask new --project -c backend "billing service" +``` + +Add a brief explanatory paragraph after the examples explaining seeding order: + +> **Project mode (`--project`):** +> - `task.yaml` records `type: project` +> - Default category becomes `projects` +> - Workspace root falls back to `CTASK_PROJECT_ROOT` if set +> - Built-in CLAUDE.md is the project-oriented template (overridable via seed directories) +> - Seed order: built-in defaults -> general seed (`CTASK_SEED_DIR`) -> project seed (`CTASK_SEED_PROJECT_DIR`) +> - `git init` runs if `git` is on PATH; `.gitignore` (`.ctask/` + `logs/sessions.log`) is created only if no `.gitignore` was provided by a seed +> - If `git` is not available, ctask prints `[ctask] git not found; skipped repository initialization` and continues + +- [ ] **Step 2: Add `--projects` row to the `ctask list` flag table.** + +```markdown +| `--projects` | | off | Show project workspaces instead of task workspaces | +``` + +Update the examples to include `ctask list --projects` and `ctask list --projects --all`. + +Add a paragraph at the bottom of the `ctask list` section: + +> The `--all` flag controls archived visibility only. The `--projects` flag controls type filtering only. They combine: `ctask list --projects --all` lists all projects including archived ones. + +- [ ] **Step 3: Update the Environment Variables section.** + +Add to "ctask exports these into every child session": + +```markdown +| `CTASK_TYPE` | `task` or `project` | +``` + +Add to "Configure ctask behavior with": + +```markdown +| `CTASK_PROJECT_ROOT` | (none) | Workspace root for projects. When set, project workspaces are created directly under this path. | +| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` (Unix) or `%APPDATA%\ctask\seed\` (Windows) | General user seed directory copied into every new workspace. | +| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` (Unix) or `%APPDATA%\ctask\seed-project\` (Windows) | Additional seed directory copied only for project workspaces (overlay on top of the general seed). | +``` + +- [ ] **Step 4: Update the doctor section.** + +In the `ctask doctor` section, add a note: + +> v0.3 also reports the resolved general and project seed directory paths as informational lines (`[INFO]`). These do not affect the pass/fail count. + +- [ ] **Step 5: Commit.** + +```bash +git add docs/commands.md +git commit -m "docs(v0.3): document --project, --projects, seed dirs, CTASK_TYPE" +``` + +--- + +## Phase 11: Final test pass and validation + +### Task 17: Run the entire test suite and capture any gaps + +- [ ] **Step 1: Run the full Go test suite with verbose output.** + +Run: `go test ./... -v -count=1` +Expected: All tests pass. + +If anything fails: stop and fix before proceeding to the smoke tests. Do not paper over failures. + +- [ ] **Step 2: Run `go vet` and `go build`.** + +Run: `go vet ./...` +Expected: no warnings. + +Run: `go build ./...` +Expected: no errors. + +- [ ] **Step 3: Record results in the validation report.** + +--- + +### Task 18: Manual smoke tests + +Capture exact commands and outputs for the validation report. Use a throwaway `CTASK_ROOT` so the user's real workspace tree is untouched. + +**Setup:** +```bash +SMOKE_ROOT="$(mktemp -d)/ctask-smoke" +mkdir -p "$SMOKE_ROOT" +go build -o ctask.exe . +``` + +- [ ] **Smoke 1: plain task with no seed dirs** + +``` +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR=/nonexistent ./ctask.exe new --no-launch "plain task" +ls "$SMOKE_ROOT/general/"*plain-task* +cat "$SMOKE_ROOT/general/"*plain-task*/CLAUDE.md +cat "$SMOKE_ROOT/general/"*plain-task*/task.yaml +``` + +Verify: `type: task`, CLAUDE.md is the v0.3 task default. + +- [ ] **Smoke 2: seeded task** + +``` +SEED="$(mktemp -d)/seed" +mkdir -p "$SEED/docs" +echo "user CLAUDE override" > "$SEED/CLAUDE.md" +echo "doc body" > "$SEED/docs/intro.md" +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR="$SEED" ./ctask.exe new --no-launch "seeded task" +cat "$SMOKE_ROOT/general/"*seeded-task*/CLAUDE.md +cat "$SMOKE_ROOT/general/"*seeded-task*/docs/intro.md +``` + +Verify: CLAUDE.md is the user override; `docs/intro.md` exists. + +- [ ] **Smoke 3: project with no project seed** + +``` +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR=/nonexistent CTASK_SEED_PROJECT_DIR=/nonexistent \ + ./ctask.exe new --no-launch --project "sample project" +ls "$SMOKE_ROOT/projects/"*sample-project* +cat "$SMOKE_ROOT/projects/"*sample-project*/CLAUDE.md +cat "$SMOKE_ROOT/projects/"*sample-project*/.gitignore +ls -la "$SMOKE_ROOT/projects/"*sample-project*/.git +cat "$SMOKE_ROOT/projects/"*sample-project*/task.yaml | grep '^type:' +``` + +Verify: project CLAUDE.md, `.gitignore` created with `.ctask/` and `logs/sessions.log`, `.git` exists, `type: project`. + +- [ ] **Smoke 4: project with both seed dirs** + +``` +GS="$(mktemp -d)/general-seed" +PS="$(mktemp -d)/project-seed" +mkdir -p "$GS" "$PS" +echo "general claude" > "$GS/CLAUDE.md" +echo "general only" > "$GS/general-only.txt" +echo "project claude" > "$PS/CLAUDE.md" +echo "project only" > "$PS/project-only.txt" + +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR="$GS" CTASK_SEED_PROJECT_DIR="$PS" \ + ./ctask.exe new --no-launch --project "seeded project" +cat "$SMOKE_ROOT/projects/"*seeded-project*/CLAUDE.md # expect: project claude +cat "$SMOKE_ROOT/projects/"*seeded-project*/general-only.txt +cat "$SMOKE_ROOT/projects/"*seeded-project*/project-only.txt +``` + +- [ ] **Smoke 5: list filtering** + +``` +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list # only tasks +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list --projects # only projects +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list --all # tasks incl archived +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list --projects --all # projects incl archived +``` + +Verify the right rows appear in each call. + +- [ ] **Smoke 6: resume / open task and project** + +Skip launching the agent (use `--shell` and immediately exit) or just verify the sessions start. Use `--shell` and `exit` to keep it short. + +``` +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe resume plain --shell <<< "exit" +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe resume sample --shell <<< "exit" +``` + +- [ ] **Smoke 7: status line output** + +``` +CTASK_TASK=demo CTASK_MODE=local CTASK_WORKSPACE=/tmp/demo bash scripts/ctask-statusline.sh +CTASK_TASK=demo CTASK_MODE=local CTASK_WORKSPACE=/tmp/demo CTASK_TYPE=project bash scripts/ctask-statusline.sh +``` + +- [ ] **Smoke 8: doctor seed reporting** + +``` +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR=/nonexistent CTASK_SEED_PROJECT_DIR=/nonexistent ./ctask.exe doctor +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR="$GS" CTASK_SEED_PROJECT_DIR="$PS" ./ctask.exe doctor +``` + +Confirm: pass/fail count is unchanged across the two runs; `[INFO]` lines reflect `(present)` vs `(not present)`. + +- [ ] **Smoke 9: backward compatibility — legacy workspace without `type`** + +Create a v0.2-style task.yaml manually (no type field) and verify resume/list/info still work: + +```bash +LEG="$SMOKE_ROOT/general/2026-04-01_legacy" +mkdir -p "$LEG/context" "$LEG/output" "$LEG/logs" +cat > "$LEG/task.yaml" <<'YAML' +id: legacy +slug: legacy +title: legacy task +created_at: 2026-04-01T00:00:00Z +updated_at: 2026-04-01T00:00:00Z +status: active +category: general +mode: local +agent: claude +workspace_path: /tmp/legacy +archived_at: null +YAML + +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe info legacy +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list | grep legacy # should appear (default = tasks) +CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe list --projects | grep legacy # should NOT appear +``` + +- [ ] **Smoke 10: project-root no double `projects/projects`** + +``` +CPR="$(mktemp -d)/projects-root" +mkdir -p "$CPR" +CTASK_PROJECT_ROOT="$CPR" CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe new --no-launch --project "proj root test" +ls "$CPR" +test ! -d "$CPR/projects" || echo "FAIL: doubled projects/ folder" +``` + +- [ ] **Smoke 11: project-root with explicit category** + +``` +CTASK_PROJECT_ROOT="$CPR" CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe new --no-launch --project -c backend "proj cat test" +ls "$CPR/backend" +``` + +- [ ] **Smoke 12: seeded `.gitignore` is preserved** + +``` +PS2="$(mktemp -d)/proj-seed-gi" +mkdir -p "$PS2" +echo "# user gitignore" > "$PS2/.gitignore" +echo "node_modules/" >> "$PS2/.gitignore" + +CTASK_ROOT="$SMOKE_ROOT" CTASK_SEED_DIR=/nonexistent CTASK_SEED_PROJECT_DIR="$PS2" \ + ./ctask.exe new --no-launch --project "gi preserve" +cat "$SMOKE_ROOT/projects/"*gi-preserve*/.gitignore +# expect: # user gitignore + node_modules/ (NOT the minimal default) +``` + +- [ ] **Smoke 13: git unavailable path (best-effort)** + +If practical, run with `PATH` stripped of git: + +``` +PATH=/usr/bin CTASK_ROOT="$SMOKE_ROOT" ./ctask.exe new --no-launch --project "no git" +# Expect: "[ctask] git not found; skipped repository initialization" +# .gitignore should still be created. +ls "$SMOKE_ROOT/projects/"*no-git*/ +``` + +If you cannot strip git from PATH on the host, note this as untested in the validation report. + +- [ ] **Cleanup:** Remove smoke artifacts. + +```bash +rm -rf "$SMOKE_ROOT" "$SEED" "$GS" "$PS" "$PS2" "$CPR" +``` + +- [ ] **Step: Write the validation report into the conversation summary.** + +The report must include: +1. Exact commands run +2. Test results (pass/fail per phase) +3. Smoke results (per smoke item, expected vs actual) +4. Any limitations (e.g. "git unavailable path not exercised on this host") +5. Any spec gaps or follow-ups for v0.4 + +--- + +## Acceptance Checklist + +When all tasks are complete, confirm each item before reporting success: + +- [ ] All v0.2 commands still work (smoke 6 + smoke 9) +- [ ] General seed dir overlay works (smoke 2) +- [ ] Project seed overlay applies in the spec order (smoke 4) +- [ ] Built-in task and project CLAUDE.md content matches v0.3 (Task 1, 2 tests + smoke 1, 3) +- [ ] `--project` works (smoke 3) +- [ ] `type` is stored and read correctly (Task 5 tests + smoke 3) +- [ ] Old workspaces without `type` still work as tasks (smoke 9) +- [ ] `CTASK_PROJECT_ROOT` semantics (smoke 10, 11) +- [ ] `git init` behavior is correct and non-destructive (smoke 3, 12, 13) +- [ ] `.gitignore` from seed is preserved (smoke 12) +- [ ] `list --projects` matches the spec (smoke 5) +- [ ] `CTASK_TYPE` exported (Task 8 tests; visible in smoke 6 if shell prints env) +- [ ] Status-line helpers remain readable (Task 13 manual + smoke 7) +- [ ] `doctor` seed reporting is informational only (Task 14 + smoke 8) +- [ ] All Go tests pass +- [ ] Plan was followed in order; no skipped tasks diff --git a/v0.3-spec.md b/v0.3-spec.md new file mode 100644 index 0000000..7c8133f --- /dev/null +++ b/v0.3-spec.md @@ -0,0 +1,322 @@ +# ctask v0.3 Feature Spec — Seed Templates & Project Mode + +## Theme + +v0.1 = create and resume workspaces +v0.2 = understand and continue workspaces later +v0.3 = personalize workspaces and support longer-lived project work + +## Scope + +Three deliverables: + +1. User seed directory +2. Improved default CLAUDE.md with file placement conventions +3. Project mode via `--project` flag + +## 1. User Seed Directory + +### Purpose + +Users don't reliably customize CLAUDE.md or workspace structure by hand. A seed directory lets users define their preferred starting files once and have them applied to every new workspace automatically. + +### How it works + +On `ctask new`, after creating the workspace directory: + +1. Write ctask's built-in default seed files (CLAUDE.md, notes.md) +2. Check if a user seed directory exists +3. If it exists, copy its contents into the workspace, overwriting any built-in defaults + +User seed wins. If the user provides their own CLAUDE.md, it replaces the built-in one entirely. If the user provides files that don't exist in the defaults (e.g., `.cursorrules`, `mcp-config.json`, a `docs/` directory), those are added. + +### Seed directory location + +| Platform | Path | +|----------|------| +| Windows | `%APPDATA%\ctask\seed\` | +| Linux | `~/.config/ctask/seed/` | +| macOS | `~/.config/ctask/seed/` | + +The location can be overridden via `CTASK_SEED_DIR` environment variable. + +### What goes in the seed directory + +Anything. ctask copies the entire contents recursively. Common examples: + +- `CLAUDE.md` — personalized agent instructions, MCP config notes, coding conventions +- `notes.md` — custom notes template +- `.cursorrules` — for Cursor users +- `docs/` — pre-created documentation directory +- `reference/` — standard reference docs the user wants in every workspace + +### Rules + +- ctask does not validate seed contents. It copies files, nothing more. +- Seed files are only applied on `ctask new`. Resuming an existing workspace does not re-apply seeds. +- If the seed directory does not exist, ctask uses built-in defaults silently. No warning. +- Subdirectories in the seed directory are preserved (copied recursively). +- `task.yaml` in the seed directory is ignored if present — ctask always generates its own. +- `.ctask/` in the seed directory is ignored if present. + +### Project-specific seeds + +When `--project` is used (see §3), ctask checks for a second seed directory: + +| Platform | Path | +|----------|------| +| Windows | `%APPDATA%\ctask\seed-project\` | +| Linux | `~/.config/ctask/seed-project/` | +| macOS | `~/.config/ctask/seed-project/` | + +Override via `CTASK_SEED_PROJECT_DIR`. + +Resolution order for `--project`: + +1. Write built-in **project** defaults (project-oriented CLAUDE.md and notes.md — not the generic task defaults) +2. Apply general user seed on top (if exists) +3. Apply project seed on top (if exists) + +This means the starting point for a project workspace is always the built-in project CLAUDE.md (see §3), not the generic task CLAUDE.md. The general seed can then customize it, and the project seed can further specialize it. If no seed directories exist, the built-in project defaults are used as-is. + +### Examples + +```bash +# User has a custom seed +ls ~/.config/ctask/seed/ + CLAUDE.md + notes.md + docs/ + +# Every new workspace gets those files +ctask new "api cleanup" + +# User has a project-specific seed too +ls ~/.config/ctask/seed-project/ + CLAUDE.md # richer, project-oriented version + src/ + docs/ + .gitignore + +# Project workspaces get the project seed +ctask new --project "billing service" +``` + +## 2. Improved Default CLAUDE.md + +### Purpose + +The built-in CLAUDE.md should be useful out of the box, even if the user never creates a seed directory. The current version is too bare — it gives general workspace scope guidance but says nothing about file organization. + +### New default CLAUDE.md content + +```markdown +# Workspace Guidelines + +This is a ctask workspace. Prefer operating inside this directory unless explicitly instructed otherwise. + +## File Placement + +- Source code and scripts → workspace root or `src/` +- Documentation, summaries, reports → `docs/` +- Deliverables and exports → `output/` +- Reference material and imported files → `context/` +- Do not place non-code outputs (docs, summaries, exports) in the workspace root + +## Conventions + +- Do not install global packages or modify system files unless asked +- Record important assumptions and actions in notes.md +- Keep the workspace root clean — use subdirectories for organization + +## Session Handoff + +Before ending a session, append a brief summary to notes.md with: + +- What was accomplished +- Key decisions made +- Open follow-ups or unfinished work +- How to continue from here + +Keep it concise — a few bullet points is enough. +``` + +### Rules + +- This is the built-in default. Users override it via the seed directory. +- The session handoff section from v0.2 is preserved. +- File placement conventions are advisory. The agent may or may not follow them, but having them is significantly better than not. + +## 3. Project Mode + +### Purpose + +Support longer-lived project work using the same ctask workflow. A project workspace is not fundamentally different from a task workspace — it has the same lifecycle commands, session logging, and metadata. What changes is the default structure, expected longevity, and optionally the workspace root. + +### How it works + +Add a `--project` flag to `ctask new`: + +```bash +ctask new --project "billing service" +ctask new --project -c backend "billing service" +``` + +### What `--project` changes + +| Aspect | Task (default) | Project | +|--------|---------------|---------| +| `task.yaml` type field | `type: task` | `type: project` | +| Default category | `general` | `projects` | +| Seed source | general seed | general seed + project seed overlay | +| Workspace root | `$CTASK_ROOT` | `$CTASK_PROJECT_ROOT` (falls back to `$CTASK_ROOT`) | +| Git initialization | no | yes, `git init` if git is available | +| Default CLAUDE.md | standard | project-oriented (from project seed, or built-in project default) | + +### New metadata field + +Add `type` to `task.yaml`: + +```yaml +type: "project" # task | project +``` + +Existing workspaces without a `type` field are treated as `task` (backward compatible). + +**Implementation note:** every code path that reads `type` from `task.yaml` must handle the missing-field case consistently: `list`, `resume`, `info`, `last`, `delete`, status line display, and any future filtering. Default to `"task"` when the field is absent or empty. Do not require a migration step for existing workspaces. + +### Separate project root (optional) + +Users who want projects in a different location can set: + +``` +CTASK_PROJECT_ROOT=~/projects +``` + +When `CTASK_PROJECT_ROOT` is set, project workspaces are created directly under it: `$CTASK_PROJECT_ROOT/_`. The default `projects` category is **not** appended — the user has already chosen a dedicated location. If the user explicitly passes `-c `, that category subdirectory is used within the project root. + +When `CTASK_PROJECT_ROOT` is **not** set, projects go under `$CTASK_ROOT/projects/_` using the default `projects` category. + +### Git initialization + +When `--project` is used and `git` is on PATH: + +1. Run `git init` in the workspace directory +2. Create a minimal `.gitignore` (ignore `.ctask/`, `logs/sessions.log`) +3. If git is not available, do not error — print an informational note: + `[ctask] git not found; skipped repository initialization` + +### Filtering + +`ctask list` gains a `--projects` flag: + +```bash +ctask list # active tasks only (type: task, or no type field) +ctask list --projects # active projects only (type: project) +ctask list --all # active tasks including archived tasks +ctask list --projects --all # active projects including archived projects +``` + +The `--all` flag controls archived visibility only. The `--projects` flag controls type filtering only. They combine naturally: `--projects --all` means "all projects, including archived ones." + +`ctask resume`, `ctask open`, `ctask info` search across both tasks and projects by default. No type filtering needed — if the user knows the name, they find it. + +### Built-in project CLAUDE.md default + +If no project seed directory exists, ctask uses a built-in project-oriented CLAUDE.md: + +```markdown +# Project Workspace Guidelines + +This is a ctask project workspace — a long-lived working environment, not a disposable task. + +## File Placement + +- Source code → `src/` or workspace root +- Documentation → `docs/` +- Deliverables and exports → `output/` +- Reference material → `context/` +- Tests → `tests/` +- Configuration files → workspace root +- Do not place non-code outputs in the workspace root + +## Conventions + +- This project uses git. Commit meaningful changes with clear messages. +- Do not install global packages or modify system files unless asked. +- Record important assumptions and actions in notes.md. +- Keep the workspace root clean. + +## Session Handoff + +Before ending a session, append a brief summary to notes.md with: + +- What was accomplished +- Key decisions made +- Open follow-ups or unfinished work +- How to continue from here +``` + +### Examples + +```bash +# Create a project (no CTASK_PROJECT_ROOT set) +ctask new --project "billing service" +# → ~/ai-workspaces/projects/2026-04-10_billing-service/ +# → git initialized, project CLAUDE.md seeded + +# Create a project in a separate root +CTASK_PROJECT_ROOT=~/projects ctask new --project "billing service" +# → ~/projects/2026-04-10_billing-service/ +# (no category subdirectory — CTASK_PROJECT_ROOT is the final root) + +# Create a project in a separate root with explicit category +CTASK_PROJECT_ROOT=~/projects ctask new --project -c backend "billing service" +# → ~/projects/backend/2026-04-10_billing-service/ + +# List only projects +ctask list --projects + +# Resume works the same +ctask resume billing-service +``` + +## Non-Goals for v0.3 + +- No template inheritance or multi-layer seed precedence beyond general + project +- No `ctask init-seed` scaffolding command (users create the seed directory manually) +- No subtask / subfolder command +- No automatic seed updates to existing workspaces +- No per-category seed directories (only general and project) +- No container mode (still deferred) + +## Environment Variables (new in v0.3) + +### Read by ctask (user-configurable input) + +| Variable | Default | Description | +|----------|---------|-------------| +| `CTASK_SEED_DIR` | `~/.config/ctask/seed/` (Unix) or `%APPDATA%\ctask\seed\` (Windows) | General seed directory | +| `CTASK_SEED_PROJECT_DIR` | `~/.config/ctask/seed-project/` (Unix) or `%APPDATA%\ctask\seed-project\` (Windows) | Project seed directory | +| `CTASK_PROJECT_ROOT` | Falls back to `$CTASK_ROOT` | Workspace root for projects | + +### Set by ctask (exported into sessions) + +| Variable | Value | +|----------|-------| +| `CTASK_TYPE` | `task` or `project` | + +Added to the existing set of exported variables (`CTASK_TASK`, `CTASK_MODE`, `CTASK_ROOT`, `CTASK_WORKSPACE`, `CTASK_CATEGORY`). + +## Build Order + +1. Improved default CLAUDE.md (smallest change, immediate value) +2. User seed directory support in `ctask new` +3. Project seed directory support +4. `--project` flag with type field, default category, and project root +5. Git initialization for project mode +6. `--projects` filter in `ctask list` +7. `CTASK_TYPE` export +8. Update status line helper to show type when in a project +9. Update `ctask doctor` to check seed directory existence (informational, not a failure) +10. Testing and validation