feat(crew): Provision .claude/commands/ for crew and polecat workspaces (gt-jhr85)
When adding a crew member with 'gt crew add' or spawning a polecat, provision the .claude/commands/ directory with standard slash commands like /handoff. This ensures all agents have Gas Town utilities even if the source repo does not have them tracked. Changes: - Add embedded commands templates (internal/templates/commands/) - Add ProvisionCommands() to templates package - Call ProvisionCommands from crew and polecat managers - Add gt doctor commands-provisioned check with --fix support
This commit is contained in:
@@ -137,6 +137,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Crew workspace checks
|
// Crew workspace checks
|
||||||
d.Register(doctor.NewCrewStateCheck())
|
d.Register(doctor.NewCrewStateCheck())
|
||||||
|
d.Register(doctor.NewCommandsCheck())
|
||||||
|
|
||||||
// Lifecycle hygiene checks
|
// Lifecycle hygiene checks
|
||||||
d.Register(doctor.NewLifecycleHygieneCheck())
|
d.Register(doctor.NewLifecycleHygieneCheck())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/util"
|
"github.com/steveyegge/gastown/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +105,13 @@ func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) {
|
|||||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision .claude/commands/ with standard slash commands (e.g., /handoff)
|
||||||
|
// This ensures crew workers have Gas Town utilities even if source repo lacks them.
|
||||||
|
if err := templates.ProvisionCommands(crewPath); err != nil {
|
||||||
|
// Non-fatal - crew can still work, warn but don't fail
|
||||||
|
fmt.Printf("Warning: could not provision slash commands: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: We intentionally do NOT write to CLAUDE.md here.
|
// NOTE: We intentionally do NOT write to CLAUDE.md here.
|
||||||
// Gas Town context is injected ephemerally via SessionStart hook (gt prime).
|
// Gas Town context is injected ephemerally via SessionStart hook (gt prime).
|
||||||
// Writing to CLAUDE.md would overwrite project instructions and leak
|
// Writing to CLAUDE.md would overwrite project instructions and leak
|
||||||
|
|||||||
169
internal/doctor/commands_check.go
Normal file
169
internal/doctor/commands_check.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommandsCheck validates that crew/polecat workspaces have .claude/commands/ provisioned.
|
||||||
|
// This ensures all agents have access to slash commands like /handoff.
|
||||||
|
type CommandsCheck struct {
|
||||||
|
FixableCheck
|
||||||
|
missingWorkspaces []workspaceWithMissingCommands // Cached during Run for use in Fix
|
||||||
|
}
|
||||||
|
|
||||||
|
type workspaceWithMissingCommands struct {
|
||||||
|
path string
|
||||||
|
rigName string
|
||||||
|
workerName string
|
||||||
|
workerType string // "crew" or "polecat"
|
||||||
|
missingFiles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommandsCheck creates a new commands check.
|
||||||
|
func NewCommandsCheck() *CommandsCheck {
|
||||||
|
return &CommandsCheck{
|
||||||
|
FixableCheck: FixableCheck{
|
||||||
|
BaseCheck: BaseCheck{
|
||||||
|
CheckName: "commands-provisioned",
|
||||||
|
CheckDescription: "Check .claude/commands/ is provisioned in crew/polecat workspaces",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run checks all crew and polecat workspaces for missing slash commands.
|
||||||
|
func (c *CommandsCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
|
c.missingWorkspaces = nil
|
||||||
|
|
||||||
|
workspaces := c.findAllWorkerDirs(ctx.TownRoot)
|
||||||
|
if len(workspaces) == 0 {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No crew/polecat workspaces found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCount int
|
||||||
|
var details []string
|
||||||
|
|
||||||
|
for _, ws := range workspaces {
|
||||||
|
missing, err := templates.MissingCommands(ws.path)
|
||||||
|
if err != nil {
|
||||||
|
details = append(details, fmt.Sprintf("%s/%s/%s: error checking commands: %v",
|
||||||
|
ws.rigName, ws.workerType, ws.workerName, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
c.missingWorkspaces = append(c.missingWorkspaces, workspaceWithMissingCommands{
|
||||||
|
path: ws.path,
|
||||||
|
rigName: ws.rigName,
|
||||||
|
workerName: ws.workerName,
|
||||||
|
workerType: ws.workerType,
|
||||||
|
missingFiles: missing,
|
||||||
|
})
|
||||||
|
details = append(details, fmt.Sprintf("%s/%s/%s: missing %s",
|
||||||
|
ws.rigName, ws.workerType, ws.workerName, strings.Join(missing, ", ")))
|
||||||
|
} else {
|
||||||
|
validCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.missingWorkspaces) == 0 {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: fmt.Sprintf("All %d workspaces have slash commands provisioned", validCount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: fmt.Sprintf("%d workspace(s) missing slash commands (e.g., /handoff)", len(c.missingWorkspaces)),
|
||||||
|
Details: details,
|
||||||
|
FixHint: "Run 'gt doctor --fix' to provision missing commands",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix provisions missing slash commands to workspaces.
|
||||||
|
func (c *CommandsCheck) Fix(ctx *CheckContext) error {
|
||||||
|
if len(c.missingWorkspaces) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, ws := range c.missingWorkspaces {
|
||||||
|
if err := templates.ProvisionCommands(ws.path); err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s/%s/%s: %w", ws.rigName, ws.workerType, ws.workerName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type workerDir struct {
|
||||||
|
path string
|
||||||
|
rigName string
|
||||||
|
workerName string
|
||||||
|
workerType string // "crew" or "polecat"
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAllWorkerDirs finds all crew and polecat directories in the workspace.
|
||||||
|
func (c *CommandsCheck) findAllWorkerDirs(townRoot string) []workerDir {
|
||||||
|
var dirs []workerDir
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rigName := entry.Name()
|
||||||
|
|
||||||
|
// Check crew directory
|
||||||
|
crewPath := filepath.Join(townRoot, rigName, "crew")
|
||||||
|
if crewEntries, err := os.ReadDir(crewPath); err == nil {
|
||||||
|
for _, crew := range crewEntries {
|
||||||
|
if !crew.IsDir() || strings.HasPrefix(crew.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirs = append(dirs, workerDir{
|
||||||
|
path: filepath.Join(crewPath, crew.Name()),
|
||||||
|
rigName: rigName,
|
||||||
|
workerName: crew.Name(),
|
||||||
|
workerType: "crew",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check polecats directory
|
||||||
|
polecatsPath := filepath.Join(townRoot, rigName, "polecats")
|
||||||
|
if polecatEntries, err := os.ReadDir(polecatsPath); err == nil {
|
||||||
|
for _, polecat := range polecatEntries {
|
||||||
|
if !polecat.IsDir() || strings.HasPrefix(polecat.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirs = append(dirs, workerDir{
|
||||||
|
path: filepath.Join(polecatsPath, polecat.Name()),
|
||||||
|
rigName: rigName,
|
||||||
|
workerName: polecat.Name(),
|
||||||
|
workerType: "polecat",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,6 +245,13 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error)
|
|||||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision .claude/commands/ with standard slash commands (e.g., /handoff)
|
||||||
|
// This ensures polecats have Gas Town utilities even if source repo lacks them.
|
||||||
|
if err := templates.ProvisionCommands(polecatPath); err != nil {
|
||||||
|
// Non-fatal - polecat can still work, warn but don't fail
|
||||||
|
fmt.Printf("Warning: could not provision slash commands: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create agent bead for ZFC compliance (self-report state).
|
// Create agent bead for ZFC compliance (self-report state).
|
||||||
// State starts as "spawning" - will be updated to "working" when Claude starts.
|
// State starts as "spawning" - will be updated to "working" when Claude starts.
|
||||||
// HookBead is set atomically at creation time if provided (avoids cross-beads routing issues).
|
// HookBead is set atomically at creation time if provided (avoids cross-beads routing issues).
|
||||||
@@ -460,6 +468,11 @@ func (m *Manager) RecreateWithOptions(name string, force bool, opts AddOptions)
|
|||||||
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
fmt.Printf("Warning: could not set up shared beads: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provision .claude/commands/ with standard slash commands (e.g., /handoff)
|
||||||
|
if err := templates.ProvisionCommands(polecatPath); err != nil {
|
||||||
|
fmt.Printf("Warning: could not provision slash commands: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create fresh agent bead for ZFC compliance
|
// Create fresh agent bead for ZFC compliance
|
||||||
// HookBead is set atomically at recreation time if provided.
|
// HookBead is set atomically at recreation time if provided.
|
||||||
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
_, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{
|
||||||
|
|||||||
21
internal/templates/commands/handoff.md
Normal file
21
internal/templates/commands/handoff.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
description: Hand off to fresh session, work continues from hook
|
||||||
|
allowed-tools: Bash(gt mail send:*),Bash(gt handoff:*)
|
||||||
|
argument-hint: [message]
|
||||||
|
---
|
||||||
|
|
||||||
|
Hand off to a fresh session.
|
||||||
|
|
||||||
|
User's handoff message (if any): $ARGUMENTS
|
||||||
|
|
||||||
|
Execute these steps in order:
|
||||||
|
|
||||||
|
1. If user provided a message, send handoff mail to yourself first.
|
||||||
|
Construct your mail address from your identity (e.g., gastown/crew/max for crew, mayor/ for mayor).
|
||||||
|
Example: `gt mail send gastown/crew/max -s "HANDOFF: Session cycling" -m "USER_MESSAGE_HERE"`
|
||||||
|
|
||||||
|
2. Run the handoff command (this will respawn your session with a fresh Claude):
|
||||||
|
`gt handoff`
|
||||||
|
|
||||||
|
Note: The new session will auto-prime via the SessionStart hook and find your handoff mail.
|
||||||
|
End watch. A new session takes over, picking up any molecule on the hook.
|
||||||
@@ -5,12 +5,17 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed roles/*.md.tmpl messages/*.md.tmpl
|
//go:embed roles/*.md.tmpl messages/*.md.tmpl
|
||||||
var templateFS embed.FS
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed commands/*.md
|
||||||
|
var commandsFS embed.FS
|
||||||
|
|
||||||
// Templates manages role and message templates.
|
// Templates manages role and message templates.
|
||||||
type Templates struct {
|
type Templates struct {
|
||||||
roleTemplates *template.Template
|
roleTemplates *template.Template
|
||||||
@@ -148,3 +153,90 @@ func GetAllRoleTemplates() (map[string][]byte, error) {
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvisionCommands creates the .claude/commands/ directory with standard slash commands.
|
||||||
|
// This ensures crew/polecat workspaces have the handoff command and other utilities
|
||||||
|
// even if the source repo doesn't have them tracked.
|
||||||
|
// If a command already exists, it is skipped (no overwrite).
|
||||||
|
func ProvisionCommands(workspacePath string) error {
|
||||||
|
entries, err := commandsFS.ReadDir("commands")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading commands directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .claude/commands/ directory
|
||||||
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
||||||
|
if err := os.MkdirAll(commandsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating commands directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(commandsDir, entry.Name())
|
||||||
|
|
||||||
|
// Skip if command already exists (don't overwrite user customizations)
|
||||||
|
if _, err := os.Stat(destPath); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := commandsFS.ReadFile("commands/" + entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandNames returns the list of embedded slash commands.
|
||||||
|
func CommandNames() ([]string, error) {
|
||||||
|
entries, err := commandsFS.ReadDir("commands")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading commands directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCommands checks if a workspace has the .claude/commands/ directory provisioned.
|
||||||
|
func HasCommands(workspacePath string) bool {
|
||||||
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
||||||
|
info, err := os.Stat(commandsDir)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingCommands returns the list of embedded commands missing from the workspace.
|
||||||
|
func MissingCommands(workspacePath string) ([]string, error) {
|
||||||
|
entries, err := commandsFS.ReadDir("commands")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading commands directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(commandsDir, entry.Name())
|
||||||
|
if _, err := os.Stat(destPath); os.IsNotExist(err) {
|
||||||
|
missing = append(missing, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user