diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 222facc7..98b04ad6 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -514,11 +514,12 @@ func (b *Beads) IsBeadsRepo() bool { // AgentFields holds structured fields for agent beads. // These are stored as "key: value" lines in the description. type AgentFields struct { - RoleType string // polecat, witness, refinery, deacon, mayor - Rig string // Rig name (empty for global agents like mayor/deacon) - AgentState string // spawning, working, done, stuck - HookBead string // Currently pinned work bead ID - RoleBead string // Role definition bead ID (canonical location; may not exist yet) + RoleType string // polecat, witness, refinery, deacon, mayor + Rig string // Rig name (empty for global agents like mayor/deacon) + AgentState string // spawning, working, done, stuck + HookBead string // Currently pinned work bead ID + RoleBead string // Role definition bead ID (canonical location; may not exist yet) + CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed) } // FormatAgentDescription creates a description string from agent fields. @@ -552,6 +553,12 @@ func FormatAgentDescription(title string, fields *AgentFields) string { lines = append(lines, "role_bead: null") } + if fields.CleanupStatus != "" { + lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus)) + } else { + lines = append(lines, "cleanup_status: null") + } + return strings.Join(lines, "\n") } @@ -587,6 +594,8 @@ func ParseAgentFields(description string) *AgentFields { fields.HookBead = value case "role_bead": fields.RoleBead = value + case "cleanup_status": + fields.CleanupStatus = value } } @@ -640,6 +649,26 @@ func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) erro return b.Update(id, UpdateOptions{Description: &description}) } +// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead. +// This is called by the polecat to self-report its git state (ZFC compliance). +// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed +func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error { + // First get current issue to preserve other fields + issue, err := b.Show(id) + if err != nil { + return err + } + + // Parse existing fields + fields := ParseAgentFields(issue.Description) + fields.CleanupStatus = cleanupStatus + + // Format new description + description := FormatAgentDescription(issue.Title, fields) + + return b.Update(id, UpdateOptions{Description: &description}) +} + // DeleteAgentBead permanently deletes an agent bead. // Uses --hard --force for immediate permanent deletion (no tombstone). func (b *Beads) DeleteAgentBead(id string) error { diff --git a/internal/cmd/done.go b/internal/cmd/done.go index db8a2ed5..86461981 100644 --- a/internal/cmd/done.go +++ b/internal/cmd/done.go @@ -227,6 +227,8 @@ func runDone(cmd *cobra.Command, args []string) error { // - COMPLETED → "done" // - ESCALATED → "stuck" // - DEFERRED → "idle" +// +// Also self-reports cleanup_status for ZFC compliance (#10). func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { // Get role context roleInfo, err := GetRoleWithContext(cwd, townRoot) @@ -267,4 +269,37 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, issueID string) { // Silently ignore - beads might not be configured return } + + // ZFC #10: Self-report cleanup status + // Compute git state and report so Witness can decide removal safety + cleanupStatus := computeCleanupStatus(cwd) + if cleanupStatus != "" { + if err := bd.UpdateAgentCleanupStatus(agentBeadID, cleanupStatus); err != nil { + // Silently ignore + return + } + } +} + +// computeCleanupStatus checks git state and returns the cleanup status. +// Returns the most critical issue: has_unpushed > has_stash > has_uncommitted > clean +func computeCleanupStatus(cwd string) string { + g := git.NewGit(cwd) + status, err := g.CheckUncommittedWork() + if err != nil { + // If we can't check, report unknown - Witness should be cautious + return "unknown" + } + + // Check in priority order (most critical first) + if status.UnpushedCommits > 0 { + return "has_unpushed" + } + if status.StashCount > 0 { + return "has_stash" + } + if status.HasUncommittedChanges { + return "has_uncommitted" + } + return "clean" } diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 11963357..30da9a35 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -89,6 +89,53 @@ func (m *Manager) agentBeadID(name string) string { return fmt.Sprintf("gt-polecat-%s-%s", m.rig.Name, name) } +// getCleanupStatusFromBead reads the cleanup_status from the polecat's agent bead. +// Returns empty string if the bead doesn't exist or has no cleanup_status. +// ZFC #10: This is the ZFC-compliant way to check if removal is safe. +func (m *Manager) getCleanupStatusFromBead(name string) string { + agentID := m.agentBeadID(name) + _, fields, err := m.beads.GetAgentBead(agentID) + if err != nil || fields == nil { + return "" + } + return fields.CleanupStatus +} + +// checkCleanupStatus validates the cleanup status against removal safety rules. +// Returns an error if removal should be blocked based on the status. +// force=true: allow has_uncommitted, block has_stash and has_unpushed +// force=false: block all non-clean statuses +func (m *Manager) checkCleanupStatus(name, cleanupStatus string, force bool) error { + switch cleanupStatus { + case "clean": + return nil + case "has_uncommitted": + if force { + return nil // force bypasses uncommitted changes + } + return &UncommittedWorkError{ + PolecatName: name, + Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true}, + } + case "has_stash": + return &UncommittedWorkError{ + PolecatName: name, + Status: &git.UncommittedWorkStatus{StashCount: 1}, + } + case "has_unpushed": + return &UncommittedWorkError{ + PolecatName: name, + Status: &git.UncommittedWorkStatus{UnpushedCommits: 1}, + } + default: + // Unknown status - be conservative and block + return &UncommittedWorkError{ + PolecatName: name, + Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true}, + } + } +} + // repoBase returns the git directory and Git object to use for worktree operations. // Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig. // The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility. @@ -206,26 +253,41 @@ func (m *Manager) Remove(name string, force bool) error { // RemoveWithOptions deletes a polecat worktree with explicit control over safety checks. // force=true: bypass uncommitted changes check (legacy behavior) // nuclear=true: bypass ALL safety checks including stashes and unpushed commits +// +// ZFC #10: Uses cleanup_status from agent bead if available (polecat self-report), +// falls back to git check for backward compatibility. func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error { if !m.exists(name) { return ErrPolecatNotFound } polecatPath := m.polecatDir(name) - polecatGit := git.NewGit(polecatPath) // Check for uncommitted work unless bypassed if !nuclear { - status, err := polecatGit.CheckUncommittedWork() - if err == nil && !status.Clean() { - // For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed - if force { - // Force mode: allow uncommitted changes but still block on stashes/unpushed - if status.StashCount > 0 || status.UnpushedCommits > 0 { + // ZFC #10: First try to read cleanup_status from agent bead + // This is the ZFC-compliant path - trust what the polecat reported + cleanupStatus := m.getCleanupStatusFromBead(name) + + if cleanupStatus != "" && cleanupStatus != "unknown" { + // ZFC path: Use polecat's self-reported status + if err := m.checkCleanupStatus(name, cleanupStatus, force); err != nil { + return err + } + } else { + // Fallback path: Check git directly (for polecats that haven't reported yet) + polecatGit := git.NewGit(polecatPath) + status, err := polecatGit.CheckUncommittedWork() + if err == nil && !status.Clean() { + // For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed + if force { + // Force mode: allow uncommitted changes but still block on stashes/unpushed + if status.StashCount > 0 || status.UnpushedCommits > 0 { + return &UncommittedWorkError{PolecatName: name, Status: status} + } + } else { return &UncommittedWorkError{PolecatName: name, Status: status} } - } else { - return &UncommittedWorkError{PolecatName: name, Status: status} } } }