From 937a1c8216262c249de2dc140b974503054de0c0 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 21:57:20 -0400 Subject: [PATCH] feat(v0.6): info source attribution on Agent and Launch session mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds source-attribution rendering to `ctask info` for the two Phase 1 settings whose effective value depends on user-level defaults: the agent recorded in task.yaml and the configured launch session mode. cmd/info.go: - runInfo loads the resolver once (config.LoadResolver) and reuses it across the rendering — matches the user's correction that "new doctor/info code should load the resolver once and reuse it". - Agent line now reads `Agent: (workspace)` when task.yaml has a non-empty agent (the common case), or `Agent: (default)` / `Agent: (default — )` when the field is empty and the value comes from the resolver fallback chain. The fallback path is informational only: every workspace created by recent ctask versions writes the resolved default into task.yaml at Create time, so the (workspace) branch is what users normally see. - New "Launch session mode:" line lives immediately after Agent and before Created — outside the v0.5.4 Session block per the user's placement decision ("Keep it outside the Session block because it represents the configured launch default, not the current session lease mode"). Format: `Launch session mode: ()`. - Two small helpers added: agentLineWithSource composes the agent payload + label; infoSourceLabel renders a single-row source string (CTASK_X env var / config file / built-in default / platform override). infoSourceLabel intentionally omits the override-chain suffix used by doctor — info's row layout has no room for the extra parenthetical. cmd/info_attribution_test.go (5 cases): - TestInfoAgentSourceWorkspace — task.yaml with agent set → "(workspace)" - TestInfoAgentSourceDefaultForLegacy — empty agent → "(default)" - TestInfoLaunchSessionModeFromConfig — config session_mode value + "config file" source label - TestInfoLaunchSessionModeBuiltinDefault — no config, no env → "direct (built-in default)" - TestInfoLaunchSessionModeAfterAgentBeforeCreated — placement check: Agent < Launch session mode < Created in the rendered output Smoke-verified against an existing v0.5.x workspace on the installed binary; render order and source labels match the spec example. --- cmd/info.go | 52 +++++++++- cmd/info_attribution_test.go | 187 +++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 cmd/info_attribution_test.go diff --git a/cmd/info.go b/cmd/info.go index cc2579d..667edce 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -32,12 +32,20 @@ func runInfo(cmd *cobra.Command, args []string) error { ws := resolveOne(roots, args[0], true) m := ws.Meta + // v0.6: load the resolver once and reuse it across this info + // invocation. The Agent line gains a workspace-vs-default source + // label; a new Launch session mode line surfaces the configured + // launch default with its own source attribution. + resolver := config.LoadResolver() + 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("Agent: %s\n", agentLineWithSource(m.Agent, 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; // info converts for friendliness so the shown time matches the user's // wall clock. @@ -85,6 +93,48 @@ func runInfo(cmd *cobra.Command, args []string) error { return nil } +// agentLineWithSource builds the value portion of info's Agent line +// with v0.6 source attribution. When task.yaml has a non-empty agent +// field, that value is workspace state and surfaces as +// " (workspace)". When the field is empty (legacy or +// hand-crafted task.yaml), the line falls through to the user-level +// default chain and is labelled " (default)" plus the source +// when the default did NOT come from the built-in. +// +// The fallback path is informational only: ctask new always writes +// the resolved default into task.yaml, so the legacy branch never +// fires for workspaces created by recent versions. +func agentLineWithSource(workspaceAgent string, r *config.Resolver) string { + if workspaceAgent != "" { + return workspaceAgent + " (workspace)" + } + s := r.DefaultAgent() + if s.Source == config.Builtin { + return s.Value + " (default)" + } + return fmt.Sprintf("%s (default — %s)", s.Value, infoSourceLabel(s)) +} + +// infoSourceLabel renders a setting's source for info output. Mirrors +// formatSettingSource in doctor.go but without the override-chain +// suffix; info lines are single-row and cannot fit the extra "(...)" +// payload cleanly. Env-var sources surface their CTASK_X env-var name +// (more specific than the bare "env var" label) so the user can spot +// which shell variable to inspect. +func infoSourceLabel(s config.ResolvedSetting) string { + switch s.Source { + case config.EnvVar: + if s.EnvName != "" { + return s.EnvName + " env var" + } + return "env var" + case config.PlatformOverride: + return "platform override" + default: + return s.Source.String() + } +} + // printSessionBlock renders the v0.5.4 Session block for `ctask info`. // // Layout (values align at column 14 across the block): diff --git a/cmd/info_attribution_test.go b/cmd/info_attribution_test.go new file mode 100644 index 0000000..fce98e9 --- /dev/null +++ b/cmd/info_attribution_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// makeTestWorkspace plants a minimal workspace at root with the +// given meta and returns the directory path. +func makeTestWorkspace(t *testing.T, root string, m *workspace.TaskMeta) string { + t.Helper() + wsDir := filepath.Join(root, m.Category, "2026-05-14_"+m.Slug) + if err := os.MkdirAll(wsDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), m); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + return wsDir +} + +// TestInfoAgentSourceWorkspace — when task.yaml has an agent field, info +// labels the Agent line with the (workspace) source. +func TestInfoAgentSourceWorkspace(t *testing.T) { + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + clearResolverEnv(t) + + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + makeTestWorkspace(t, root, &workspace.TaskMeta{ + ID: "t", Slug: "agentwsdemo", Title: "agentwsdemo", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "opencode", + }) + + out, err := runInfoCapture(t, root, "agentwsdemo") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Agent:") { + t.Fatalf("output missing Agent line:\n%s", out) + } + if !strings.Contains(out, "opencode") { + t.Errorf("expected agent value 'opencode' in output:\n%s", out) + } + if !strings.Contains(out, "(workspace)") { + t.Errorf("expected (workspace) source label, got:\n%s", out) + } +} + +// TestInfoAgentSourceDefaultForLegacy — task.yaml with empty agent (a +// legacy or hand-crafted workspace) falls through to the resolver and +// is labelled with the default source. +func TestInfoAgentSourceDefaultForLegacy(t *testing.T) { + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + clearResolverEnv(t) + + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + makeTestWorkspace(t, root, &workspace.TaskMeta{ + ID: "t", Slug: "legacyagent", Title: "legacy agent", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "", // legacy: no agent recorded + }) + + out, err := runInfoCapture(t, root, "legacyagent") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Agent:") { + t.Fatalf("output missing Agent line:\n%s", out) + } + if !strings.Contains(out, "claude") { + t.Errorf("expected fallback agent 'claude' in output:\n%s", out) + } + if !strings.Contains(out, "(default)") { + t.Errorf("expected (default) source label, got:\n%s", out) + } +} + +// TestInfoLaunchSessionModeFromConfig — the new "Launch session mode" +// line surfaces between Agent and Created, with its source label. +func TestInfoLaunchSessionModeFromConfig(t *testing.T) { + clearResolverEnv(t) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("session_mode: persistent\n"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + config.SetConfigPathForTest(t, cfgPath) + + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + makeTestWorkspace(t, root, &workspace.TaskMeta{ + ID: "t", Slug: "modesrc", Title: "mode src", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + }) + + out, err := runInfoCapture(t, root, "modesrc") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Launch session mode:") { + t.Errorf("expected 'Launch session mode:' line, got:\n%s", out) + } + if !strings.Contains(out, "persistent") { + t.Errorf("expected mode value 'persistent', got:\n%s", out) + } + if !strings.Contains(out, "config file") { + t.Errorf("expected 'config file' source label, got:\n%s", out) + } +} + +// TestInfoLaunchSessionModeBuiltinDefault — without env/config, the +// line shows the built-in default with the right source label. +func TestInfoLaunchSessionModeBuiltinDefault(t *testing.T) { + clearResolverEnv(t) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + makeTestWorkspace(t, root, &workspace.TaskMeta{ + ID: "t", Slug: "modedefault", Title: "mode default", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + }) + + out, err := runInfoCapture(t, root, "modedefault") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + if !strings.Contains(out, "Launch session mode: direct") { + t.Errorf("expected 'Launch session mode: direct', got:\n%s", out) + } + if !strings.Contains(out, "built-in default") { + t.Errorf("expected 'built-in default' source label, got:\n%s", out) + } +} + +// TestInfoLaunchSessionModeAfterAgentBeforeCreated — placement check: +// the line lives between Agent and Created, outside the v0.5.4 Session +// block. +func TestInfoLaunchSessionModeAfterAgentBeforeCreated(t *testing.T) { + clearResolverEnv(t) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + root := t.TempDir() + now := time.Now().UTC().Truncate(time.Second) + makeTestWorkspace(t, root, &workspace.TaskMeta{ + ID: "t", Slug: "placement", Title: "placement", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + }) + + out, err := runInfoCapture(t, root, "placement") + if err != nil { + t.Fatalf("runInfo: %v", err) + } + agentIdx := strings.Index(out, "Agent:") + modeIdx := strings.Index(out, "Launch session mode:") + createdIdx := strings.Index(out, "Created:") + if agentIdx < 0 || modeIdx < 0 || createdIdx < 0 { + t.Fatalf("missing one of Agent/Launch session mode/Created:\n%s", out) + } + if !(agentIdx < modeIdx && modeIdx < createdIdx) { + t.Errorf("expected Agent < Launch session mode < Created order; got Agent@%d Mode@%d Created@%d:\n%s", + agentIdx, modeIdx, createdIdx, out) + } +}