feat: absorb workspace command from CLI

Workspace, agent, and task management commands.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 21:46:16 +00:00
parent 65e3ef4d49
commit 45a78d77bc
7 changed files with 1143 additions and 0 deletions

7
cmd/workspace/cmd.go Normal file
View file

@ -0,0 +1,7 @@
package workspace
import "forge.lthn.ai/core/go/pkg/cli"
func init() {
cli.RegisterCommands(AddWorkspaceCommands)
}

289
cmd/workspace/cmd_agent.go Normal file
View file

@ -0,0 +1,289 @@
// cmd_agent.go manages persistent agent context within task workspaces.
//
// Each agent gets a directory at:
//
// .core/workspace/p{epic}/i{issue}/agents/{provider}/{agent-name}/
//
// This directory persists across invocations, allowing agents to build
// understanding over time — QA agents accumulate findings, reviewers
// track patterns, implementors record decisions.
//
// Layout:
//
// agents/
// ├── claude-opus/implementor/
// │ ├── memory.md # Persistent notes, decisions, context
// │ └── artifacts/ # Generated artifacts (reports, diffs, etc.)
// ├── claude-opus/qa/
// │ ├── memory.md
// │ └── artifacts/
// └── gemini/reviewer/
// └── memory.md
package workspace
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io"
"github.com/spf13/cobra"
)
var (
agentProvider string
agentName string
)
func addAgentCommands(parent *cobra.Command) {
agentCmd := &cobra.Command{
Use: "agent",
Short: "Manage persistent agent context within task workspaces",
}
initCmd := &cobra.Command{
Use: "init <provider/agent-name>",
Short: "Initialize an agent's context directory in the task workspace",
Long: `Creates agents/{provider}/{agent-name}/ with memory.md and artifacts/
directory. The agent can read/write memory.md across invocations to
build understanding over time.`,
Args: cobra.ExactArgs(1),
RunE: runAgentInit,
}
initCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
initCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = initCmd.MarkFlagRequired("epic")
_ = initCmd.MarkFlagRequired("issue")
agentListCmd := &cobra.Command{
Use: "list",
Short: "List agents in a task workspace",
RunE: runAgentList,
}
agentListCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
agentListCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = agentListCmd.MarkFlagRequired("epic")
_ = agentListCmd.MarkFlagRequired("issue")
pathCmd := &cobra.Command{
Use: "path <provider/agent-name>",
Short: "Print the agent's context directory path",
Args: cobra.ExactArgs(1),
RunE: runAgentPath,
}
pathCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
pathCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = pathCmd.MarkFlagRequired("epic")
_ = pathCmd.MarkFlagRequired("issue")
agentCmd.AddCommand(initCmd, agentListCmd, pathCmd)
parent.AddCommand(agentCmd)
}
// agentContextPath returns the path for an agent's context directory.
func agentContextPath(wsPath, provider, name string) string {
return filepath.Join(wsPath, "agents", provider, name)
}
// parseAgentID splits "provider/agent-name" into parts.
func parseAgentID(id string) (provider, name string, err error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("agent ID must be provider/agent-name (e.g. claude-opus/qa)")
}
return parts[0], parts[1], nil
}
// AgentManifest tracks agent metadata for a task workspace.
type AgentManifest struct {
Provider string `json:"provider"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
func runAgentInit(cmd *cobra.Command, args []string) error {
provider, name, err := parseAgentID(args[0])
if err != nil {
return err
}
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d — create it first with `core workspace task create`", taskEpic, taskIssue)
}
agentDir := agentContextPath(wsPath, provider, name)
if coreio.Local.IsDir(agentDir) {
// Update last_seen
updateAgentManifest(agentDir, provider, name)
cli.Print("Agent %s/%s already initialized at p%d/i%d\n",
cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), taskEpic, taskIssue)
cli.Print("Path: %s\n", cli.DimStyle.Render(agentDir))
return nil
}
// Create directory structure
if err := coreio.Local.EnsureDir(agentDir); err != nil {
return fmt.Errorf("failed to create agent directory: %w", err)
}
if err := coreio.Local.EnsureDir(filepath.Join(agentDir, "artifacts")); err != nil {
return fmt.Errorf("failed to create artifacts directory: %w", err)
}
// Create initial memory.md
memoryContent := fmt.Sprintf(`# %s/%s Issue #%d (EPIC #%d)
## Context
- **Task workspace:** p%d/i%d
- **Initialized:** %s
## Notes
<!-- Add observations, decisions, and findings below -->
`, provider, name, taskIssue, taskEpic, taskEpic, taskIssue, time.Now().Format(time.RFC3339))
if err := coreio.Local.Write(filepath.Join(agentDir, "memory.md"), memoryContent); err != nil {
return fmt.Errorf("failed to create memory.md: %w", err)
}
// Write manifest
updateAgentManifest(agentDir, provider, name)
cli.Print("%s Agent %s/%s initialized at p%d/i%d\n",
cli.SuccessStyle.Render("Done:"),
cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name),
taskEpic, taskIssue)
cli.Print("Memory: %s\n", cli.DimStyle.Render(filepath.Join(agentDir, "memory.md")))
return nil
}
func runAgentList(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
agentsDir := filepath.Join(wsPath, "agents")
if !coreio.Local.IsDir(agentsDir) {
cli.Println("No agents in this workspace.")
return nil
}
providers, err := coreio.Local.List(agentsDir)
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
}
found := false
for _, providerEntry := range providers {
if !providerEntry.IsDir() {
continue
}
providerDir := filepath.Join(agentsDir, providerEntry.Name())
agents, err := coreio.Local.List(providerDir)
if err != nil {
continue
}
for _, agentEntry := range agents {
if !agentEntry.IsDir() {
continue
}
found = true
agentDir := filepath.Join(providerDir, agentEntry.Name())
// Read manifest for last_seen
lastSeen := ""
manifestPath := filepath.Join(agentDir, "manifest.json")
if data, err := coreio.Local.Read(manifestPath); err == nil {
var m AgentManifest
if json.Unmarshal([]byte(data), &m) == nil {
lastSeen = m.LastSeen.Format("2006-01-02 15:04")
}
}
// Check if memory has content beyond the template
memorySize := ""
if content, err := coreio.Local.Read(filepath.Join(agentDir, "memory.md")); err == nil {
lines := len(strings.Split(content, "\n"))
memorySize = fmt.Sprintf("%d lines", lines)
}
cli.Print(" %s/%s %s",
cli.ValueStyle.Render(providerEntry.Name()),
cli.ValueStyle.Render(agentEntry.Name()),
cli.DimStyle.Render(memorySize))
if lastSeen != "" {
cli.Print(" last: %s", cli.DimStyle.Render(lastSeen))
}
cli.Print("\n")
}
}
if !found {
cli.Println("No agents in this workspace.")
}
return nil
}
func runAgentPath(cmd *cobra.Command, args []string) error {
provider, name, err := parseAgentID(args[0])
if err != nil {
return err
}
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
agentDir := agentContextPath(wsPath, provider, name)
if !coreio.Local.IsDir(agentDir) {
return cli.Err("agent %s/%s not initialized — run `core workspace agent init %s/%s`", provider, name, provider, name)
}
// Print just the path (useful for scripting: cd $(core workspace agent path ...))
cli.Text(agentDir)
return nil
}
func updateAgentManifest(agentDir, provider, name string) {
now := time.Now()
manifest := AgentManifest{
Provider: provider,
Name: name,
CreatedAt: now,
LastSeen: now,
}
// Try to preserve created_at from existing manifest
manifestPath := filepath.Join(agentDir, "manifest.json")
if data, err := coreio.Local.Read(manifestPath); err == nil {
var existing AgentManifest
if json.Unmarshal([]byte(data), &existing) == nil {
manifest.CreatedAt = existing.CreatedAt
}
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return
}
_ = coreio.Local.Write(manifestPath, string(data))
}

View file

@ -0,0 +1,79 @@
package workspace
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseAgentID_Good(t *testing.T) {
provider, name, err := parseAgentID("claude-opus/qa")
require.NoError(t, err)
assert.Equal(t, "claude-opus", provider)
assert.Equal(t, "qa", name)
}
func TestParseAgentID_Bad(t *testing.T) {
tests := []string{
"noslash",
"/missing-provider",
"missing-name/",
"",
}
for _, id := range tests {
_, _, err := parseAgentID(id)
assert.Error(t, err, "expected error for: %q", id)
}
}
func TestAgentContextPath(t *testing.T) {
path := agentContextPath("/ws/p101/i343", "claude-opus", "qa")
assert.Equal(t, "/ws/p101/i343/agents/claude-opus/qa", path)
}
func TestUpdateAgentManifest_Good(t *testing.T) {
tmp := t.TempDir()
agentDir := filepath.Join(tmp, "agents", "test-provider", "test-agent")
require.NoError(t, os.MkdirAll(agentDir, 0755))
updateAgentManifest(agentDir, "test-provider", "test-agent")
data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var m AgentManifest
require.NoError(t, json.Unmarshal(data, &m))
assert.Equal(t, "test-provider", m.Provider)
assert.Equal(t, "test-agent", m.Name)
assert.False(t, m.CreatedAt.IsZero())
assert.False(t, m.LastSeen.IsZero())
}
func TestUpdateAgentManifest_PreservesCreatedAt(t *testing.T) {
tmp := t.TempDir()
agentDir := filepath.Join(tmp, "agents", "p", "a")
require.NoError(t, os.MkdirAll(agentDir, 0755))
// First call sets created_at
updateAgentManifest(agentDir, "p", "a")
data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var first AgentManifest
require.NoError(t, json.Unmarshal(data, &first))
// Second call should preserve created_at
updateAgentManifest(agentDir, "p", "a")
data, err = os.ReadFile(filepath.Join(agentDir, "manifest.json"))
require.NoError(t, err)
var second AgentManifest
require.NoError(t, json.Unmarshal(data, &second))
assert.Equal(t, first.CreatedAt, second.CreatedAt)
assert.True(t, second.LastSeen.After(first.CreatedAt) || second.LastSeen.Equal(first.CreatedAt))
}

466
cmd/workspace/cmd_task.go Normal file
View file

@ -0,0 +1,466 @@
// cmd_task.go implements task workspace isolation using git worktrees.
//
// Each task gets an isolated workspace at .core/workspace/p{epic}/i{issue}/
// containing git worktrees of required repos. This prevents agents from
// writing to the implementor's working tree.
//
// Safety checks enforce that workspaces cannot be removed if they contain
// uncommitted changes or unpushed branches.
package workspace
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
"github.com/spf13/cobra"
)
var (
taskEpic int
taskIssue int
taskRepos []string
taskForce bool
taskBranch string
)
func addTaskCommands(parent *cobra.Command) {
taskCmd := &cobra.Command{
Use: "task",
Short: "Manage isolated task workspaces for agents",
}
createCmd := &cobra.Command{
Use: "create",
Short: "Create an isolated task workspace with git worktrees",
Long: `Creates a workspace at .core/workspace/p{epic}/i{issue}/ with git
worktrees for each specified repo. Each worktree gets a fresh branch
(issue/{id} by default) so agents work in isolation.`,
RunE: runTaskCreate,
}
createCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
createCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
createCmd.Flags().StringSliceVar(&taskRepos, "repo", nil, "Repos to include (default: all from registry)")
createCmd.Flags().StringVar(&taskBranch, "branch", "", "Branch name (default: issue/{issue})")
_ = createCmd.MarkFlagRequired("epic")
_ = createCmd.MarkFlagRequired("issue")
removeCmd := &cobra.Command{
Use: "remove",
Short: "Remove a task workspace (with safety checks)",
Long: `Removes a task workspace after checking for uncommitted changes and
unpushed branches. Use --force to skip safety checks.`,
RunE: runTaskRemove,
}
removeCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
removeCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
removeCmd.Flags().BoolVar(&taskForce, "force", false, "Skip safety checks")
_ = removeCmd.MarkFlagRequired("epic")
_ = removeCmd.MarkFlagRequired("issue")
listCmd := &cobra.Command{
Use: "list",
Short: "List all task workspaces",
RunE: runTaskList,
}
statusCmd := &cobra.Command{
Use: "status",
Short: "Show status of a task workspace",
RunE: runTaskStatus,
}
statusCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number")
statusCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number")
_ = statusCmd.MarkFlagRequired("epic")
_ = statusCmd.MarkFlagRequired("issue")
addAgentCommands(taskCmd)
taskCmd.AddCommand(createCmd, removeCmd, listCmd, statusCmd)
parent.AddCommand(taskCmd)
}
// taskWorkspacePath returns the path for a task workspace.
func taskWorkspacePath(root string, epic, issue int) string {
return filepath.Join(root, ".core", "workspace", fmt.Sprintf("p%d", epic), fmt.Sprintf("i%d", issue))
}
func runTaskCreate(cmd *cobra.Command, args []string) error {
ctx := context.Background()
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace — run from workspace root or a package directory")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace already exists: %s", wsPath)
}
branch := taskBranch
if branch == "" {
branch = fmt.Sprintf("issue/%d", taskIssue)
}
// Determine repos to include
repoNames := taskRepos
if len(repoNames) == 0 {
repoNames, err = registryRepoNames(root)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
}
}
if len(repoNames) == 0 {
return cli.Err("no repos specified and no registry found")
}
// Resolve package paths
config, _ := LoadConfig(root)
pkgDir := "./packages"
if config != nil && config.PackagesDir != "" {
pkgDir = config.PackagesDir
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(root, pkgDir)
}
if err := coreio.Local.EnsureDir(wsPath); err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
}
cli.Print("Creating task workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue)))
cli.Print("Branch: %s\n", cli.ValueStyle.Render(branch))
cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath))
var created, skipped int
for _, repoName := range repoNames {
repoPath := filepath.Join(pkgDir, repoName)
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
cli.Print(" %s %s (not cloned, skipping)\n", cli.DimStyle.Render("·"), repoName)
skipped++
continue
}
worktreePath := filepath.Join(wsPath, repoName)
cli.Print(" %s %s... ", cli.DimStyle.Render("·"), repoName)
if err := createWorktree(ctx, repoPath, worktreePath, branch); err != nil {
cli.Print("%s\n", cli.ErrorStyle.Render("x "+err.Error()))
skipped++
continue
}
cli.Print("%s\n", cli.SuccessStyle.Render("ok"))
created++
}
cli.Print("\n%s %d worktrees created", cli.SuccessStyle.Render("Done:"), created)
if skipped > 0 {
cli.Print(", %d skipped", skipped)
}
cli.Print("\n")
return nil
}
func runTaskRemove(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue)
}
if !taskForce {
dirty, reasons := checkWorkspaceSafety(wsPath)
if dirty {
cli.Print("%s Cannot remove workspace p%d/i%d:\n", cli.ErrorStyle.Render("Blocked:"), taskEpic, taskIssue)
for _, r := range reasons {
cli.Print(" %s %s\n", cli.ErrorStyle.Render("·"), r)
}
cli.Print("\nUse --force to override or resolve the issues first.\n")
return errors.New("workspace has unresolved changes")
}
}
// Remove worktrees first (so git knows they're gone)
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
}
config, _ := LoadConfig(root)
pkgDir := "./packages"
if config != nil && config.PackagesDir != "" {
pkgDir = config.PackagesDir
}
if !filepath.IsAbs(pkgDir) {
pkgDir = filepath.Join(root, pkgDir)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
repoPath := filepath.Join(pkgDir, entry.Name())
// Remove worktree from git
if coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
removeWorktree(repoPath, worktreePath)
}
}
// Remove the workspace directory
if err := coreio.Local.DeleteAll(wsPath); err != nil {
return fmt.Errorf("failed to remove workspace directory: %w", err)
}
// Clean up empty parent (p{epic}/) if it's now empty
epicDir := filepath.Dir(wsPath)
if entries, err := coreio.Local.List(epicDir); err == nil && len(entries) == 0 {
coreio.Local.DeleteAll(epicDir)
}
cli.Print("%s Removed workspace p%d/i%d\n", cli.SuccessStyle.Render("Done:"), taskEpic, taskIssue)
return nil
}
func runTaskList(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsRoot := filepath.Join(root, ".core", "workspace")
if !coreio.Local.IsDir(wsRoot) {
cli.Println("No task workspaces found.")
return nil
}
epics, err := coreio.Local.List(wsRoot)
if err != nil {
return fmt.Errorf("failed to list workspaces: %w", err)
}
found := false
for _, epicEntry := range epics {
if !epicEntry.IsDir() || !strings.HasPrefix(epicEntry.Name(), "p") {
continue
}
epicDir := filepath.Join(wsRoot, epicEntry.Name())
issues, err := coreio.Local.List(epicDir)
if err != nil {
continue
}
for _, issueEntry := range issues {
if !issueEntry.IsDir() || !strings.HasPrefix(issueEntry.Name(), "i") {
continue
}
found = true
wsPath := filepath.Join(epicDir, issueEntry.Name())
// Count worktrees
entries, _ := coreio.Local.List(wsPath)
dirCount := 0
for _, e := range entries {
if e.IsDir() {
dirCount++
}
}
// Check safety
dirty, _ := checkWorkspaceSafety(wsPath)
status := cli.SuccessStyle.Render("clean")
if dirty {
status = cli.ErrorStyle.Render("dirty")
}
cli.Print(" %s/%s %d repos %s\n",
epicEntry.Name(), issueEntry.Name(),
dirCount, status)
}
}
if !found {
cli.Println("No task workspaces found.")
}
return nil
}
func runTaskStatus(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
wsPath := taskWorkspacePath(root, taskEpic, taskIssue)
if !coreio.Local.IsDir(wsPath) {
return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue)
}
cli.Print("Workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue)))
cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath))
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
// Get branch
branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
branch = strings.TrimSpace(branch)
// Get status
status := gitOutput(worktreePath, "status", "--porcelain")
statusLabel := cli.SuccessStyle.Render("clean")
if strings.TrimSpace(status) != "" {
lines := len(strings.Split(strings.TrimSpace(status), "\n"))
statusLabel = cli.ErrorStyle.Render(fmt.Sprintf("%d changes", lines))
}
// Get unpushed
unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD")
unpushedLabel := ""
if trimmed := strings.TrimSpace(unpushed); trimmed != "" {
count := len(strings.Split(trimmed, "\n"))
unpushedLabel = cli.WarningStyle.Render(fmt.Sprintf(" %d unpushed", count))
}
cli.Print(" %s %s %s%s\n",
cli.RepoStyle.Render(entry.Name()),
cli.DimStyle.Render(branch),
statusLabel,
unpushedLabel)
}
return nil
}
// createWorktree adds a git worktree at worktreePath for the given branch.
func createWorktree(ctx context.Context, repoPath, worktreePath, branch string) error {
// Check if branch exists on remote first
cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branch, worktreePath)
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
errStr := strings.TrimSpace(string(output))
// If branch already exists, try without -b
if strings.Contains(errStr, "already exists") {
cmd = exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branch)
cmd.Dir = repoPath
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
}
return nil
}
return fmt.Errorf("%s", errStr)
}
return nil
}
// removeWorktree removes a git worktree.
func removeWorktree(repoPath, worktreePath string) {
cmd := exec.Command("git", "worktree", "remove", worktreePath)
cmd.Dir = repoPath
_ = cmd.Run()
// Prune stale worktrees
cmd = exec.Command("git", "worktree", "prune")
cmd.Dir = repoPath
_ = cmd.Run()
}
// checkWorkspaceSafety checks all worktrees in a workspace for uncommitted/unpushed changes.
func checkWorkspaceSafety(wsPath string) (dirty bool, reasons []string) {
entries, err := coreio.Local.List(wsPath)
if err != nil {
return false, nil
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worktreePath := filepath.Join(wsPath, entry.Name())
// Check for uncommitted changes
status := gitOutput(worktreePath, "status", "--porcelain")
if strings.TrimSpace(status) != "" {
dirty = true
reasons = append(reasons, fmt.Sprintf("%s: has uncommitted changes", entry.Name()))
}
// Check for unpushed commits
unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD")
if strings.TrimSpace(unpushed) != "" {
dirty = true
count := len(strings.Split(strings.TrimSpace(unpushed), "\n"))
reasons = append(reasons, fmt.Sprintf("%s: %d unpushed commits", entry.Name(), count))
}
}
return dirty, reasons
}
// gitOutput runs a git command and returns stdout.
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, _ := cmd.Output()
return string(out)
}
// registryRepoNames returns repo names from the workspace registry.
func registryRepoNames(root string) ([]string, error) {
// Try to find repos.yaml
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return nil, err
}
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return nil, err
}
var names []string
for _, repo := range reg.List() {
// Only include cloneable repos
if repo.Clone != nil && !*repo.Clone {
continue
}
// Skip meta repos
if repo.Type == "meta" {
continue
}
names = append(names, repo.Name)
}
return names, nil
}
// epicBranchName returns the branch name for an EPIC.
func epicBranchName(epicID int) string {
return "epic/" + strconv.Itoa(epicID)
}

View file

@ -0,0 +1,109 @@
package workspace
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper()
repoPath := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(repoPath, 0755))
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
{"git", "commit", "--allow-empty", "-m", "initial"},
}
for _, c := range cmds {
cmd := exec.Command(c[0], c[1:]...)
cmd.Dir = repoPath
out, err := cmd.CombinedOutput()
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
}
return repoPath
}
func TestTaskWorkspacePath(t *testing.T) {
path := taskWorkspacePath("/home/user/Code/host-uk", 101, 343)
assert.Equal(t, "/home/user/Code/host-uk/.core/workspace/p101/i343", path)
}
func TestCreateWorktree_Good(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "test-repo")
worktreePath := filepath.Join(tmp, "workspace", "test-repo")
err := createWorktree(t.Context(), repoPath, worktreePath, "issue/123")
require.NoError(t, err)
// Verify worktree exists
assert.DirExists(t, worktreePath)
assert.FileExists(t, filepath.Join(worktreePath, ".git"))
// Verify branch
branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
assert.Equal(t, "issue/123", trimNL(branch))
}
func TestCreateWorktree_BranchExists(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "test-repo")
// Create branch first
cmd := exec.Command("git", "branch", "issue/456")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
worktreePath := filepath.Join(tmp, "workspace", "test-repo")
err := createWorktree(t.Context(), repoPath, worktreePath, "issue/456")
require.NoError(t, err)
assert.DirExists(t, worktreePath)
}
func TestCheckWorkspaceSafety_Clean(t *testing.T) {
tmp := t.TempDir()
wsPath := filepath.Join(tmp, "workspace")
require.NoError(t, os.MkdirAll(wsPath, 0755))
repoPath := setupTestRepo(t, tmp, "origin-repo")
worktreePath := filepath.Join(wsPath, "origin-repo")
require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch"))
dirty, reasons := checkWorkspaceSafety(wsPath)
assert.False(t, dirty)
assert.Empty(t, reasons)
}
func TestCheckWorkspaceSafety_Dirty(t *testing.T) {
tmp := t.TempDir()
wsPath := filepath.Join(tmp, "workspace")
require.NoError(t, os.MkdirAll(wsPath, 0755))
repoPath := setupTestRepo(t, tmp, "origin-repo")
worktreePath := filepath.Join(wsPath, "origin-repo")
require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch"))
// Create uncommitted file
require.NoError(t, os.WriteFile(filepath.Join(worktreePath, "dirty.txt"), []byte("dirty"), 0644))
dirty, reasons := checkWorkspaceSafety(wsPath)
assert.True(t, dirty)
assert.Contains(t, reasons[0], "uncommitted changes")
}
func TestEpicBranchName(t *testing.T) {
assert.Equal(t, "epic/101", epicBranchName(101))
assert.Equal(t, "epic/42", epicBranchName(42))
}
func trimNL(s string) string {
return s[:len(s)-1]
}

View file

@ -0,0 +1,90 @@
package workspace
import (
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"github.com/spf13/cobra"
)
// AddWorkspaceCommands registers workspace management commands.
func AddWorkspaceCommands(root *cobra.Command) {
wsCmd := &cobra.Command{
Use: "workspace",
Short: "Manage workspace configuration",
RunE: runWorkspaceInfo,
}
wsCmd.AddCommand(&cobra.Command{
Use: "active [package]",
Short: "Show or set the active package",
RunE: runWorkspaceActive,
})
addTaskCommands(wsCmd)
root.AddCommand(wsCmd)
}
func runWorkspaceInfo(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
config, err := LoadConfig(root)
if err != nil {
return err
}
if config == nil {
return cli.Err("workspace config not found")
}
cli.Print("Active: %s\n", cli.ValueStyle.Render(config.Active))
cli.Print("Packages: %s\n", cli.DimStyle.Render(config.PackagesDir))
if len(config.DefaultOnly) > 0 {
cli.Print("Types: %s\n", cli.DimStyle.Render(strings.Join(config.DefaultOnly, ", ")))
}
return nil
}
func runWorkspaceActive(cmd *cobra.Command, args []string) error {
root, err := FindWorkspaceRoot()
if err != nil {
return cli.Err("not in a workspace")
}
config, err := LoadConfig(root)
if err != nil {
return err
}
if config == nil {
config = DefaultConfig()
}
// If no args, show active
if len(args) == 0 {
if config.Active == "" {
cli.Println("No active package set")
return nil
}
cli.Text(config.Active)
return nil
}
// Set active
target := args[0]
if target == config.Active {
cli.Print("Active package is already %s\n", cli.ValueStyle.Render(target))
return nil
}
config.Active = target
if err := SaveConfig(root, config); err != nil {
return err
}
cli.Print("Active package set to %s\n", cli.SuccessStyle.Render(target))
return nil
}

103
cmd/workspace/config.go Normal file
View file

@ -0,0 +1,103 @@
package workspace
import (
"fmt"
"os"
"path/filepath"
coreio "forge.lthn.ai/core/go/pkg/io"
"gopkg.in/yaml.v3"
)
// WorkspaceConfig holds workspace-level configuration from .core/workspace.yaml.
type WorkspaceConfig struct {
Version int `yaml:"version"`
Active string `yaml:"active"` // Active package name
DefaultOnly []string `yaml:"default_only"` // Default types for setup
PackagesDir string `yaml:"packages_dir"` // Where packages are cloned
}
// DefaultConfig returns a config with default values.
func DefaultConfig() *WorkspaceConfig {
return &WorkspaceConfig{
Version: 1,
PackagesDir: "./packages",
}
}
// LoadConfig tries to load workspace.yaml from the given directory's .core subfolder.
// Returns nil if no config file exists (caller should check for nil).
func LoadConfig(dir string) (*WorkspaceConfig, error) {
path := filepath.Join(dir, ".core", "workspace.yaml")
data, err := coreio.Local.Read(path)
if err != nil {
// If using Local.Read, it returns error on not found.
// We can check if file exists first or handle specific error if exposed.
// Simplest is to check existence first or assume IsNotExist.
// Since we don't have easy IsNotExist check on coreio error returned yet (uses wrapped error),
// let's check IsFile first.
if !coreio.Local.IsFile(path) {
// Try parent directory
parent := filepath.Dir(dir)
if parent != dir {
return LoadConfig(parent)
}
// No workspace.yaml found anywhere - return nil to indicate no config
return nil, nil
}
return nil, fmt.Errorf("failed to read workspace config: %w", err)
}
config := DefaultConfig()
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
}
if config.Version != 1 {
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
}
return config, nil
}
// SaveConfig saves the configuration to the given directory's .core/workspace.yaml.
func SaveConfig(dir string, config *WorkspaceConfig) error {
coreDir := filepath.Join(dir, ".core")
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
}
path := filepath.Join(coreDir, "workspace.yaml")
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal workspace config: %w", err)
}
if err := coreio.Local.Write(path, string(data)); err != nil {
return fmt.Errorf("failed to write workspace config: %w", err)
}
return nil
}
// FindWorkspaceRoot searches for the root directory containing .core/workspace.yaml.
func FindWorkspaceRoot() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if coreio.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("not in a workspace")
}