diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..911a535 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,73 @@ +{ + "permissions": { + "allow": [ + "Bash(go version:*)", + "Bash(cmd.exe:*)", + "Bash(export PATH=\"$PATH:/c/Program Files/Go/bin:/c/Users/Warren/go/bin\")", + "Read(//c/Program Files/Go/**)", + "Read(//c/Users/Warren/go/**)", + "Bash(rustc --version)", + "Bash(powershell.exe:*)", + "Bash(export PATH=\"$PATH:/c/Program Files/Go/bin\")", + "Bash(go mod:*)", + "Bash(go test:*)", + "Bash(go get:*)", + "Bash(go build:*)", + "Bash(./ctask.exe --version)", + "Bash(./ctask.exe --help)", + "Bash(git init:*)", + "Bash(git add:*)", + "Bash(git commit -m ':*)", + "Bash(./ctask.exe new:*)", + "Bash(./ctask.exe list:*)", + "Bash(./ctask.exe resume:*)", + "Bash(./ctask.exe open:*)", + "Bash(./ctask.exe info:*)", + "Read(//tmp/**)", + "Bash(export CTASK_ROOT)", + "Bash(./ctask.exe archive:*)", + "Bash(echo \"exit: $?\")", + "Bash(CTASK_ROOT=$\\(ls -d /tmp/tmp.SbUlBO0N9l\\) ./ctask.exe resume --agent \"nonexistent_agent_12345\" task-183813)", + "Bash(CTASK_ROOT=$\\(ls -d /tmp/tmp.SbUlBO0N9l\\) ./ctask.exe resume --agent \"bad_agent\" task-183813)", + "Bash(go env:*)", + "Bash(go install:*)", + "Bash(mkdir -p /c/Users/Warren/go/bin)", + "Bash(cp /c/Users/Warren/claude_tasks/ctask_v0.1/scripts/ctask-statusline.ps1 /c/Users/Warren/go/bin/ctask-statusline.ps1)", + "Bash(cp /c/Users/Warren/claude_tasks/ctask_v0.1/scripts/ctask-statusline.sh /c/Users/Warren/go/bin/ctask-statusline.sh)", + "Bash(ls -la /c/Users/Warren/go/bin/ctask-statusline.*)", + "Bash(export CTASK_TASK=\"manual-test\")", + "Bash(export CTASK_MODE=\"local\")", + "Bash(export CTASK_WORKSPACE=\"C:\\\\\\\\Users\\\\\\\\Warren\\\\\\\\ai-workspaces\\\\\\\\general\\\\\\\\2026-04-05_manual-test\")", + "Bash(unset CTASK_TASK CTASK_MODE CTASK_WORKSPACE)", + "Skill(update-config)", + "Bash(command -v powershell)", + "Bash(command -v powershell.exe)", + "Bash(pwsh -NoProfile -Command '$PSVersionTable.PSVersion')", + "Bash(export CTASK_TASK=\"manual-test-2\")", + "Bash(export CTASK_WORKSPACE=\"C:\\\\\\\\Users\\\\\\\\Warren\\\\\\\\ai-workspaces\\\\\\\\general\\\\\\\\2026-04-05_manual-test-2\")", + "Bash(bash -c 'C:\\\\WINDOWS\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe -NoProfile -File C:\\\\Users\\\\Warren\\\\go\\\\bin\\\\ctask-statusline.ps1')", + "Bash(bash -c 'powershell -NoProfile -File C:\\\\Users\\\\Warren\\\\go\\\\bin\\\\ctask-statusline.ps1')", + "Bash(bash -c 'powershell -NoProfile -File /c/Users/Warren/go/bin/ctask-statusline.ps1')", + "Bash(CTASK_TASK=\"manual-test-2\" CTASK_MODE=\"local\" CTASK_WORKSPACE=\"C:\\\\\\\\Users\\\\\\\\Warren\\\\\\\\ai-workspaces\\\\\\\\general\\\\\\\\2026-04-05_manual-test-2\" bash /c/Users/Warren/go/bin/ctask-statusline.sh)", + "Bash(bash /c/Users/Warren/go/bin/ctask-statusline.sh)", + "Bash(ls /c/Users/Warren/claude_tasks/ctask_v0.1/*v0.2* /c/Users/Warren/claude_tasks/ctask_v0.1/*spec*)", + "Bash(./ctask.exe doctor:*)", + "Bash(./ctask.exe last:*)", + "Bash(./ctask.exe delete:*)", + "Bash(export PATH=\"$PATH:/c/Users/Warren/go/bin\")", + "Bash(mktemp -d)", + "Bash(export CTASK_ROOT=$\\(mktemp -d\\))", + "Bash(ctask --version)", + "Bash(ctask --help)", + "Bash(ctask new:*)", + "Bash(ctask list:*)", + "Bash(ctask info:*)", + "Bash(ctask archive:*)", + "Bash(ctask resume:*)", + "Bash(echo \"missing arg exit: $?\")", + "Bash(ctask last:*)", + "Bash(ctask doctor:*)", + "Bash(ctask delete:*)" + ] + } +} diff --git a/cli-spec-v0.1.md b/cli-spec-v0.1.md new file mode 100644 index 0000000..c153837 --- /dev/null +++ b/cli-spec-v0.1.md @@ -0,0 +1,404 @@ +# ctask CLI Specification (v0.1) + +## Global Options + +``` +ctask [command] [options] [arguments] +ctask -h | --help +ctask --version +``` + +## Commands + +--- + +### `ctask new [title]` + +Create a new task workspace and launch the agent. + +``` +ctask new [title] [options] +``` + +**Arguments:** + +- `title` — Human-readable task name (optional). If omitted, generates `task-HHMMSS`. + +**Options:** + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--category` | `-c` | `general` | Workspace category subdirectory | +| `--container` | | off | Launch in container sandbox (v0.2 — prints notice in v0.1) | +| `--shell` | | off | Open interactive shell instead of agent | +| `--agent` | `-a` | `claude` | Command to exec as the agent | +| `--no-launch` | | off | Create workspace only, do not launch | + +**Behavior:** + +- `new` always creates a fresh workspace. If a workspace with the same date+slug exists, append `-2`, `-3`, etc. +- Slugs are derived from the title: lowercase, non-alphanumeric replaced with hyphens, trimmed +- Workspace created at `$CTASK_ROOT//_/` +- Seed files written only if they do not already exist + +**Examples:** + +```bash +# Basic — creates workspace, launches claude +ctask new "arch notes" + +# With category +ctask new -c scripts "backup helper" + +# Container mode +ctask new --container -c risky "unknown installer" + +# Shell instead of agent +ctask new --shell "test env" + +# Create workspace but don't launch anything +ctask new --no-launch "json cleanup" + +# Use aider instead of claude +ctask new --agent aider "refactor api" + +# Auto-generated name +ctask new +``` + +**Output:** + +``` +[ctask] created general/2026-04-05_arch-notes +[ctask] local :: arch-notes +[ctask] ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +--- + +### `ctask list` + +Show recent workspaces in reverse-chronological order. + +``` +ctask list [options] +``` + +**Options:** + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--all` | `-a` | off | Include archived workspaces | +| `--category` | `-c` | all | Filter by category | +| `--limit` | `-n` | 20 | Maximum entries to show | + +**Output format:** + +``` +active local general 2026-04-05 arch-notes +active container risky 2026-04-04 unknown-installer +active local scripts 2026-04-03 backup-helper +``` + +Columns: status, mode, category, date, slug. + +**Examples:** + +```bash +# Recent workspaces +ctask list + +# Only scripts category +ctask list -c scripts + +# Include archived +ctask list --all + +# Last 5 +ctask list -n 5 +``` + +--- + +### `ctask resume ` + +Reopen an existing workspace and launch the agent. + +``` +ctask resume [options] +``` + +**Arguments:** + +- `query` — Workspace slug, partial name, or full directory name (required) + +**Options:** + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--container` | | off | Resume in container mode (v0.2 — prints notice in v0.1) | +| `--shell` | | off | Open shell instead of agent | +| `--agent` | `-a` | from task.yaml | Override agent command | + +**Resolution:** + +1. Exact directory name match under `$CTASK_ROOT/*/*` → use it +2. Exact slug match (portion after date prefix) → use it +3. Case-insensitive substring match against slug → use if unique +4. Multiple matches → print all matches and exit 1 +5. No matches → error and exit 1 + +Archived workspaces are excluded from matching by default. + +**Behavior:** + +- Updates `updated_at` in `task.yaml` +- Sets environment variables +- Prints launch banner +- Execs agent or shell + +**Examples:** + +```bash +# Resume by slug +ctask resume arch-notes + +# Resume by partial match +ctask resume backup + +# Resume by full directory name +ctask resume 2026-04-05_arch-notes + +# Resume in container mode (even if originally local) +ctask resume --container arch-notes + +# Resume with shell +ctask resume --shell arch-notes +``` + +**On multiple matches:** + +``` +Multiple workspaces match "notes": + general/2026-04-05_arch-notes + research/2026-04-03_migration-notes +Specify a more precise query. +``` + +--- + +### `ctask open ` + +Open a workspace directory without launching the agent. + +``` +ctask open [options] +``` + +**Options:** + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--all` | `-a` | off | Include archived workspaces in query resolution | + +**Behavior:** + +- Resolves workspace (same rules as `resume`) +- Updates `updated_at` +- Sets environment variables +- Spawns a new interactive subshell in the workspace directory with ctask prompt prefix +- Does NOT launch the agent +- Does NOT mutate the caller's existing shell session — this is a subshell, not a `cd` + +This is equivalent to `ctask resume --shell ` but reads more naturally for "I just want to look at the files." + +**Examples:** + +```bash +# Open workspace in shell +ctask open arch-notes +``` + +--- + +### `ctask info ` + +Display metadata and path for a workspace without entering it. + +``` +ctask info [options] +``` + +**Options:** + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--all` | `-a` | off | Include archived workspaces in query resolution | + +**Output:** + +``` +Task: arch-notes +Title: arch notes +Category: general +Status: active +Mode: local +Agent: claude +Created: 2026-04-05 14:30:22 +Updated: 2026-04-05 15:12:00 +Path: ~/ai-workspaces/general/2026-04-05_arch-notes + +Contents: + task.yaml + CLAUDE.md + notes.md + context/ + output/ + logs/ +``` + +**Examples:** + +```bash +ctask info arch-notes +ctask info backup +``` + +--- + +### `ctask archive ` + +Mark a workspace as archived. + +``` +ctask archive +``` + +**Behavior:** + +- Resolves workspace (same rules as `resume`) +- Sets `status: archived` and `archived_at` timestamp in `task.yaml` +- Workspace stays in place (not moved or deleted) +- Archived workspaces are hidden from `ctask list` unless `--all` is passed + +**Examples:** + +```bash +ctask archive arch-notes +ctask archive 2026-04-03_backup-helper +``` + +**Output:** + +``` +[ctask] archived: general/2026-04-05_arch-notes +``` + +--- + +## Environment Variables + +### Set by ctask (exported into every child session) + +| Variable | Example | Description | +|----------|---------|-------------| +| `CTASK_TASK` | `arch-notes` | Task slug | +| `CTASK_MODE` | `local` | Execution mode | +| `CTASK_ROOT` | `/home/warren/ai-workspaces` | Resolved workspace root (absolute path) | +| `CTASK_WORKSPACE` | `/home/warren/ai-workspaces/general/2026-04-05_arch-notes` | Full workspace path | +| `CTASK_CATEGORY` | `general` | Category | + +### Read by ctask (user-configurable input) + +| Variable | Default | Description | +|----------|---------|-------------| +| `CTASK_ROOT` | `~/ai-workspaces` (Unix) or `%USERPROFILE%\ai-workspaces` (Windows) | Workspace root directory | +| `CTASK_AGENT` | `claude` | Default agent command | + +**Implementation note on `CTASK_ROOT`:** ctask reads the caller's `CTASK_ROOT` as config input (may contain `~` or relative path), resolves it to an absolute path, then exports the resolved value into the child process environment. The child session always sees a fully resolved absolute path. + +--- + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (multiple matches, not found, invalid args) | +| 2 | Missing required argument | +| 127 | Agent command not found | + +--- + +## Shell Prompt Behavior + +### In `--shell` mode + +Ephemeral `PS1` prefix, no permanent config changes: + +``` +(ctask:arch-notes|local) warren@host:~/ai-workspaces/general/2026-04-05_arch-notes$ +``` + +### In agent mode + +Banner printed before exec: + +``` +[ctask] local :: arch-notes +[ctask] ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +--- + +## Container Mode (Deferred to v0.2) + +The `--container` flag is accepted but prints a notice that container mode is not yet available in v0.1. This keeps the CLI interface stable so users and scripts don't need to change when container support lands. + +``` +[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup. +``` + +Design intent for v0.2: persistent named container, Podman-first (Docker fallback), Claude Code only inside container. See MVP contract for details. + +--- + +## Status Line Integration + +### Claude Code status line + +ctask ships a small helper script for Claude Code's status line feature. When ctask environment variables are set, it renders session context at the bottom of the Claude Code UI. + +**Setup** (add to `~/.claude/settings.json`): + +```json +{ + "statusLine": { + "type": "command", + "command": "bash /path/to/ctask-statusline.sh" + } +} +``` + +On Windows, use the PowerShell variant: + +```json +{ + "statusLine": { + "type": "command", + "command": "powershell -NoProfile -File C:\\path\\to\\ctask-statusline.ps1" + } +} +``` + +**Output when in a ctask session:** + +``` +(ctask:arch-notes|local) ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +**Output when NOT in a ctask session:** empty (no output, falls through gracefully). + +### Fallback for non-Claude agents + +The ephemeral shell prompt prefix (`PS1` on Unix, `PROMPT` on Windows) in `--shell` mode provides session context for any tool that doesn't support a dedicated status line. diff --git a/docs/superpowers/plans/2026-04-05-ctask-v0.1.md b/docs/superpowers/plans/2026-04-05-ctask-v0.1.md new file mode 100644 index 0000000..57c54ee --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-ctask-v0.1.md @@ -0,0 +1,2623 @@ +# ctask v0.1 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:** Build a cross-platform CLI that creates and manages named AI-agent task workspaces with consistent structure, visible session identity, environment context injection, and agent-agnostic launching. + +**Architecture:** Single Go binary using cobra for CLI parsing. Internal packages: `config` (path/env resolution), `workspace` (metadata model, CRUD, query resolution), `shell` (platform-specific launch helpers), `seed` (template files). Commands are thin wrappers that call workspace/shell functions. + +**Tech Stack:** Go 1.26, cobra (CLI framework), gopkg.in/yaml.v3 (YAML parsing), standard library for everything else. + +--- + +## File Structure + +``` +ctask_v0.1/ +├── go.mod +├── go.sum +├── main.go # Entrypoint, calls cmd.Execute() +├── cmd/ +│ ├── root.go # Root cobra command, global flags, version +│ ├── new.go # ctask new +│ ├── list.go # ctask list +│ ├── resume.go # ctask resume +│ ├── open.go # ctask open +│ ├── info.go # ctask info +│ └── archive.go # ctask archive +├── internal/ +│ ├── config/ +│ │ └── config.go # CTASK_ROOT, CTASK_AGENT resolution, defaults +│ ├── workspace/ +│ │ ├── metadata.go # TaskMeta struct, YAML read/write +│ │ ├── slug.go # Slug generation, collision suffixing +│ │ ├── create.go # Workspace creation (dirs + seed files) +│ │ ├── query.go # Query resolution (5-step algorithm) +│ │ └── list.go # Scan and list workspaces +│ ├── shell/ +│ │ └── launch.go # Platform-specific shell/agent exec +│ └── seed/ +│ └── templates.go # CLAUDE.md, notes.md content templates +├── internal/config/config_test.go +├── internal/workspace/slug_test.go +├── internal/workspace/metadata_test.go +├── internal/workspace/create_test.go +├── internal/workspace/query_test.go +├── internal/workspace/list_test.go +├── internal/shell/launch_test.go +├── scripts/ +│ ├── ctask-statusline.sh # Bash status line helper +│ └── ctask-statusline.ps1 # PowerShell status line helper +└── docs/ + └── status-line-setup.md # Setup instructions for Claude Code +``` + +--- + +### Task 1: Project Initialization and Config Package + +**Files:** +- Create: `go.mod` +- Create: `main.go` +- Create: `cmd/root.go` +- Create: `internal/config/config.go` +- Test: `internal/config/config_test.go` + +- [ ] **Step 1: Initialize Go module** + +```bash +cd /c/Users/Warren/claude_tasks/ctask_v0.1 +export PATH="$PATH:/c/Program Files/Go/bin" +go mod init github.com/warrenronsiek/ctask +``` + +- [ ] **Step 2: Write config test** + +Create `internal/config/config_test.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestDefaultRoot(t *testing.T) { + os.Unsetenv("CTASK_ROOT") + root := ResolveRoot() + home, _ := os.UserHomeDir() + expected := filepath.Join(home, "ai-workspaces") + if root != expected { + t.Errorf("expected %q, got %q", expected, root) + } +} + +func TestCustomRoot(t *testing.T) { + dir := t.TempDir() + os.Setenv("CTASK_ROOT", dir) + defer os.Unsetenv("CTASK_ROOT") + root := ResolveRoot() + if root != dir { + t.Errorf("expected %q, got %q", dir, root) + } +} + +func TestRootResolvesRelative(t *testing.T) { + os.Setenv("CTASK_ROOT", "relative/path") + defer os.Unsetenv("CTASK_ROOT") + root := ResolveRoot() + if !filepath.IsAbs(root) { + t.Errorf("expected absolute path, got %q", root) + } +} + +func TestDefaultAgent(t *testing.T) { + os.Unsetenv("CTASK_AGENT") + agent := ResolveAgent() + if agent != "claude" { + t.Errorf("expected \"claude\", got %q", agent) + } +} + +func TestCustomAgent(t *testing.T) { + os.Setenv("CTASK_AGENT", "aider") + defer os.Unsetenv("CTASK_AGENT") + agent := ResolveAgent() + if agent != "aider" { + t.Errorf("expected \"aider\", got %q", agent) + } +} + +func TestRootResolvesTilde(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tilde expansion not typical on Windows") + } + os.Setenv("CTASK_ROOT", "~/my-workspaces") + defer os.Unsetenv("CTASK_ROOT") + root := ResolveRoot() + if root[0] == '~' { + t.Errorf("tilde not expanded: %q", root) + } + if !filepath.IsAbs(root) { + t.Errorf("expected absolute path, got %q", root) + } +} + +func TestEnvVars(t *testing.T) { + vars := EnvVars("my-slug", "local", "/abs/root", "/abs/root/cat/ws", "general") + expected := map[string]string{ + "CTASK_TASK": "my-slug", + "CTASK_MODE": "local", + "CTASK_ROOT": "/abs/root", + "CTASK_WORKSPACE": "/abs/root/cat/ws", + "CTASK_CATEGORY": "general", + } + for k, v := range expected { + if vars[k] != v { + t.Errorf("env %s: expected %q, got %q", k, v, vars[k]) + } + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cd /c/Users/Warren/claude_tasks/ctask_v0.1 +export PATH="$PATH:/c/Program Files/Go/bin" +go test ./internal/config/ -v +``` + +Expected: compilation error — `config` package doesn't exist yet. + +- [ ] **Step 4: Implement config package** + +Create `internal/config/config.go`: + +```go +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// ResolveRoot returns the absolute workspace root path. +// Reads CTASK_ROOT env var, falls back to ~/ai-workspaces. +func ResolveRoot() string { + root := os.Getenv("CTASK_ROOT") + if root == "" { + home, _ := os.UserHomeDir() + return filepath.Join(home, "ai-workspaces") + } + // Expand leading tilde + if strings.HasPrefix(root, "~") { + home, _ := os.UserHomeDir() + root = filepath.Join(home, root[1:]) + } + abs, err := filepath.Abs(root) + if err != nil { + return root + } + return abs +} + +// ResolveAgent returns the agent command. +// Reads CTASK_AGENT env var, falls back to "claude". +func ResolveAgent() string { + agent := os.Getenv("CTASK_AGENT") + if agent == "" { + return "claude" + } + return agent +} + +// EnvVars returns the environment variables to export into child sessions. +func EnvVars(slug, mode, root, workspace, category string) map[string]string { + return map[string]string{ + "CTASK_TASK": slug, + "CTASK_MODE": mode, + "CTASK_ROOT": root, + "CTASK_WORKSPACE": workspace, + "CTASK_CATEGORY": category, + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +go test ./internal/config/ -v +``` + +Expected: all PASS. + +- [ ] **Step 6: Create main.go and root command stub** + +Create `main.go`: + +```go +package main + +import ( + "os" + + "github.com/warrenronsiek/ctask/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} +``` + +Create `cmd/root.go`: + +```go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var version = "0.1.0" + +var rootCmd = &cobra.Command{ + Use: "ctask", + Short: "Create and manage AI-agent task workspaces", + Long: "ctask is a local CLI that creates and manages named AI-agent task workspaces so developers can start, resume, and organize work more safely and predictably.", +} + +func Execute() error { + return rootCmd.Execute() +} + +func init() { + rootCmd.Version = version + rootCmd.SetVersionTemplate(fmt.Sprintf("ctask v%s\n", version)) +} +``` + +- [ ] **Step 7: Install dependencies and verify build** + +```bash +go get github.com/spf13/cobra +go get gopkg.in/yaml.v3 +go mod tidy +go build -o ctask.exe . +``` + +- [ ] **Step 8: Commit** + +```bash +git init +git add go.mod go.sum main.go cmd/root.go internal/config/config.go internal/config/config_test.go +git commit -m "feat: project init with config package and root command" +``` + +--- + +### Task 2: Workspace Metadata Model + +**Files:** +- Create: `internal/workspace/metadata.go` +- Test: `internal/workspace/metadata_test.go` + +- [ ] **Step 1: Write metadata test** + +Create `internal/workspace/metadata_test.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteAndReadMeta(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "arch-notes", + Title: "arch notes", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/home/warren/ai-workspaces/general/20260405_arch-notes", + ArchivedAt: nil, + } + + 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.ID != meta.ID { + t.Errorf("ID: got %q, want %q", got.ID, meta.ID) + } + if got.Slug != meta.Slug { + t.Errorf("Slug: got %q, want %q", got.Slug, meta.Slug) + } + if got.Status != "active" { + t.Errorf("Status: got %q, want \"active\"", got.Status) + } + if got.ArchivedAt != nil { + t.Errorf("ArchivedAt: expected nil, got %v", got.ArchivedAt) + } + if !got.CreatedAt.Equal(now) { + t.Errorf("CreatedAt: got %v, want %v", got.CreatedAt, now) + } +} + +func TestMetaYAMLFieldOrder(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/tmp/test", + } + + WriteMeta(path, meta) + + data, _ := os.ReadFile(path) + content := string(data) + + // Verify all required fields are present + for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "mode:", "agent:", "workspace_path:", "archived_at:"} { + if !containsString(content, field) { + t.Errorf("missing field %s in YAML output", field) + } + } +} + +func TestMetaArchive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: "claude", + WorkspacePath: "/tmp/test", + } + + WriteMeta(path, meta) + + archiveTime := now.Add(time.Hour) + meta.Status = "archived" + meta.ArchivedAt = &archiveTime + WriteMeta(path, meta) + + got, _ := ReadMeta(path) + if got.Status != "archived" { + t.Errorf("Status: got %q, want \"archived\"", got.Status) + } + if got.ArchivedAt == nil { + t.Fatal("ArchivedAt: expected non-nil") + } + if !got.ArchivedAt.Equal(archiveTime) { + t.Errorf("ArchivedAt: got %v, want %v", got.ArchivedAt, archiveTime) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) +} + +func containsSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/workspace/ -run TestWriteAndReadMeta -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement metadata model** + +Create `internal/workspace/metadata.go`: + +```go +package workspace + +import ( + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// TaskMeta represents the task.yaml schema. Exactly these fields, no extras. +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"` + Mode string `yaml:"mode"` + Agent string `yaml:"agent"` + WorkspacePath string `yaml:"workspace_path"` + ArchivedAt *time.Time `yaml:"archived_at"` +} + +// 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: Run tests to verify they pass** + +```bash +go test ./internal/workspace/ -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/workspace/metadata.go internal/workspace/metadata_test.go +git commit -m "feat: workspace metadata model with YAML read/write" +``` + +--- + +### Task 3: Slug Generation and Collision Suffixing + +**Files:** +- Create: `internal/workspace/slug.go` +- Test: `internal/workspace/slug_test.go` + +- [ ] **Step 1: Write slug test** + +Create `internal/workspace/slug_test.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"arch notes", "arch-notes"}, + {"Arch Notes", "arch-notes"}, + {" hello world ", "hello-world"}, + {"foo---bar", "foo-bar"}, + {"Test 123!", "test-123"}, + {"---leading---", "leading"}, + {"trailing---", "trailing"}, + {"UPPERCASE", "uppercase"}, + {"a", "a"}, + {"", ""}, + {"hello_world", "hello-world"}, + {"foo@bar#baz", "foo-bar-baz"}, + {" multiple spaces ", "multiple-spaces"}, + } + + for _, tt := range tests { + got := Slugify(tt.input) + if got != tt.want { + t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestAutoTitle(t *testing.T) { + title := AutoTitle() + if title == "" { + t.Error("AutoTitle() returned empty string") + } + // Should match pattern task-HHMMSS + if len(title) != 11 { // "task-" + 6 digits + t.Errorf("AutoTitle() = %q, expected 11 chars", title) + } + if title[:5] != "task-" { + t.Errorf("AutoTitle() = %q, expected prefix \"task-\"", title) + } +} + +func TestDirName(t *testing.T) { + name := DirName("2026-04-05", "arch-notes") + if name != "2026-04-05_arch-notes" { + t.Errorf("DirName = %q, want \"2026-04-05_arch-notes\"", name) + } +} + +func TestResolveDirCollision(t *testing.T) { + root := t.TempDir() + cat := filepath.Join(root, "general") + os.MkdirAll(cat, 0755) + + date := "2026-04-05" + slug := "test" + + // First call: no collision + got := ResolveDir(cat, date, slug) + if filepath.Base(got) != "2026-04-05_test" { + t.Errorf("first call: got %q, want dir ending in 2026-04-05_test", got) + } + + // Create that dir + os.MkdirAll(got, 0755) + + // Second call: should get -2 + got2 := ResolveDir(cat, date, slug) + if filepath.Base(got2) != "2026-04-05_test-2" { + t.Errorf("second call: got %q, want dir ending in 2026-04-05_test-2", got2) + } + + // Create that too + os.MkdirAll(got2, 0755) + + // Third call: should get -3 + got3 := ResolveDir(cat, date, slug) + if filepath.Base(got3) != "2026-04-05_test-3" { + t.Errorf("third call: got %q, want dir ending in 2026-04-05_test-3", got3) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/workspace/ -run TestSlugify -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement slug package** + +Create `internal/workspace/slug.go`: + +```go +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) +var leadTrailHyphen = regexp.MustCompile(`^-+|-+$`) + +// Slugify converts a title to a URL-friendly slug. +// Lowercase, non-alphanumeric replaced with hyphens, trimmed. +func Slugify(title string) string { + s := strings.ToLower(strings.TrimSpace(title)) + s = nonAlnum.ReplaceAllString(s, "-") + s = leadTrailHyphen.ReplaceAllString(s, "") + return s +} + +// AutoTitle generates a default title when none is provided: task-HHMMSS. +func AutoTitle() string { + now := time.Now() + return fmt.Sprintf("task-%s", now.Format("150405")) +} + +// DirName produces the workspace directory name: YYYY-MM-DD_slug. +func DirName(date, slug string) string { + return date + "_" + slug +} + +// ResolveDir returns the full path for a new workspace, appending -2, -3, etc. +// if a directory with the same date+slug already exists. +func ResolveDir(categoryDir, date, slug string) string { + base := DirName(date, slug) + candidate := filepath.Join(categoryDir, base) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + for i := 2; ; i++ { + suffixed := fmt.Sprintf("%s-%d", base, i) + candidate = filepath.Join(categoryDir, suffixed) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/workspace/ -run "TestSlug|TestAuto|TestDir|TestResolveDir" -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/workspace/slug.go internal/workspace/slug_test.go +git commit -m "feat: slug generation and directory collision resolution" +``` + +--- + +### Task 4: Seed File Templates + +**Files:** +- Create: `internal/seed/templates.go` + +- [ ] **Step 1: Create seed templates** + +Create `internal/seed/templates.go`: + +```go +package seed + +import "fmt" + +// ClaudeMD returns the advisory CLAUDE.md content for a workspace. +func ClaudeMD(slug, category, workspacePath string) string { + return fmt.Sprintf(`# Task Workspace: %s + +This is a ctask-managed workspace. + +- **Category:** %s +- **Workspace:** %s + +## Scope + +This workspace is scoped to a single task. Keep work focused on the task described in notes.md. + +## Files + +- task.yaml — Task metadata (machine-managed, do not edit) +- notes.md — Task log (human/agent-managed) +- context/ — Reference documents (user-managed) +- output/ — Task deliverables and artifacts +- logs/ — Session logs (reserved for future use) +`, slug, category, workspacePath) +} + +// NotesMD returns the skeleton notes.md content. +func NotesMD(title string) string { + return fmt.Sprintf(`# %s + +## Purpose + + + +## Constraints + + + +## Actions + + + +## Results + +`, title) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add internal/seed/templates.go +git commit -m "feat: seed file templates for CLAUDE.md and notes.md" +``` + +--- + +### Task 5: Workspace Creation + +**Files:** +- Create: `internal/workspace/create.go` +- Test: `internal/workspace/create_test.go` + +- [ ] **Step 1: Write create test** + +Create `internal/workspace/create_test.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateWorkspace(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "test task", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Verify directory exists + if _, err := os.Stat(ws.Path); os.IsNotExist(err) { + t.Fatalf("workspace dir not created: %s", ws.Path) + } + + // Verify seed files + for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} { + p := filepath.Join(ws.Path, name) + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("missing seed file: %s", name) + } + } + + // Verify subdirectories + for _, name := range []string{"context", "output", "logs"} { + p := filepath.Join(ws.Path, name) + info, err := os.Stat(p) + if os.IsNotExist(err) { + t.Errorf("missing dir: %s", name) + } else if !info.IsDir() { + t.Errorf("%s is not a directory", name) + } + } + + // Verify metadata + meta, err := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Slug != "test-task" { + t.Errorf("slug: got %q, want \"test-task\"", meta.Slug) + } + if meta.Status != "active" { + t.Errorf("status: got %q, want \"active\"", meta.Status) + } + if meta.Category != "general" { + t.Errorf("category: got %q, want \"general\"", meta.Category) + } +} + +func TestCreateWorkspaceNoTitle(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, err := Create(opts) + if err != nil { + t.Fatalf("Create: %v", err) + } + + meta, _ := ReadMeta(filepath.Join(ws.Path, "task.yaml")) + if len(meta.Slug) == 0 { + t.Error("expected auto-generated slug") + } + if meta.Slug[:5] != "task-" { + t.Errorf("auto slug should start with 'task-', got %q", meta.Slug) + } +} + +func TestCreateDoesNotOverwriteSeedFiles(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "overwrite test", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws, _ := Create(opts) + + // Write custom content to notes.md + notesPath := filepath.Join(ws.Path, "notes.md") + os.WriteFile(notesPath, []byte("custom content"), 0644) + + // Call writeSeedFiles again (simulating what would happen on resume) + writeSeedFiles(ws.Path, "overwrite-test", "overwrite test", "general") + + data, _ := os.ReadFile(notesPath) + if string(data) != "custom content" { + t.Error("seed files should not overwrite existing files") + } +} + +func TestCreateCollisionSuffix(t *testing.T) { + root := t.TempDir() + + opts := CreateOpts{ + Root: root, + Title: "same name", + Category: "general", + Mode: "local", + Agent: "claude", + } + + ws1, _ := Create(opts) + ws2, _ := Create(opts) + + if ws1.Path == ws2.Path { + t.Error("two creates with same title should produce different paths") + } + + meta2, _ := ReadMeta(filepath.Join(ws2.Path, "task.yaml")) + if meta2.Slug != "same-name-2" { + t.Errorf("collision slug: got %q, want \"same-name-2\"", meta2.Slug) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/workspace/ -run TestCreateWorkspace -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement workspace creation** + +Create `internal/workspace/create.go`: + +```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 +} + +// CreateResult holds the result of workspace creation. +type CreateResult struct { + Path string + Meta *TaskMeta +} + +// Create creates a new task workspace with seed files and metadata. +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) + + // Extract actual slug from resolved dir name (may have -2, -3 suffix) + 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) + } + + // Create 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) + } + } + + // Adjust title if slug was suffixed + actualTitle := title + if actualSlug != slug { + // Append the suffix to the title + suffix := actualSlug[len(slug):] + actualTitle = title + suffix + } + + meta := &TaskMeta{ + ID: id, + Slug: actualSlug, + Title: actualTitle, + CreatedAt: now.Truncate(time.Second), + UpdatedAt: now.Truncate(time.Second), + Status: "active", + Category: opts.Category, + Mode: opts.Mode, + Agent: opts.Agent, + WorkspacePath: wsDir, + ArchivedAt: nil, + } + + // Write seed files (only if they don't exist) + writeSeedFiles(wsDir, actualSlug, actualTitle, opts.Category) + + // Write task.yaml (always overwrite on new) + 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 +} + +// writeSeedFiles writes CLAUDE.md and notes.md if they don't already exist. +func writeSeedFiles(wsDir, slug, title, category string) { + claudePath := filepath.Join(wsDir, "CLAUDE.md") + if _, err := os.Stat(claudePath); os.IsNotExist(err) { + content := seed.ClaudeMD(slug, category, wsDir) + os.WriteFile(claudePath, []byte(content), 0644) + } + + notesPath := filepath.Join(wsDir, "notes.md") + if _, err := os.Stat(notesPath); os.IsNotExist(err) { + content := seed.NotesMD(title) + os.WriteFile(notesPath, []byte(content), 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), "/") +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/workspace/ -run TestCreate -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/workspace/create.go internal/workspace/create_test.go internal/seed/templates.go +git commit -m "feat: workspace creation with seed files and collision handling" +``` + +--- + +### Task 6: Query Resolution + +**Files:** +- Create: `internal/workspace/query.go` +- Test: `internal/workspace/query_test.go` + +- [ ] **Step 1: Write query resolution test** + +Create `internal/workspace/query_test.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// helper to create a minimal workspace for query testing +func createTestWorkspace(t *testing.T, root, category, dirName string, status string) { + t.Helper() + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + // Extract slug from dirName (skip "YYYY-MM-DD_") + slug := dirName[11:] + meta := &TaskMeta{ + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Mode: "local", + Agent: "claude", + WorkspacePath: dir, + } + WriteMeta(filepath.Join(dir, "task.yaml"), meta) +} + +func TestQueryExactDirName(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + + results, err := ResolveQuery(root, "2026-04-05_arch-notes", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Meta.Slug != "arch-notes" { + t.Errorf("slug: got %q, want \"arch-notes\"", results[0].Meta.Slug) + } +} + +func TestQueryExactSlug(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + + results, err := ResolveQuery(root, "arch-notes", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } +} + +func TestQuerySubstringUnique(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active") + + results, err := ResolveQuery(root, "arch", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Meta.Slug != "arch-notes" { + t.Errorf("slug: got %q", results[0].Meta.Slug) + } +} + +func TestQuerySubstringMultiple(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + createTestWorkspace(t, root, "research", "2026-04-03_migration-notes", "active") + + results, err := ResolveQuery(root, "notes", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } +} + +func TestQueryNoMatch(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + + results, err := ResolveQuery(root, "nonexistent", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } +} + +func TestQueryExcludesArchived(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_old-task", "archived") + createTestWorkspace(t, root, "general", "2026-04-05_new-task", "active") + + // Without --all: archived excluded + results, _ := ResolveQuery(root, "old-task", false) + if len(results) != 0 { + t.Errorf("archived should be excluded, got %d results", len(results)) + } + + // With --all: archived included + results, _ = ResolveQuery(root, "old-task", true) + if len(results) != 1 { + t.Errorf("with includeArchived, expected 1, got %d", len(results)) + } +} + +func TestQueryResolutionOrder(t *testing.T) { + root := t.TempDir() + // Create two workspaces where one has an exact slug match + createTestWorkspace(t, root, "general", "2026-04-05_backup", "active") + createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active") + + // "backup" should match exactly by slug, not return both as substring matches + results, _ := ResolveQuery(root, "backup", false) + if len(results) != 1 { + t.Fatalf("exact slug should return 1, got %d", len(results)) + } + if results[0].Meta.Slug != "backup" { + t.Errorf("should match exact slug \"backup\", got %q", results[0].Meta.Slug) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/workspace/ -run TestQuery -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement query resolution** + +Create `internal/workspace/query.go`: + +```go +package workspace + +import ( + "os" + "path/filepath" + "strings" +) + +// QueryResult holds a resolved workspace match. +type QueryResult struct { + Path string + Meta *TaskMeta +} + +// ResolveQuery implements the 5-step query resolution algorithm. +// Returns matching workspaces. Caller decides what to do with 0, 1, or N results. +func ResolveQuery(root, query string, includeArchived bool) ([]QueryResult, error) { + all, err := scanWorkspaces(root) + if err != nil { + return nil, err + } + + // Filter archived unless includeArchived + var candidates []QueryResult + for _, ws := range all { + if !includeArchived && ws.Meta.Status == "archived" { + continue + } + candidates = append(candidates, ws) + } + + // Step 1: Exact directory name match + for _, ws := range candidates { + dirName := filepath.Base(ws.Path) + if dirName == query { + return []QueryResult{ws}, nil + } + } + + // Step 2: Exact slug match + var exactSlug []QueryResult + for _, ws := range candidates { + if ws.Meta.Slug == query { + exactSlug = append(exactSlug, ws) + } + } + if len(exactSlug) > 0 { + return exactSlug, nil + } + + // Step 3: Case-insensitive substring match against slug + queryLower := strings.ToLower(query) + var substring []QueryResult + for _, ws := range candidates { + if strings.Contains(strings.ToLower(ws.Meta.Slug), queryLower) { + substring = append(substring, ws) + } + } + + return substring, nil +} + +// scanWorkspaces walks root/*/dir looking for task.yaml files. +func scanWorkspaces(root string) ([]QueryResult, error) { + var results []QueryResult + + categories, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + for _, cat := range categories { + if !cat.IsDir() { + continue + } + catPath := filepath.Join(root, cat.Name()) + entries, err := os.ReadDir(catPath) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + metaPath := filepath.Join(catPath, entry.Name(), "task.yaml") + meta, err := ReadMeta(metaPath) + if err != nil { + continue + } + results = append(results, QueryResult{ + Path: filepath.Join(catPath, entry.Name()), + Meta: meta, + }) + } + } + + return results, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/workspace/ -run TestQuery -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/workspace/query.go internal/workspace/query_test.go +git commit -m "feat: 5-step query resolution with archived exclusion" +``` + +--- + +### Task 7: Workspace Listing + +**Files:** +- Create: `internal/workspace/list.go` +- Test: `internal/workspace/list_test.go` + +- [ ] **Step 1: Write list test** + +Create `internal/workspace/list_test.go`: + +```go +package workspace + +import ( + "testing" + "time" +) + +func TestListWorkspaces(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_newer-task", "active") + // Small delay to ensure different updated_at + time.Sleep(10 * time.Millisecond) + createTestWorkspace(t, root, "scripts", "2026-04-03_older-task", "active") + createTestWorkspace(t, root, "general", "2026-04-01_archived-task", "archived") + + // Default: no archived, no filter + results, err := ListWorkspaces(root, ListOpts{Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 active workspaces, got %d", len(results)) + } + + // With --all + results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces --all: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 workspaces with --all, got %d", len(results)) + } + + // Category filter + results, err = ListWorkspaces(root, ListOpts{Category: "scripts", Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces -c scripts: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 scripts workspace, got %d", len(results)) + } + + // Limit + results, err = ListWorkspaces(root, ListOpts{Limit: 1}) + if err != nil { + t.Fatalf("ListWorkspaces -n 1: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 with limit, got %d", len(results)) + } +} + +func TestListReverseChronological(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-01_first", "active") + createTestWorkspace(t, root, "general", "2026-04-05_second", "active") + + results, _ := ListWorkspaces(root, ListOpts{Limit: 20}) + if len(results) < 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + // Most recent first (by directory name date prefix) + if results[0].Meta.Slug != "second" { + t.Errorf("first result should be newest: got %q", results[0].Meta.Slug) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/workspace/ -run TestList -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement list** + +Create `internal/workspace/list.go`: + +```go +package workspace + +import ( + "path/filepath" + "sort" +) + +// ListOpts configures workspace listing. +type ListOpts struct { + IncludeArchived bool + Category string + Limit int +} + +// 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 + } + + var filtered []QueryResult + for _, ws := range all { + if !opts.IncludeArchived && ws.Meta.Status == "archived" { + continue + } + if opts.Category != "" && ws.Meta.Category != opts.Category { + continue + } + filtered = append(filtered, ws) + } + + // Sort reverse-chronological by directory name (date prefix sorts naturally) + 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 4: Run tests to verify they pass** + +```bash +go test ./internal/workspace/ -run TestList -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/workspace/list.go internal/workspace/list_test.go +git commit -m "feat: workspace listing with filtering and reverse-chronological sort" +``` + +--- + +### Task 8: Shell Launch Helpers + +**Files:** +- Create: `internal/shell/launch.go` +- Test: `internal/shell/launch_test.go` + +- [ ] **Step 1: Write shell launch test** + +Create `internal/shell/launch_test.go`: + +```go +package shell + +import ( + "runtime" + "testing" +) + +func TestShellCommand(t *testing.T) { + cmd := DefaultShell() + if runtime.GOOS == "windows" { + // Should be cmd.exe or powershell + if cmd != "cmd.exe" && cmd != "powershell.exe" { + t.Errorf("unexpected Windows shell: %q", cmd) + } + } else { + if cmd == "" { + t.Error("shell should not be empty on Unix") + } + } +} + +func TestBuildEnvList(t *testing.T) { + vars := map[string]string{ + "CTASK_TASK": "my-task", + "CTASK_MODE": "local", + } + env := BuildEnvList(vars) + found := 0 + for _, e := range env { + if e == "CTASK_TASK=my-task" || e == "CTASK_MODE=local" { + found++ + } + } + if found != 2 { + t.Errorf("expected 2 ctask vars in env, found %d", found) + } +} + +func TestPromptPrefix(t *testing.T) { + prefix := PromptPrefix("my-task", "local") + expected := "(ctask:my-task|local) " + if prefix != expected { + t.Errorf("got %q, want %q", prefix, expected) + } +} + +func TestBannerLines(t *testing.T) { + lines := BannerLines("local", "my-task", "/home/user/ws/general/2026-04-05_my-task") + if len(lines) != 2 { + t.Fatalf("expected 2 banner lines, got %d", len(lines)) + } + if lines[0] != "[ctask] local :: my-task" { + t.Errorf("banner line 1: %q", lines[0]) + } + if lines[1] != "[ctask] /home/user/ws/general/2026-04-05_my-task" { + t.Errorf("banner line 2: %q", lines[1]) + } +} + +func TestContainerNotice(t *testing.T) { + msg := ContainerNotice() + if msg == "" { + t.Error("container notice should not be empty") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +go test ./internal/shell/ -v +``` + +Expected: compilation error. + +- [ ] **Step 3: Implement shell launch** + +Create `internal/shell/launch.go`: + +```go +package shell + +import ( + "fmt" + "os" + "os/exec" + "runtime" +) + +// DefaultShell returns the platform-appropriate interactive shell. +func DefaultShell() string { + if runtime.GOOS == "windows" { + // Prefer PowerShell if available + if _, err := exec.LookPath("powershell.exe"); err == nil { + return "powershell.exe" + } + return "cmd.exe" + } + shell := os.Getenv("SHELL") + if shell != "" { + return shell + } + return "bash" +} + +// BuildEnvList merges ctask env vars into the current process environment. +func BuildEnvList(vars map[string]string) []string { + env := os.Environ() + for k, v := range vars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env +} + +// PromptPrefix returns the shell prompt prefix for --shell mode. +func PromptPrefix(slug, mode string) string { + return fmt.Sprintf("(ctask:%s|%s) ", slug, mode) +} + +// BannerLines returns the launch banner lines for agent mode. +func BannerLines(mode, slug, wsPath string) []string { + return []string{ + fmt.Sprintf("[ctask] %s :: %s", mode, slug), + fmt.Sprintf("[ctask] %s", wsPath), + } +} + +// ContainerNotice returns the v0.1 deferred container mode message. +func ContainerNotice() string { + return "[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup." +} + +// ExecAgent replaces the current process with the agent command. +// On Windows, we use cmd.Start + Wait since exec.Command with syscall is complex. +func ExecAgent(agent string, wsDir string, envVars map[string]string) error { + path, err := exec.LookPath(agent) + if err != nil { + return fmt.Errorf("agent command not found: %s", agent) + } + + cmd := exec.Command(path) + cmd.Dir = wsDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = BuildEnvList(envVars) + + return cmd.Run() +} + +// ExecShell launches an interactive shell in the workspace directory. +func ExecShell(wsDir string, envVars map[string]string, slug, mode string) error { + shellCmd := DefaultShell() + cmd := exec.Command(shellCmd) + cmd.Dir = wsDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + env := BuildEnvList(envVars) + + // Set prompt prefix + prefix := PromptPrefix(slug, mode) + if runtime.GOOS == "windows" { + if shellCmd == "cmd.exe" { + env = append(env, fmt.Sprintf("PROMPT=%s$P$G", prefix)) + } + // PowerShell prompt is handled via -NoExit -Command + if shellCmd == "powershell.exe" { + cmd = exec.Command(shellCmd, "-NoExit", "-Command", + fmt.Sprintf("function prompt { '%s' + (Get-Location).Path + '> ' }", prefix)) + cmd.Dir = wsDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } + } else { + // Unix: set PS1 + existingPS1 := os.Getenv("PS1") + if existingPS1 == "" { + existingPS1 = "\\u@\\h:\\w\\$ " + } + env = append(env, fmt.Sprintf("PS1=%s%s", prefix, existingPS1)) + } + + cmd.Env = env + return cmd.Run() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +go test ./internal/shell/ -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/shell/launch.go internal/shell/launch_test.go +git commit -m "feat: platform-specific shell and agent launch helpers" +``` + +--- + +### Task 9: Command — `ctask new` + +**Files:** +- Create: `cmd/new.go` + +- [ ] **Step 1: Implement new command** + +Create `cmd/new.go`: + +```go +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "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 workspace and launch the agent. If title is omitted, generates task-HHMMSS.", + Args: cobra.MaximumNArgs(1), + RunE: runNew, +} + +var ( + newCategory string + newContainer bool + newShell bool + newAgent string + newNoLaunch bool +) + +func init() { + newCmd.Flags().StringVarP(&newCategory, "category", "c", "general", "Workspace category subdirectory") + newCmd.Flags().BoolVar(&newContainer, "container", false, "Launch in container sandbox (v0.2)") + 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 + } + + root := config.ResolveRoot() + agent := newAgent + if agent == "" { + agent = config.ResolveAgent() + } + + title := "" + if len(args) > 0 { + title = args[0] + } + + ws, err := workspace.Create(workspace.CreateOpts{ + Root: root, + Title: title, + Category: newCategory, + Mode: "local", + Agent: agent, + }) + if err != nil { + return err + } + + 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) + + if newShell { + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) + } + + // Agent mode: print banner and exec + for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { + fmt.Println(line) + } + + return shell.ExecAgent(agent, ws.Path, envVars) +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe new --help +``` + +Expected: help text showing `ctask new [title]` with documented flags. + +- [ ] **Step 3: Commit** + +```bash +git add cmd/new.go +git commit -m "feat: ctask new command" +``` + +--- + +### Task 10: Command — `ctask list` + +**Files:** +- Create: `cmd/list.go` + +- [ ] **Step 1: Implement list command** + +Create `cmd/list.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "Show recent workspaces in reverse-chronological order", + Args: cobra.NoArgs, + RunE: runList, +} + +var ( + listAll bool + listCategory string + listLimit int +) + +func init() { + listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "Include archived workspaces") + listCmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") + listCmd.Flags().IntVarP(&listLimit, "limit", "n", 20, "Maximum entries to show") + rootCmd.AddCommand(listCmd) +} + +func runList(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + + results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: listAll, + Category: listCategory, + Limit: listLimit, + }) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Println("No workspaces found.") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + for _, ws := range results { + // Extract date from directory name + dirName := filepath.Base(ws.Path) + date := "" + if len(dirName) >= 10 { + date = dirName[:10] + } + + // Pad status to consistent width + status := ws.Meta.Status + mode := ws.Meta.Mode + category := ws.Meta.Category + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + padRight(status, 8), + padRight(mode, 10), + padRight(category, 10), + date, + ws.Meta.Slug, + ) + } + w.Flush() + + return nil +} + +func padRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe list --help +``` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/list.go +git commit -m "feat: ctask list command" +``` + +--- + +### Task 11: Command — `ctask resume` + +**Files:** +- Create: `cmd/resume.go` + +- [ ] **Step 1: Implement resume command** + +Create `cmd/resume.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var resumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Reopen an existing workspace and launch the agent", + Args: cobra.ExactArgs(1), + RunE: runResume, +} + +var ( + resumeContainer bool + resumeShell bool + resumeAgent string +) + +func init() { + resumeCmd.Flags().BoolVar(&resumeContainer, "container", false, "Resume in container mode (v0.2)") + resumeCmd.Flags().BoolVar(&resumeShell, "shell", false, "Open shell instead of agent") + resumeCmd.Flags().StringVarP(&resumeAgent, "agent", "a", "", "Override agent command") + rootCmd.AddCommand(resumeCmd) +} + +func runResume(cmd *cobra.Command, args []string) error { + if resumeContainer { + fmt.Println(shell.ContainerNotice()) + return nil + } + + root := config.ResolveRoot() + query := args[0] + + results, err := workspace.ResolveQuery(root, query, false) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Fprintf(os.Stderr, "No workspace matches %q.\n", query) + os.Exit(1) + } + + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "Multiple workspaces match %q:\n", query) + for _, r := range results { + fmt.Fprintf(os.Stderr, " %s\n", workspace.RelativePath(root, r.Path)) + } + fmt.Fprintln(os.Stderr, "Specify a more precise query.") + os.Exit(1) + } + + ws := results[0] + + // Update updated_at + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + agent := resumeAgent + if agent == "" { + agent = ws.Meta.Agent + } + + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + + if resumeShell { + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) + } + + // Agent mode + for _, line := range shell.BannerLines(ws.Meta.Mode, ws.Meta.Slug, ws.Path) { + fmt.Println(line) + } + + return shell.ExecAgent(agent, ws.Path, envVars) +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe resume --help +``` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/resume.go +git commit -m "feat: ctask resume command" +``` + +--- + +### Task 12: Command — `ctask open` + +**Files:** +- Create: `cmd/open.go` + +- [ ] **Step 1: Implement open command** + +Create `cmd/open.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var openCmd = &cobra.Command{ + Use: "open ", + Short: "Open a workspace directory without launching the agent", + Args: cobra.ExactArgs(1), + RunE: runOpen, +} + +var openAll bool + +func init() { + openCmd.Flags().BoolVarP(&openAll, "all", "a", false, "Include archived workspaces in query resolution") + rootCmd.AddCommand(openCmd) +} + +func runOpen(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + query := args[0] + + results, err := workspace.ResolveQuery(root, query, openAll) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Fprintf(os.Stderr, "No workspace matches %q.\n", query) + os.Exit(1) + } + + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "Multiple workspaces match %q:\n", query) + for _, r := range results { + fmt.Fprintf(os.Stderr, " %s\n", workspace.RelativePath(root, r.Path)) + } + fmt.Fprintln(os.Stderr, "Specify a more precise query.") + os.Exit(1) + } + + ws := results[0] + + // Update updated_at + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.UpdatedAt = now + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + envVars := config.EnvVars(ws.Meta.Slug, ws.Meta.Mode, root, ws.Path, ws.Meta.Category) + + // Spawn interactive subshell (not agent, not cd in parent) + return shell.ExecShell(ws.Path, envVars, ws.Meta.Slug, ws.Meta.Mode) +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe open --help +``` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/open.go +git commit -m "feat: ctask open command (subshell, not parent cd)" +``` + +--- + +### Task 13: Command — `ctask info` + +**Files:** +- Create: `cmd/info.go` + +- [ ] **Step 1: Implement info command** + +Create `cmd/info.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var infoCmd = &cobra.Command{ + Use: "info ", + Short: "Display metadata and path for a workspace", + Args: cobra.ExactArgs(1), + RunE: runInfo, +} + +var infoAll bool + +func init() { + infoCmd.Flags().BoolVarP(&infoAll, "all", "a", false, "Include archived workspaces in query resolution") + rootCmd.AddCommand(infoCmd) +} + +func runInfo(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + query := args[0] + + results, err := workspace.ResolveQuery(root, query, infoAll) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Fprintf(os.Stderr, "No workspace matches %q.\n", query) + os.Exit(1) + } + + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "Multiple workspaces match %q:\n", query) + for _, r := range results { + fmt.Fprintf(os.Stderr, " %s\n", workspace.RelativePath(root, r.Path)) + } + fmt.Fprintln(os.Stderr, "Specify a more precise query.") + os.Exit(1) + } + + ws := results[0] + m := ws.Meta + + fmt.Printf("Task: %s\n", m.Slug) + fmt.Printf("Title: %s\n", m.Title) + fmt.Printf("Category: %s\n", m.Category) + fmt.Printf("Status: %s\n", m.Status) + fmt.Printf("Mode: %s\n", m.Mode) + fmt.Printf("Agent: %s\n", m.Agent) + fmt.Printf("Created: %s\n", m.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", m.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Path: %s\n", ws.Path) + + if m.ArchivedAt != nil { + fmt.Printf("Archived: %s\n", m.ArchivedAt.Format("2006-01-02 15:04:05")) + } + + // List contents + fmt.Println() + fmt.Println("Contents:") + entries, err := os.ReadDir(ws.Path) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + fmt.Printf(" %s\n", name) + } + + return nil +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe info --help +``` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/info.go +git commit -m "feat: ctask info command" +``` + +--- + +### Task 14: Command — `ctask archive` + +**Files:** +- Create: `cmd/archive.go` + +- [ ] **Step 1: Implement archive command** + +Create `cmd/archive.go`: + +```go +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var archiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Mark a workspace as archived", + Args: cobra.ExactArgs(1), + RunE: runArchive, +} + +func init() { + rootCmd.AddCommand(archiveCmd) +} + +func runArchive(cmd *cobra.Command, args []string) error { + root := config.ResolveRoot() + query := args[0] + + results, err := workspace.ResolveQuery(root, query, false) + if err != nil { + return err + } + + if len(results) == 0 { + fmt.Fprintf(os.Stderr, "No workspace matches %q.\n", query) + os.Exit(1) + } + + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "Multiple workspaces match %q:\n", query) + for _, r := range results { + fmt.Fprintf(os.Stderr, " %s\n", workspace.RelativePath(root, r.Path)) + } + fmt.Fprintln(os.Stderr, "Specify a more precise query.") + os.Exit(1) + } + + ws := results[0] + + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.Status = "archived" + ws.Meta.ArchivedAt = &now + ws.Meta.UpdatedAt = now + + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMeta(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + relPath := workspace.RelativePath(root, ws.Path) + fmt.Printf("[ctask] archived: %s\n", relPath) + + return nil +} +``` + +- [ ] **Step 2: Build and verify** + +```bash +go build -o ctask.exe . +./ctask.exe archive --help +``` + +- [ ] **Step 3: Commit** + +```bash +git add cmd/archive.go +git commit -m "feat: ctask archive command" +``` + +--- + +### Task 15: Status Line Helper Scripts + +**Files:** +- Create: `scripts/ctask-statusline.sh` +- Create: `scripts/ctask-statusline.ps1` +- Create: `docs/status-line-setup.md` + +- [ ] **Step 1: Create bash status line helper** + +Create `scripts/ctask-statusline.sh`: + +```bash +#!/usr/bin/env bash +# ctask status line helper for Claude Code +# Reads ctask environment variables and prints a formatted context string. +# Output: (ctask:|) +# Outputs nothing when not in a ctask session. + +[ -z "$CTASK_TASK" ] && exit 0 + +echo "(ctask:${CTASK_TASK}|${CTASK_MODE}) ${CTASK_WORKSPACE}" +``` + +- [ ] **Step 2: Create PowerShell status line helper** + +Create `scripts/ctask-statusline.ps1`: + +```powershell +# ctask status line helper for Claude Code +# Reads ctask environment variables and prints a formatted context string. +# Output: (ctask:|) +# Outputs nothing when not in a ctask session. + +if (-not $env:CTASK_TASK) { exit 0 } + +Write-Output "(ctask:$($env:CTASK_TASK)|$($env:CTASK_MODE)) $($env:CTASK_WORKSPACE)" +``` + +- [ ] **Step 3: Create setup documentation** + +Create `docs/status-line-setup.md`: + +```markdown +# ctask Status Line Setup + +## Claude Code Integration + +ctask includes helper scripts that display task context in Claude Code's status line. + +### Setup + +Add one of the following to your `~/.claude/settings.json`: + +**Linux / macOS (bash):** + +```json +{ + "statusLine": { + "type": "command", + "command": "bash /path/to/ctask-statusline.sh" + } +} +``` + +**Windows (PowerShell):** + +```json +{ + "statusLine": { + "type": "command", + "command": "powershell -NoProfile -File C:\\path\\to\\ctask-statusline.ps1" + } +} +``` + +Replace `/path/to/` with the actual location of the script. + +### Output + +When inside a ctask session: + +``` +(ctask:arch-notes|local) ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +When NOT in a ctask session: no output (falls through gracefully). + +### How It Works + +The scripts read only from environment variables set by ctask: + +- `CTASK_TASK` — task slug +- `CTASK_MODE` — execution mode (local/container) +- `CTASK_WORKSPACE` — full workspace path + +No file parsing or subprocess calls are performed. + +## Non-Claude Agents + +For agents that do not support a dedicated status line, ctask provides an ephemeral +shell prompt prefix in `--shell` mode: + +``` +(ctask:arch-notes|local) user@host:~/path$ +``` + +This is set via `PS1` (Unix) or `PROMPT` (Windows) and does not modify permanent +shell configuration. +``` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/ctask-statusline.sh scripts/ctask-statusline.ps1 docs/status-line-setup.md +git commit -m "feat: status line helper scripts and setup documentation" +``` + +--- + +### Task 16: Container Notice Test and Integration Smoke Tests + +**Files:** +- Modify: `internal/shell/launch_test.go` (already has ContainerNotice test) +- No new files — run manual smoke tests + +- [ ] **Step 1: Run all unit tests** + +```bash +cd /c/Users/Warren/claude_tasks/ctask_v0.1 +export PATH="$PATH:/c/Program Files/Go/bin" +go test ./... -v +``` + +Expected: all PASS. + +- [ ] **Step 2: Build final binary** + +```bash +go build -o ctask.exe . +``` + +- [ ] **Step 3: Smoke test — ctask new** + +```bash +export CTASK_ROOT=$(mktemp -d) +./ctask.exe new --no-launch "smoke test" +ls "$CTASK_ROOT/general/" +cat "$CTASK_ROOT/general/2026-04-05_smoke-test/task.yaml" +``` + +Expected: workspace created with all seed files and correct metadata. + +- [ ] **Step 4: Smoke test — ctask list** + +```bash +./ctask.exe list +``` + +Expected: one row showing the smoke-test workspace. + +- [ ] **Step 5: Smoke test — ctask info** + +```bash +./ctask.exe info smoke-test +``` + +Expected: metadata display with all fields. + +- [ ] **Step 6: Smoke test — ctask archive** + +```bash +./ctask.exe archive smoke-test +./ctask.exe list +./ctask.exe list --all +``` + +Expected: archived workspace hidden from default list, visible with --all. + +- [ ] **Step 7: Smoke test — ctask resume (no-op since no agent)** + +```bash +./ctask.exe resume --help +``` + +Expected: help text with documented flags. + +- [ ] **Step 8: Smoke test — container notice** + +```bash +./ctask.exe new --container "container test" +``` + +Expected: prints `[ctask] container mode is not available in v0.1. Use local mode or see docs for manual container setup.` + +- [ ] **Step 9: Smoke test — collision suffixing** + +```bash +./ctask.exe new --no-launch "smoke test" +ls "$CTASK_ROOT/general/" +``` + +Expected: `2026-04-05_smoke-test` and `2026-04-05_smoke-test-2` both exist. + +- [ ] **Step 10: Commit any fixes** + +If any smoke tests revealed issues, fix and commit. + +```bash +git add -A +git commit -m "fix: smoke test fixes" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- `ctask new` with all flags (--category, --container, --shell, --agent, --no-launch): Task 9 +- `ctask list` with --all, --category, --limit: Task 10 +- `ctask resume` with --container, --shell, --agent: Task 11 +- `ctask open` with --all (subshell, not cd): Task 12 +- `ctask info` with --all: Task 13 +- `ctask archive`: Task 14 +- Query resolution 5-step algorithm: Task 6 +- Archived exclusion: Task 6 tests +- Workspace layout (task.yaml, CLAUDE.md, notes.md, context/, output/, logs/): Task 5 +- task.yaml schema (11 fields exactly): Task 2 +- Slug generation + collision: Task 3 +- Environment variables (5 vars): Task 1 +- Launch banner: Task 8 +- Shell prompt prefix: Task 8 +- Container deferred notice: Task 8 +- Status line scripts (bash + PowerShell): Task 15 +- Exit codes (0, 1, 2, 127): Task 8 (agent not found), Tasks 11-14 (os.Exit(1)), cobra handles missing args (exit 2 via cobra) +- Platform-specific defaults: Task 1 (root), Task 8 (shell) + +**Placeholder scan:** No TBD/TODO/placeholder patterns found. + +**Type consistency:** TaskMeta, CreateOpts, CreateResult, QueryResult, ListOpts used consistently across all tasks. Function signatures match (ResolveQuery, ListWorkspaces, WriteMeta, ReadMeta, etc.). diff --git a/mvp-contract.md b/mvp-contract.md new file mode 100644 index 0000000..55a1f53 --- /dev/null +++ b/mvp-contract.md @@ -0,0 +1,201 @@ +# ctask — MVP Contract (v0.1) + +## 1. Purpose + +ctask is a local CLI that creates and manages named AI-agent task workspaces so developers can start, resume, and organize work more safely and predictably. It provides dedicated task directories with consistent structure, visible session identity, environment context injection, and optional container isolation. It is agent-agnostic — the default agent is Claude Code, but any command-line agent or shell can be launched. + +## 2. Must-Have Commands + +| Command | Description | +|---------|-------------| +| `ctask new [title]` | Create a new task workspace and launch the agent in it | +| `ctask list` | Show recent workspaces, reverse-chronological | +| `ctask resume ` | Reopen an existing workspace by name/slug match | +| `ctask open ` | Open a workspace directory without launching the agent | +| `ctask info ` | Display metadata and path for a workspace | +| `ctask archive ` | Mark a workspace as archived (status flag, workspace stays in place) | + +### Flags (apply across commands where relevant) + +| Flag | Description | +|------|-------------| +| `-c, --category ` | Workspace category (default: `general`) | +| `--container` | Request container mode (deferred to v0.2 — prints notice in v0.1) | +| `--shell` | Open an interactive shell instead of the agent | +| `--agent ` | Override the default agent command | +| `-h, --help` | Show help | + +### Resolution rules for `` + +1. Exact directory name match under `/*/*` → use it +2. Exact slug match (portion after date prefix) → use it +3. Case-insensitive substring match against slug → use if unique +4. Multiple matches → print all and exit non-zero +5. No matches → error + +Archived workspaces are excluded from matching unless `--all` is passed (where supported). + +## 3. Workspace Layout + +Every task workspace contains: + +``` +~/ai-workspaces//_/ +├── task.yaml # Task metadata (machine-managed) +├── CLAUDE.md # Advisory workspace scope guidelines (not security enforcement) +├── notes.md # Freeform task log (human/agent-managed) +├── context/ # Additional reference docs (user-managed, not seeded) +├── output/ # Task deliverables and artifacts +└── logs/ # Session logs (future use, empty in v0.1) +``` + +### Seed files + +On `ctask new`: + +- `task.yaml` — populated with initial metadata (see §4) +- `notes.md` — skeleton with Purpose / Constraints / Actions / Results headings +- `CLAUDE.md` — advisory workspace scope guidelines (placed in root so Claude Code finds it automatically) + +Seed files are only created if they do not already exist (safe for resume/reopen). + +### Workspace root + +Default: `~/ai-workspaces` + +Override via: `CTASK_ROOT` environment variable + +### Categories + +Predefined suggestions: `general`, `scripts`, `research`, `risky` + +Any string is accepted. Categories are just subdirectories — no registry. + +## 4. Task Metadata + +`task.yaml` contains only: + +```yaml +id: "20260405-143022" # YYYYMMDD-HHMMSS, unique enough +slug: "arch-notes" +title: "arch notes" +created_at: "2026-04-05T14:30:22Z" +updated_at: "2026-04-05T15:12:00Z" +status: "active" # active | archived +category: "general" +mode: "local" # local | container +agent: "claude" # command used to launch +workspace_path: "/home/warren/ai-workspaces/general/20260405_arch-notes" +archived_at: null # set by ctask archive, null while active +``` + +`updated_at` is refreshed on every `resume` or `open`. + +No additional fields in v0.1. Schema grows only when a real need appears. + +## 5. Launch Behavior + +### On `ctask new` + +1. Compute slug from title (lowercase, alphanumeric + hyphens) +2. If workspace with same date+slug already exists, append `-2`, `-3`, etc. +3. Create workspace directory under `//_` +4. Write seed files (`task.yaml`, `notes.md`, `CLAUDE.md`) +5. Export environment variables (see below) +6. Print launch banner +7. `cd` into workspace and exec the agent (or shell if `--shell`) + +### On `ctask resume` + +1. Resolve workspace by query (see §2 resolution rules) +2. Update `updated_at` in `task.yaml` +3. Export environment variables +4. Print launch banner +5. `cd` into workspace and exec the agent (or shell) + +### Environment variables (set for every session) + +| Variable | Value | +|----------|-------| +| `CTASK_TASK` | Slug (e.g., `arch-notes`) | +| `CTASK_MODE` | `local` or `container` | +| `CTASK_ROOT` | Workspace root path | +| `CTASK_WORKSPACE` | Full path to current workspace | +| `CTASK_CATEGORY` | Category name | + +These are available to the agent, status line scripts, and shell prompts. + +### Launch banner (printed before exec) + +``` +[ctask] local :: arch-notes +[ctask] ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +### Shell prompt prefix (for `--shell` mode only) + +``` +(ctask:arch-notes|local) warren@host:~$ +``` + +Set via ephemeral `PS1` override. No permanent shell config changes. + +### Container mode (v0.2 — deferred from initial release) + +Container mode uses a persistent named container (`ctask-sandbox`). It is deferred from the initial v0.1 release to keep scope focused on the workspace lifecycle, which is the core value. The `--container` flag will be accepted but will print a message that container mode is not yet available. + +Design intent for v0.2: Linux-only, Podman-first (Docker fallback), Claude Code only inside the container. Mount `$CTASK_ROOT` at `/workspaces`. No other host directories mounted. Agent auth done once manually after first container creation. + +## 6. Explicit Non-Goals for v0.1 + +- **No plugin system.** Agent is a command string, not an interface. +- **No elevated helper / privilege adapter.** Sudo policy is separate from ctask. +- **No task templates.** Every workspace gets the same seed files. +- **No built-in session logging.** `logs/` directory exists but ctask does not populate it. +- **No agent abstraction layer.** Swap agents via `--agent ` flag, not adapters. +- **No container orchestration.** Container mode is one persistent sandbox, not per-task containers. +- **No GUI, no TUI beyond the CLI.** +- **No cloud sync, no remote sharing.** + +## 7. Platform Target + +Cross-platform from the start: Windows, Linux, macOS. Primary development and testing on Windows. + +Go's standard library handles the main platform differences (path separators via `filepath`, environment via `os`). The only platform-specific concerns are: + +- **Shell spawning:** Use `cmd.exe` or PowerShell on Windows, user's `$SHELL` or `bash` on Unix +- **Default workspace root:** `%USERPROFILE%\ai-workspaces` on Windows, `~/ai-workspaces` on Unix +- **Status line helper:** Provide both a bash script and a PowerShell script + +Keep platform-specific code isolated behind small helper functions. Do not introduce build tags or platform abstraction layers unless a concrete need appears. + +## 8. Status Line Support + +The status line is an MVP deliverable, not a future enhancement. It provides persistent visible session context inside Claude Code's UI. + +### Deliverables + +- A small status line helper script (bash + PowerShell variants) that reads ctask environment variables and prints a formatted context string +- Brief setup instructions for wiring it into Claude Code's `statusLine` configuration in `~/.claude/settings.json` +- A note explaining the fallback approach for non-Claude agents (ephemeral shell prompt prefix) + +### Output format + +``` +(ctask:arch-notes|local) ~/ai-workspaces/general/2026-04-05_arch-notes +``` + +When `CTASK_TASK` is not set (normal non-ctask Claude session), the script should output nothing or fall through to other status line content. + +### Source of truth + +The script reads only from environment variables — no file parsing, no subprocess calls: + +- `CTASK_TASK` — task slug +- `CTASK_MODE` — execution mode +- `CTASK_WORKSPACE` — full workspace path +- `CTASK_CATEGORY` — category (optional, include if concise) + +### Fallback for non-Claude agents + +For `open` and `resume --shell`, the ephemeral `PS1` / `PROMPT` prefix remains the generic fallback for tools that do not support a dedicated status line hook. diff --git a/v0.2-spec.md b/v0.2-spec.md new file mode 100644 index 0000000..5fbe966 --- /dev/null +++ b/v0.2-spec.md @@ -0,0 +1,302 @@ +# ctask v0.2 Feature Spec — Session Traceability & Resumability + +## Theme + +v0.1 = create and resume workspaces +v0.2 = understand and continue workspaces later + +## Scope + +Five deliverables: + +1. Automatic session snapshot logging +2. Improved seeded CLAUDE.md with handoff instructions +3. `ctask doctor` +4. `ctask last` +5. `ctask delete` + +## 1. Automatic Session Snapshot Logging + +### Purpose + +When a developer returns to a workspace after hours or days, they need to immediately understand what happened last time — without relying on memory or manual note-taking. + +### How it works + +ctask wraps the agent/shell launch so it can run logic before and after the session. + +**On session start:** + +1. Create `.ctask/` directory in workspace if it doesn't exist +2. Capture a file manifest: relative path, size in bytes, and modification timestamp for every file in the workspace (excluding `.ctask/` itself) +3. Write manifest to `.ctask/manifest-start.json` +4. Append a session-start entry to `logs/sessions.log` + +**On session end (agent/shell process exits):** + +1. Capture a second manifest using the same format +2. Diff against the start manifest to produce: files added, files modified (size or mtime changed), files deleted +3. Append a session-end entry to `logs/sessions.log` with the diff summary +4. Clean up `.ctask/manifest-start.json` (or keep for debugging — implementer's choice) +5. Exit with the child process's exit code + +### Implementation change + +v0.1 uses `exec` to replace the ctask process with the agent. v0.2 must change this to spawn a child process and wait for it, so ctask can run post-session logic. This applies to `new`, `resume`, and `open`. + +Session snapshot logging runs for any command that launches an agent or shell session: + +- `ctask new` (when it launches into agent or shell) +- `ctask resume` +- `ctask open` +- `ctask last` (delegates to `resume`) + +Session snapshot logging does **not** run for: + +- `ctask new --no-launch` (no session to capture) +- `ctask list`, `ctask info`, `ctask archive`, `ctask doctor`, `ctask delete` + +On Unix: run the agent as a child process with stdin/stdout/stderr inherited, wait for exit. +On Windows: same pattern using `os/exec` with `Cmd.Run()`. + +Do not use `exec` (process replacement) in v0.2. + +### Session log format + +`logs/sessions.log` is a human-readable, append-only text file. + +``` +── Session 2026-04-05 14:30:22 ── +Agent: claude +Mode: local +Start: 2026-04-05 14:30:22 +End: 2026-04-05 15:45:10 +Duration: 1h 14m 48s + +Added: + output/migration-plan.md + output/schema.sql + context/reference.md + +Modified: + notes.md + CLAUDE.md + +Deleted: + (none) + +Notes updated: yes +──────────────────────────────── +``` + +### Rules + +- The manifest captures only regular files, not directories +- Ignore `.ctask/` contents when diffing +- Ignore `task.yaml` changes (ctask updates this itself, not the agent) +- Ignore `logs/sessions.log` changes (ctask's own logging would otherwise show as a modification every session) +- "Modified" means: file present in both start and end manifests with a different size or mtime. This is a lightweight heuristic, not content hashing. +- If the session is very short (< 5 seconds) and no files changed, still log it but with a note like "No changes detected" +- If manifest capture fails for any reason, log the error and continue — never block the user from exiting +- "Notes updated" is a simple boolean: did `notes.md` change between start and end? +- `.ctask/manifest-start.json` is deleted after successful session-end logging. If post-session logging fails, keep it and log the error. + +### Manifest format + +`.ctask/manifest-start.json`: + +```json +{ + "captured_at": "2026-04-05T14:30:22Z", + "files": [ + {"path": "notes.md", "size": 245, "mtime": "2026-04-05T14:30:20Z"}, + {"path": "CLAUDE.md", "size": 512, "mtime": "2026-04-05T14:30:20Z"}, + {"path": "task.yaml", "size": 310, "mtime": "2026-04-05T14:30:22Z"} + ] +} +``` + +## 2. Improved Seeded CLAUDE.md + +### Purpose + +Nudge the agent to leave a human-readable handoff in `notes.md` so the developer (or a future agent session) can understand what was accomplished and what remains. + +### Change + +Update the seeded `CLAUDE.md` template to include the following instruction (in addition to existing workspace scope guidance): + +```markdown +## 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. This helps the developer (or a future session) resume without losing context. +``` + +### Rules + +- This is advisory, not enforced. The agent may or may not follow it. +- The automatic session snapshot (§1) provides the structural safety net regardless. +- Do not add complex formatting requirements — the simpler the instruction, the more likely the agent complies. + +## 3. `ctask doctor` + +### Purpose + +Verify that ctask is correctly set up and help users diagnose common configuration problems. + +### Command + +``` +ctask doctor +``` + +No arguments, no flags. + +### Checks + +| Check | Pass condition | Fail message | +|-------|---------------|--------------| +| Workspace root exists | `CTASK_ROOT` directory exists and is writable | `Workspace root not found: . Create it with: mkdir ` | +| Default agent on PATH | Configured agent command is found | `Agent command not found: . Install it or set CTASK_AGENT.` | +| Status line helper exists | Status line script file exists at expected location | `Status line helper not found. Run ctask setup instructions at .` | +| Claude Code status line configured | `~/.claude/settings.json` exists, contains a `statusLine` key with `type: "command"`, and the `command` value references a file that exists on disk | `Claude Code status line not configured or misconfigured. Add to ~/.claude/settings.json: ` | +| Workspace root has workspaces | At least one workspace directory exists | `No workspaces found. Create one with: ctask new "my first task"` | + +### Output format + +``` +ctask doctor + + ✓ Workspace root exists: ~/ai-workspaces + ✓ Default agent found: claude + ✗ Status line helper not found + → Install it to: ~/.local/bin/ctask-statusline.sh + ✓ Claude Code status line configured + ✓ Workspaces found: 12 tasks (3 archived) + +3 checks passed, 1 failed +``` + +Use ✓ and ✗ (or platform-safe equivalents if Unicode is unreliable on Windows cmd.exe). Provide actionable fix instructions for every failure. + +### Rules + +- doctor never modifies anything — read-only +- doctor should work even if ctask has never been used (no workspaces yet) +- exit 0 if all checks pass, exit 1 if any fail +- check paths using platform-appropriate logic (Windows vs Unix) + +## 4. `ctask last` + +### Purpose + +Instantly resume the most recently updated workspace without remembering its name. + +### Command + +``` +ctask last [options] +``` + +### Options + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--shell` | | off | Open shell instead of agent | +| `--agent` | `-a` | from task.yaml | Override agent command | + +### Behavior + +1. Scan all non-archived workspaces +2. Find the one with the most recent `updated_at` in `task.yaml` +3. Behave exactly like `ctask resume ` + +If no workspaces exist, print an error and exit 1. + +### Examples + +```bash +# Resume most recent workspace +ctask last + +# Open most recent workspace in shell +ctask last --shell +``` + +### Output + +Same as `resume` — launch banner, then agent or shell. + +## 5. `ctask delete` + +### Purpose + +Permanently remove a workspace directory when it's no longer needed. + +### Command + +``` +ctask delete [options] +``` + +### Options + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--force` | `-f` | off | Skip confirmation prompt | +| `--all` | `-a` | off | Include archived workspaces in query resolution | + +### Behavior + +1. Resolve workspace (same query rules as `resume`) +2. Print workspace path and contents summary (file count, total size) +3. Prompt for confirmation: `Delete ? This cannot be undone. [y/N]` +4. If confirmed (or `--force`), remove the entire workspace directory +5. Print confirmation message + +### Output + +``` +ctask delete arch-notes + + Workspace: ~/ai-workspaces/general/2026-04-05_arch-notes + Files: 8 (24 KB) + + Delete this workspace? This cannot be undone. [y/N] y + +[ctask] deleted: general/2026-04-05_arch-notes +``` + +### Rules + +- Confirmation is required by default. `--force` skips it. +- Delete removes the entire directory tree, not just ctask metadata. +- Archived workspaces are excluded from query resolution unless `--all` is passed (consistent with other commands). +- If the workspace is the most recently updated one, print a note: "This was your most recent workspace." +- If `CTASK_WORKSPACE` is set and matches the resolved target (i.e., the user is inside an active ctask session targeting this workspace), refuse to delete and print: "Cannot delete the active workspace. Exit the session first." This check applies even with `--force`. + +## Non-Goals for v0.2 + +- No terminal output recording or command history capture +- No automatic agent-generated summaries (the CLAUDE.md nudge is advisory only) +- No container mode (still deferred) +- No `ctask summarize` command (the automatic snapshot replaces the need) +- No manifest history / multiple snapshots per workspace (one start manifest is enough) + +## Build Order + +1. Change launch model from `exec` to child-process-and-wait (touches `new`, `resume`, `open`) +2. Manifest capture (start snapshot) +3. Session log writer (end snapshot + diff + append to `logs/sessions.log`) +4. Updated CLAUDE.md template +5. `ctask doctor` +6. `ctask last` +7. `ctask delete` +8. Testing and validation