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.
This commit is contained in:
2026-05-15 10:58:06 -04:00
parent 6c4c3e8df2
commit 8120c399df
31 changed files with 427 additions and 189 deletions
+10 -10
View File
@@ -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
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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)
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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;
+5 -5
View File
@@ -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")
+6 -6
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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{
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)