From 8120c399dfaf5e5312a8bf6ac8cfc7d8d5e0b10b Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 15 May 2026 10:58:06 -0400 Subject: [PATCH] feat(v0.6): AgentSpec field on TaskMeta with backward-compat unmarshal Replace TaskMeta.Agent (string) with TaskMeta.Agent (AgentSpec) carrying type/command/args/env. Custom UnmarshalYAML preserves the legacy scalar form: a built-in name (claude, opencode) maps to that type; any other scalar maps to type=custom with the scalar as command. A missing agent field leaves Type empty so the resolver fills in default_agent at launch. ValidateAgentSpec enforces: known type (claude|opencode|custom), type=custom requires command, command must be an executable name or path with no whitespace or shell metacharacters. Launch-path wiring (Task 3) and the --agent flag rework (Task 4) are intentionally not part of this commit; cmd/* call sites are patched to the minimum needed for the build to compile. --- cmd/archive_test.go | 20 ++-- cmd/attach.go | 2 +- cmd/attach_test.go | 2 +- cmd/completion_test.go | 2 +- cmd/delete_test.go | 18 ++-- cmd/entry_test.go | 2 +- cmd/info.go | 2 +- cmd/info_attribution_test.go | 10 +- cmd/info_launch_test.go | 12 +-- cmd/info_session_test.go | 2 +- cmd/list_session_test.go | 2 +- cmd/list_test.go | 20 ++-- cmd/new.go | 2 +- cmd/notes_test.go | 2 +- cmd/open.go | 2 +- cmd/open_persistent_test.go | 2 +- cmd/path_test.go | 4 +- cmd/restore_test.go | 4 +- cmd/resume.go | 2 +- cmd/resume_archived_polish_test.go | 2 +- cmd/resume_persistent_test.go | 2 +- cmd/resume_test.go | 2 +- internal/session/adopt_test.go | 2 +- internal/workspace/agent_spec_test.go | 127 ++++++++++++++++++++++++++ internal/workspace/create.go | 12 +-- internal/workspace/create_test.go | 84 ++++++++--------- internal/workspace/git_test.go | 6 +- internal/workspace/metadata.go | 113 ++++++++++++++++++++++- internal/workspace/metadata_test.go | 84 ++++++++--------- internal/workspace/query_test.go | 40 ++++---- internal/workspace/schema_test.go | 30 +++--- 31 files changed, 427 insertions(+), 189 deletions(-) create mode 100644 internal/workspace/agent_spec_test.go diff --git a/cmd/archive_test.go b/cmd/archive_test.go index b99acb2..d45595e 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -22,16 +22,16 @@ func makeArchiveWs(t *testing.T, root, category, dirName string) string { now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] meta := &workspace.TaskMeta{ - ID: "test", - Slug: slug, - Title: slug, - CreatedAt: now, - UpdatedAt: now, - Status: "active", - Category: category, - Type: "task", - Mode: "local", - Agent: "claude", + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: category, + Type: "task", + Mode: "local", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) return dir diff --git a/cmd/attach.go b/cmd/attach.go index 39a7cf5..a199ac5 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -45,7 +45,7 @@ func runAttach(cmd *cobra.Command, args []string) error { agent := attachAgent if agent == "" { - agent = ws.Meta.Agent + agent = ws.Meta.Agent.Type } return runWorkspaceEntry(WorkspaceEntryOptions{ diff --git a/cmd/attach_test.go b/cmd/attach_test.go index c6903b9..1fdba09 100644 --- a/cmd/attach_test.go +++ b/cmd/attach_test.go @@ -56,7 +56,7 @@ func TestAttachForwardsToEntryHelperWithAlwaysPersistent(t *testing.T) { ID: "t", Slug: "attach-fwd-demo", Title: "attach-fwd-demo", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/cmd/completion_test.go b/cmd/completion_test.go index e9bab8d..540629b 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -27,7 +27,7 @@ func completionTestEnv(t *testing.T) string { ID: "t", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: status, Category: category, Type: taskType, - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if status == "archived" { meta.ArchivedAt = &now diff --git a/cmd/delete_test.go b/cmd/delete_test.go index 0ac92eb..9a23480 100644 --- a/cmd/delete_test.go +++ b/cmd/delete_test.go @@ -21,15 +21,15 @@ func createTestWs(t *testing.T, root, category, dirName, status string) string { now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] // skip "YYYY-MM-DD_" meta := &workspace.TaskMeta{ - ID: "test", - Slug: slug, - Title: slug, - CreatedAt: now, - UpdatedAt: now, - Status: status, - Category: category, - Mode: "local", - Agent: "claude", + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Mode: "local", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) diff --git a/cmd/entry_test.go b/cmd/entry_test.go index e276009..62a2021 100644 --- a/cmd/entry_test.go +++ b/cmd/entry_test.go @@ -65,7 +65,7 @@ func TestRunWorkspaceEntryIsInjectable(t *testing.T) { want := WorkspaceEntryOptions{ WsPath: "/tmp/ws", WsRoot: "/tmp", - WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: "claude"}, + WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}}, Agent: "claude", Shell: true, CommandName: "test", diff --git a/cmd/info.go b/cmd/info.go index 667edce..a6a0668 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -43,7 +43,7 @@ func runInfo(cmd *cobra.Command, args []string) error { 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", agentLineWithSource(m.Agent, resolver)) + fmt.Printf("Agent: %s\n", agentLineWithSource(m.Agent.Type, resolver)) fmt.Printf("Launch session mode: %s (%s)\n", resolver.SessionMode().Value, infoSourceLabel(resolver.SessionMode())) // v0.5.1: display timestamps in local time. task.yaml stores UTC; diff --git a/cmd/info_attribution_test.go b/cmd/info_attribution_test.go index fce98e9..dd10af9 100644 --- a/cmd/info_attribution_test.go +++ b/cmd/info_attribution_test.go @@ -38,7 +38,7 @@ func TestInfoAgentSourceWorkspace(t *testing.T) { ID: "t", Slug: "agentwsdemo", Title: "agentwsdemo", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "opencode", + Mode: "local", Agent: workspace.AgentSpec{Type: "opencode"}, }) out, err := runInfoCapture(t, root, "agentwsdemo") @@ -70,7 +70,7 @@ func TestInfoAgentSourceDefaultForLegacy(t *testing.T) { ID: "t", Slug: "legacyagent", Title: "legacy agent", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "", // legacy: no agent recorded + Mode: "local", Agent: workspace.AgentSpec{Type: ""}, // legacy: no agent recorded }) out, err := runInfoCapture(t, root, "legacyagent") @@ -107,7 +107,7 @@ func TestInfoLaunchSessionModeFromConfig(t *testing.T) { ID: "t", Slug: "modesrc", Title: "mode src", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, }) out, err := runInfoCapture(t, root, "modesrc") @@ -138,7 +138,7 @@ func TestInfoLaunchSessionModeBuiltinDefault(t *testing.T) { ID: "t", Slug: "modedefault", Title: "mode default", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, }) out, err := runInfoCapture(t, root, "modedefault") @@ -167,7 +167,7 @@ func TestInfoLaunchSessionModeAfterAgentBeforeCreated(t *testing.T) { ID: "t", Slug: "placement", Title: "placement", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, }) out, err := runInfoCapture(t, root, "placement") diff --git a/cmd/info_launch_test.go b/cmd/info_launch_test.go index 118585e..c8ce3b9 100644 --- a/cmd/info_launch_test.go +++ b/cmd/info_launch_test.go @@ -46,7 +46,7 @@ func TestInfoShowsLaunchFieldsWhenLaunchDirSet(t *testing.T) { ID: "t", Slug: "demo", Title: "demo", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "projects", Type: "project", - Mode: "local", Agent: "claude", LaunchDir: "demo", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, LaunchDir: "demo", } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) @@ -73,7 +73,7 @@ func TestInfoOmitsLaunchFieldsForTask(t *testing.T) { ID: "t", Slug: "regular", Title: "regular", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) @@ -104,8 +104,8 @@ func TestInfoFormatsTimestampsInLocalZone(t *testing.T) { ID: "t", Slug: "tz-test", Title: "tz-test", CreatedAt: fixedUTC, UpdatedAt: fixedUTC, ArchivedAt: &archived, - Status: "archived", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Status: "archived", Category: "general", Type: "task", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) @@ -153,7 +153,7 @@ func TestInfoFindsArchivedWorkspaceWithoutFlag(t *testing.T) { Category: "general", Type: "task", Mode: "local", - Agent: "claude", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) @@ -179,7 +179,7 @@ func TestInfoShowsDirExistsNoWhenLaunchDirMissing(t *testing.T) { ID: "t", Slug: "renamed", Title: "renamed", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "projects", Type: "project", - Mode: "local", Agent: "claude", LaunchDir: "renamed", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, LaunchDir: "renamed", } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go index a3c38b9..165a301 100644 --- a/cmd/info_session_test.go +++ b/cmd/info_session_test.go @@ -29,7 +29,7 @@ func makeInfoSessionWorkspace(t *testing.T, root, slug string) string { ID: "t", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/cmd/list_session_test.go b/cmd/list_session_test.go index 1ef27a3..5243cf8 100644 --- a/cmd/list_session_test.go +++ b/cmd/list_session_test.go @@ -26,7 +26,7 @@ func makeListSessionWorkspace(t *testing.T, root, category, dirName, slug, statu ID: "t", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: status, Category: category, Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if status == "archived" { meta.ArchivedAt = &now diff --git a/cmd/list_test.go b/cmd/list_test.go index afd5abc..f22c7ee 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -24,16 +24,16 @@ func listTestEnv(t *testing.T) string { now := time.Now().UTC().Truncate(time.Second) slug := dirName[11:] meta := &workspace.TaskMeta{ - ID: "test", - Slug: slug, - Title: slug, - CreatedAt: now, - UpdatedAt: now, - Status: status, - Category: category, - Type: taskType, - Mode: "local", - Agent: "claude", + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Type: taskType, + Mode: "local", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) } diff --git a/cmd/new.go b/cmd/new.go index e75b53a..1242d73 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -92,7 +92,7 @@ func runNew(cmd *cobra.Command, args []string) error { Title: title, Category: category, Mode: "local", - Agent: agent, + AgentSpec: workspace.AgentSpec{Type: agent}, IsProject: newProject, SeedDir: config.ResolveSeedDir(), SkipCategoryDir: skipCategoryDir, diff --git a/cmd/notes_test.go b/cmd/notes_test.go index 6adcb53..11fa31a 100644 --- a/cmd/notes_test.go +++ b/cmd/notes_test.go @@ -57,7 +57,7 @@ func makeNotesWs(t *testing.T, root, category, dirName, status, notesBody string ID: "t", Slug: slug, Title: slug, CreatedAt: now, UpdatedAt: now, Status: status, Category: category, Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if status == "archived" { meta.ArchivedAt = &now diff --git a/cmd/open.go b/cmd/open.go index 3993137..e1f40d1 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -51,7 +51,7 @@ func runOpen(cmd *cobra.Command, args []string) error { WsPath: ws.Path, WsRoot: ws.Root, WsMeta: ws.Meta, - Agent: ws.Meta.Agent, + Agent: ws.Meta.Agent.Type, Shell: true, // open always launches a shell Force: openForce, Direct: openDirect, diff --git a/cmd/open_persistent_test.go b/cmd/open_persistent_test.go index 13796d9..6537fb8 100644 --- a/cmd/open_persistent_test.go +++ b/cmd/open_persistent_test.go @@ -35,7 +35,7 @@ func TestOpenForwardsToEntryHelperWithShellTrue(t *testing.T) { ID: "t", Slug: "open-fwd-demo", Title: "open-fwd-demo", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/cmd/path_test.go b/cmd/path_test.go index 08c90a1..b44f1be 100644 --- a/cmd/path_test.go +++ b/cmd/path_test.go @@ -47,7 +47,7 @@ func TestPathPrintsAbsolutePath(t *testing.T) { ID: "t", Slug: "path-active", Title: "path-active", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) @@ -83,7 +83,7 @@ func TestPathWorksOnArchivedWorkspace(t *testing.T) { Category: "general", Type: "task", Mode: "local", - Agent: "claude", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) diff --git a/cmd/restore_test.go b/cmd/restore_test.go index 9251920..6edb2be 100644 --- a/cmd/restore_test.go +++ b/cmd/restore_test.go @@ -34,7 +34,7 @@ func makeArchivedWs(t *testing.T, root, category, dirName string) string { Category: category, Type: "task", Mode: "local", - Agent: "claude", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) return dir @@ -149,7 +149,7 @@ func TestRestoreRefusesAlreadyActive(t *testing.T) { ID: "t", Slug: "already", Title: "already", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) diff --git a/cmd/resume.go b/cmd/resume.go index c1943b3..44134cf 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -86,7 +86,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin agent := agentOverride if agent == "" { - agent = ws.Meta.Agent + agent = ws.Meta.Agent.Type } return runWorkspaceEntry(WorkspaceEntryOptions{ diff --git a/cmd/resume_archived_polish_test.go b/cmd/resume_archived_polish_test.go index 253bfb3..06fea13 100644 --- a/cmd/resume_archived_polish_test.go +++ b/cmd/resume_archived_polish_test.go @@ -43,7 +43,7 @@ func TestResumeArchivedHintNoDuplicateError(t *testing.T) { Category: "general", Type: "task", Mode: "local", - Agent: "claude", + Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/cmd/resume_persistent_test.go b/cmd/resume_persistent_test.go index c7afd97..ae57e84 100644 --- a/cmd/resume_persistent_test.go +++ b/cmd/resume_persistent_test.go @@ -28,7 +28,7 @@ func TestRunResumeForwardsToEntryHelperWithCommandNameResume(t *testing.T) { ID: "t", Slug: "resume-fwd-demo", Title: "resume-fwd-demo", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/cmd/resume_test.go b/cmd/resume_test.go index ecae37f..b619d57 100644 --- a/cmd/resume_test.go +++ b/cmd/resume_test.go @@ -60,7 +60,7 @@ func TestResumeArchivedWorkspaceShowsRestoreHint(t *testing.T) { Category: "general", Type: "task", Mode: "local", - Agent: "claude", + Agent: workspace.AgentSpec{Type: "claude"}, } workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) diff --git a/internal/session/adopt_test.go b/internal/session/adopt_test.go index f8e67af..f9ac4ad 100644 --- a/internal/session/adopt_test.go +++ b/internal/session/adopt_test.go @@ -31,7 +31,7 @@ func newAdoptionFixture(t *testing.T) *adoptionFixture { ID: "demo-id", Slug: "demo", Title: "demo", CreatedAt: initial, UpdatedAt: initial, Status: "active", Category: "projects", Type: "project", - Mode: "local", Agent: "claude", + Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, } if err := workspace.WriteMeta(filepath.Join(ws, "task.yaml"), meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/internal/workspace/agent_spec_test.go b/internal/workspace/agent_spec_test.go new file mode 100644 index 0000000..cc3e972 --- /dev/null +++ b/internal/workspace/agent_spec_test.go @@ -0,0 +1,127 @@ +package workspace + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestAgentSpecRoundTripMappingForm(t *testing.T) { + src := []byte(` +agent: + type: opencode + command: /usr/local/bin/opencode + args: + - --model + - deepseek-coder + env: + OPENAI_API_KEY: ollama +`) + var doc struct { + Agent AgentSpec `yaml:"agent"` + } + if err := yaml.Unmarshal(src, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Agent.Type != "opencode" { + t.Errorf("Type = %q, want opencode", doc.Agent.Type) + } + if doc.Agent.Command != "/usr/local/bin/opencode" { + t.Errorf("Command = %q, want /usr/local/bin/opencode", doc.Agent.Command) + } + if got := doc.Agent.Args; len(got) != 2 || got[0] != "--model" || got[1] != "deepseek-coder" { + t.Errorf("Args = %v, want [--model deepseek-coder]", got) + } + if doc.Agent.Env["OPENAI_API_KEY"] != "ollama" { + t.Errorf("Env[OPENAI_API_KEY] = %q, want ollama", doc.Agent.Env["OPENAI_API_KEY"]) + } +} + +func TestAgentSpecLegacyScalarForm(t *testing.T) { + cases := []struct { + name string + src string + wantType string + wantCommand string + }{ + {"builtin claude", "agent: claude", "claude", ""}, + {"builtin opencode", "agent: opencode", "opencode", ""}, + {"arbitrary command", "agent: aider", "custom", "aider"}, + {"absolute path", "agent: /opt/agent", "custom", "/opt/agent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var doc struct { + Agent AgentSpec `yaml:"agent"` + } + if err := yaml.Unmarshal([]byte(tc.src), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Agent.Type != tc.wantType { + t.Errorf("Type = %q, want %q", doc.Agent.Type, tc.wantType) + } + if doc.Agent.Command != tc.wantCommand { + t.Errorf("Command = %q, want %q", doc.Agent.Command, tc.wantCommand) + } + }) + } +} + +func TestAgentSpecMissingFieldStaysEmpty(t *testing.T) { + // No `agent:` key at all — Type stays empty so resolution falls + // through to the user-level default_agent. + var doc struct { + Agent AgentSpec `yaml:"agent"` + } + if err := yaml.Unmarshal([]byte("title: x\n"), &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Agent.Type != "" || doc.Agent.Command != "" { + t.Errorf("got %+v, want zero AgentSpec", doc.Agent) + } +} + +func TestIsBuiltinAgentType(t *testing.T) { + for _, name := range []string{"claude", "opencode"} { + if !IsBuiltinAgentType(name) { + t.Errorf("IsBuiltinAgentType(%q) = false, want true", name) + } + } + for _, name := range []string{"custom", "gemini", "", "aider"} { + if IsBuiltinAgentType(name) { + t.Errorf("IsBuiltinAgentType(%q) = true, want false", name) + } + } +} + +func TestValidateAgentSpec(t *testing.T) { + cases := []struct { + name string + spec AgentSpec + wantErr string // substring; "" means no error + }{ + {"empty ok (resolved later)", AgentSpec{}, ""}, + {"claude ok", AgentSpec{Type: "claude"}, ""}, + {"opencode ok", AgentSpec{Type: "opencode"}, ""}, + {"custom needs command", AgentSpec{Type: "custom"}, "type \"custom\" requires command"}, + {"custom with command ok", AgentSpec{Type: "custom", Command: "my-agent"}, ""}, + {"unknown type rejected", AgentSpec{Type: "gemini"}, "unknown agent type"}, + {"command with space rejected", AgentSpec{Type: "claude", Command: "claude --debug"}, "must be an executable name"}, + {"command with shell metachar rejected", AgentSpec{Type: "claude", Command: "claude|grep"}, "must be an executable name"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateAgentSpec("ws", &TaskMeta{Agent: tc.spec}) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("got %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got %v, want substring %q", err, tc.wantErr) + } + }) + } +} diff --git a/internal/workspace/create.go b/internal/workspace/create.go index 3fb0f53..d3d0156 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -12,11 +12,11 @@ import ( // CreateOpts holds parameters for workspace creation. type CreateOpts struct { - Root string - Title string - Category string - Mode string - Agent string + Root string + Title string + Category string + Mode string + AgentSpec AgentSpec // IsProject switches the built-in defaults to project-oriented templates, // records type=project in task.yaml, and triggers project seed overlay. @@ -141,7 +141,7 @@ func Create(opts CreateOpts) (*CreateResult, error) { Category: opts.Category, Type: taskType, Mode: opts.Mode, - Agent: opts.Agent, + Agent: opts.AgentSpec, ArchivedAt: nil, } if opts.IsProject { diff --git a/internal/workspace/create_test.go b/internal/workspace/create_test.go index fb8ae9d..4b2990b 100644 --- a/internal/workspace/create_test.go +++ b/internal/workspace/create_test.go @@ -12,11 +12,11 @@ func TestCreateWorkspace(t *testing.T) { root := t.TempDir() opts := CreateOpts{ - Root: root, - Title: "test task", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "test task", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, } ws, err := Create(opts) @@ -68,11 +68,11 @@ func TestCreateWorkspaceNoTitle(t *testing.T) { root := t.TempDir() opts := CreateOpts{ - Root: root, - Title: "", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, } ws, err := Create(opts) @@ -96,7 +96,7 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { Title: "billing", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, } ws, err := Create(opts) @@ -119,11 +119,11 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) { func TestCreateTaskWritesTaskClaudeMD(t *testing.T) { root := t.TempDir() opts := CreateOpts{ - Root: root, - Title: "fix bug", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "fix bug", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, } ws, err := Create(opts) if err != nil { @@ -150,12 +150,12 @@ func TestCreateAppliesGeneralSeed(t *testing.T) { } opts := CreateOpts{ - Root: root, - Title: "seeded", - Category: "general", - Mode: "local", - Agent: "claude", - SeedDir: seedDir, + Root: root, + Title: "seeded", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, + SeedDir: seedDir, } ws, err := Create(opts) if err != nil { @@ -193,7 +193,7 @@ func TestCreateProjectAppliesGeneralThenProjectSeed(t *testing.T) { Title: "billing", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, SeedDir: general, ProjectSeedDir: project, @@ -222,12 +222,12 @@ func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) { t.Fatalf("write seed task.yaml: %v", err) } opts := CreateOpts{ - Root: root, - Title: "no clobber", - Category: "general", - Mode: "local", - Agent: "claude", - SeedDir: seedDir, + Root: root, + Title: "no clobber", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, + SeedDir: seedDir, } ws, err := Create(opts) if err != nil { @@ -249,7 +249,7 @@ func TestCreateSkipCategoryDirPlacesUnderRoot(t *testing.T) { Title: "billing service", Category: "projects", // recorded in metadata Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, SkipCategoryDir: true, } @@ -273,7 +273,7 @@ func TestCreateExplicitCategoryAppendsSubdir(t *testing.T) { Title: "billing service", Category: "backend", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, // SkipCategoryDir: false (default) } @@ -291,11 +291,11 @@ func TestCreateCollisionSuffix(t *testing.T) { root := t.TempDir() opts := CreateOpts{ - Root: root, - Title: "same name", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "same name", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, } ws1, _ := Create(opts) @@ -318,7 +318,7 @@ func TestCreateProjectScaffoldsSubdirAndSetsLaunchDir(t *testing.T) { Title: "litlink-v2", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, }) if err != nil { @@ -345,7 +345,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) { root := t.TempDir() first, err := Create(CreateOpts{ Root: root, Title: "dup", Category: "projects", - Mode: "local", Agent: "claude", IsProject: true, + Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, }) if err != nil { t.Fatalf("first Create: %v", err) @@ -356,7 +356,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) { second, err := Create(CreateOpts{ Root: root, Title: "dup", Category: "projects", - Mode: "local", Agent: "claude", IsProject: true, + Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, }) if err != nil { t.Fatalf("second Create: %v", err) @@ -380,7 +380,7 @@ func TestCreateDirectoryPrefixUsesLocalDate(t *testing.T) { expected := time.Now().Format("2006-01-02") // LOCAL — no .UTC() res, err := Create(CreateOpts{ Root: root, Title: "tz-check", Category: "general", - Mode: "local", Agent: "claude", + Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, }) if err != nil { t.Fatalf("Create: %v", err) @@ -397,7 +397,7 @@ func TestCreateStoresTimestampsInUTC(t *testing.T) { root := t.TempDir() res, err := Create(CreateOpts{ Root: root, Title: "tz-check-stored", Category: "general", - Mode: "local", Agent: "claude", + Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, }) if err != nil { t.Fatalf("Create: %v", err) @@ -414,7 +414,7 @@ func TestCreateTaskDoesNotScaffoldSubdir(t *testing.T) { root := t.TempDir() res, err := Create(CreateOpts{ Root: root, Title: "hello", Category: "general", - Mode: "local", Agent: "claude", IsProject: false, + Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: false, }) if err != nil { t.Fatalf("Create: %v", err) diff --git a/internal/workspace/git_test.go b/internal/workspace/git_test.go index 41730a3..05531bf 100644 --- a/internal/workspace/git_test.go +++ b/internal/workspace/git_test.go @@ -59,7 +59,7 @@ func TestCreateProjectPreservesGeneralSeedGitignore(t *testing.T) { Title: "gen gi proj", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, SeedDir: general, }) @@ -91,7 +91,7 @@ func TestCreateProjectPreservesProjectSeedGitignore(t *testing.T) { Title: "proj gi proj", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, ProjectSeedDir: project, }) @@ -116,7 +116,7 @@ func TestCreateProjectCreatesMinimalGitignoreWhenNoSeed(t *testing.T) { Title: "no seed gi", Category: "projects", Mode: "local", - Agent: "claude", + AgentSpec: AgentSpec{Type: "claude"}, IsProject: true, }) if err != nil { diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go index 3c2460e..d9d207c 100644 --- a/internal/workspace/metadata.go +++ b/internal/workspace/metadata.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/warrenronsiek/ctask/internal/lockfile" @@ -45,11 +46,118 @@ type TaskMeta struct { Category string `yaml:"category"` Type string `yaml:"type"` Mode string `yaml:"mode"` - Agent string `yaml:"agent"` + Agent AgentSpec `yaml:"agent,omitempty"` ArchivedAt *time.Time `yaml:"archived_at"` LaunchDir string `yaml:"launch_dir,omitempty"` } +// AgentSpec is the v0.6 agent profile carried in task.yaml. All fields are +// optional; a missing Type falls through to the user-level default_agent at +// resolution time (see internal/agent.Resolve). +type AgentSpec struct { + Type string `yaml:"type,omitempty"` + Command string `yaml:"command,omitempty"` + Args []string `yaml:"args,omitempty"` + Env map[string]string `yaml:"env,omitempty"` +} + +// IsBuiltinAgentType reports whether name is a built-in agent type with a +// known default command (claude, opencode). "custom" is NOT a built-in — +// it is the escape hatch. internal/agent.BuiltinProfiles must mirror this +// exact set; when a new built-in lands, update both together. +func IsBuiltinAgentType(name string) bool { + switch name { + case "claude", "opencode": + return true + default: + return false + } +} + +// UnmarshalYAML accepts either: +// - a mapping: the v0.6+ structured form, decoded field-by-field. +// - a scalar string: the legacy v0.1-v0.5 form. The scalar is PRESERVED, +// not dropped. A scalar matching a built-in name (claude, opencode) +// maps to AgentSpec{Type: }. Any other scalar — a path or an +// arbitrary command such as `aider` or `/opt/agent` — maps to +// AgentSpec{Type: "custom", Command: }, so a legacy workspace +// keeps launching the agent it was created with. +// - a null/empty node: Type stays empty so resolution falls through to +// the user-level default_agent (the agent field is effectively +// missing). +func (a *AgentSpec) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var s string + if err := node.Decode(&s); err != nil { + return fmt.Errorf("agent: %w", err) + } + switch { + case s == "": + // Empty/null — leave Type empty; resolver fills in the default. + case IsBuiltinAgentType(s): + *a = AgentSpec{Type: s} + default: + *a = AgentSpec{Type: "custom", Command: s} + } + return nil + case yaml.MappingNode: + type rawAgentSpec AgentSpec // alias to avoid recursion + var v rawAgentSpec + if err := node.Decode(&v); err != nil { + return fmt.Errorf("agent: %w", err) + } + *a = AgentSpec(v) + return nil + default: + return fmt.Errorf("agent: expected string or mapping, got node kind %d", node.Kind) + } +} + +// knownAgentTypes is the set of agent types accepted in task.yaml. "custom" +// is the escape hatch — any executable can be used, but agent.command is +// required for it. Built-in profiles (claude, opencode) live in +// internal/agent.BuiltinProfiles; this set must stay in sync with that map +// and with IsBuiltinAgentType. +var knownAgentTypes = map[string]struct{}{ + "claude": {}, + "opencode": {}, + "custom": {}, +} + +// agentCommandForbidden is the set of characters disallowed in agent.command. +// command must be an executable name or absolute/relative path — arguments +// belong in agent.args. Whitespace and shell metacharacters indicate the +// user is trying to embed args; we reject them with a hint. +const agentCommandForbidden = " \t|&;<>()$`" + +// ValidateAgentSpec enforces the v0.6 invariants on the agent block. An +// empty Type is always allowed (resolution fills in default_agent at +// launch time). Any non-empty Type must be in knownAgentTypes. type:custom +// requires a non-empty Command. Command (when set) must look like a single +// executable: no whitespace, no shell metacharacters. +func ValidateAgentSpec(slug string, m *TaskMeta) error { + if m == nil { + return nil + } + a := m.Agent + if a.Type != "" { + if _, ok := knownAgentTypes[a.Type]; !ok { + return fmt.Errorf("workspace %q: unknown agent type %q (must be claude, opencode, or custom)", + slug, a.Type) + } + } + if a.Type == "custom" && a.Command == "" { + return fmt.Errorf("workspace %q: agent type \"custom\" requires command field", + slug) + } + if a.Command != "" && strings.ContainsAny(a.Command, agentCommandForbidden) { + return fmt.Errorf("workspace %q: agent.command %q must be an executable name or path with no whitespace or shell metacharacters; put arguments in agent.args", + slug, a.Command) + } + return nil +} + // EffectiveSchemaVersion returns the schema version a meta should be // treated as. A stored value of 0 means the field was missing in // task.yaml (legacy pre-v0.6 workspace); per spec, these are treated @@ -146,6 +254,9 @@ func ReadMeta(path string) (*TaskMeta, error) { if err := ValidateWorkspaceMode(slug, &meta); err != nil { return nil, err } + if err := ValidateAgentSpec(slug, &meta); err != nil { + return nil, err + } return &meta, nil } diff --git a/internal/workspace/metadata_test.go b/internal/workspace/metadata_test.go index b4d26f3..3a80687 100644 --- a/internal/workspace/metadata_test.go +++ b/internal/workspace/metadata_test.go @@ -17,7 +17,7 @@ func TestWriteMetaLockedHappyPath(t *testing.T) { ID: "1", Slug: "s", Title: "t", CreatedAt: now, UpdatedAt: now, Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Mode: "local", Agent: AgentSpec{Type: "claude"}, } if err := WriteMetaLocked(metaPath, meta); err != nil { @@ -68,16 +68,16 @@ func TestWriteAndReadMeta(t *testing.T) { 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", - ArchivedAt: nil, + ID: "20260405-143022", + Slug: "arch-notes", + Title: "arch notes", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: AgentSpec{Type: "claude"}, + ArchivedAt: nil, } if err := WriteMeta(path, meta); err != nil { @@ -112,15 +112,15 @@ func TestMetaYAMLFieldsPresent(t *testing.T) { 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", + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: AgentSpec{Type: "claude"}, } WriteMeta(path, meta) @@ -141,16 +141,16 @@ func TestMetaTypeRoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) meta := &TaskMeta{ - ID: "20260410-120000", - Slug: "billing", - Title: "billing", - CreatedAt: now, - UpdatedAt: now, - Status: "active", - Category: "projects", - Type: "project", - Mode: "local", - Agent: "claude", + ID: "20260410-120000", + Slug: "billing", + Title: "billing", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "projects", + Type: "project", + Mode: "local", + Agent: AgentSpec{Type: "claude"}, } if err := WriteMeta(path, meta); err != nil { t.Fatalf("WriteMeta: %v", err) @@ -221,15 +221,15 @@ func TestMetaArchive(t *testing.T) { 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", + ID: "20260405-143022", + Slug: "test", + Title: "test", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "general", + Mode: "local", + Agent: AgentSpec{Type: "claude"}, } WriteMeta(path, meta) @@ -265,7 +265,7 @@ func TestMetaLaunchDirRoundTrip(t *testing.T) { Category: "projects", Type: "project", Mode: "local", - Agent: "claude", + Agent: AgentSpec{Type: "claude"}, LaunchDir: "demo", } if err := WriteMeta(path, meta); err != nil { @@ -289,8 +289,8 @@ func TestMetaLaunchDirOmittedWhenEmpty(t *testing.T) { ID: "t", Slug: "t", Title: "t", CreatedAt: time.Now().UTC().Truncate(time.Second), UpdatedAt: time.Now().UTC().Truncate(time.Second), - Status: "active", Category: "general", Type: "task", - Mode: "local", Agent: "claude", + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: AgentSpec{Type: "claude"}, } if err := WriteMeta(path, meta); err != nil { t.Fatalf("WriteMeta: %v", err) diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index ddc4a24..82c661c 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -30,16 +30,16 @@ func createTestWorkspaceFull(t *testing.T, root, category, dirName, status, task // Extract slug from dirName (skip "YYYY-MM-DD_") slug := dirName[11:] meta := &TaskMeta{ - ID: "test", - Slug: slug, - Title: slug, - CreatedAt: updatedAt, - UpdatedAt: updatedAt, - Status: status, - Category: category, - Type: taskType, - Mode: "local", - Agent: "claude", + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: updatedAt, + UpdatedAt: updatedAt, + Status: status, + Category: category, + Type: taskType, + Mode: "local", + Agent: AgentSpec{Type: "claude"}, } WriteMeta(filepath.Join(dir, "task.yaml"), meta) } @@ -175,16 +175,16 @@ func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedA os.MkdirAll(dir, 0755) slug := dirName[11:] meta := &TaskMeta{ - ID: "test", - Slug: slug, - Title: slug, - CreatedAt: updatedAt, - UpdatedAt: updatedAt, - Status: "active", - Category: "projects", - Type: "project", - Mode: "local", - Agent: "claude", + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: updatedAt, + UpdatedAt: updatedAt, + Status: "active", + Category: "projects", + Type: "project", + Mode: "local", + Agent: AgentSpec{Type: "claude"}, } WriteMeta(filepath.Join(dir, "task.yaml"), meta) } diff --git a/internal/workspace/schema_test.go b/internal/workspace/schema_test.go index 553d3c3..03eaf9c 100644 --- a/internal/workspace/schema_test.go +++ b/internal/workspace/schema_test.go @@ -13,11 +13,11 @@ import ( func TestNewMetaIncludesSchemaVersion(t *testing.T) { root := t.TempDir() result, err := Create(CreateOpts{ - Root: root, - Title: "Test schema", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "Test schema", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, }) if err != nil { t.Fatalf("Create: %v", err) @@ -37,11 +37,11 @@ func TestNewMetaIncludesSchemaVersion(t *testing.T) { func TestNewMetaIncludesWorkspaceMode(t *testing.T) { root := t.TempDir() result, err := Create(CreateOpts{ - Root: root, - Title: "Test mode", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "Test mode", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, }) if err != nil { t.Fatalf("Create: %v", err) @@ -65,11 +65,11 @@ func TestNewMetaIncludesWorkspaceMode(t *testing.T) { func TestNewMetaRoundTripWithSchemaFields(t *testing.T) { root := t.TempDir() result, err := Create(CreateOpts{ - Root: root, - Title: "Round trip", - Category: "general", - Mode: "local", - Agent: "claude", + Root: root, + Title: "Round trip", + Category: "general", + Mode: "local", + AgentSpec: AgentSpec{Type: "claude"}, }) if err != nil { t.Fatalf("Create: %v", err)