// 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 ", 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 ", 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 `, 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)) }