diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 1ec4f2f6..6d09d56a 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -6,7 +6,9 @@ import ( "encoding/json" "errors" "fmt" + "os" "os/exec" + "path/filepath" "strings" "time" ) @@ -1007,3 +1009,92 @@ func (b *Beads) GetAttachment(pinnedBeadID string) (*AttachmentFields, error) { func currentTimestamp() string { return time.Now().UTC().Format(time.RFC3339) } + +// DetachAuditEntry represents an audit log entry for a detach operation. +type DetachAuditEntry struct { + Timestamp string `json:"timestamp"` + Operation string `json:"operation"` // "detach", "burn", "squash" + PinnedBeadID string `json:"pinned_bead_id"` + DetachedMolecule string `json:"detached_molecule"` + DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach + Reason string `json:"reason,omitempty"` // Optional reason for detach + PreviousState string `json:"previous_state,omitempty"` +} + +// DetachOptions specifies optional context for a detach operation. +type DetachOptions struct { + Operation string // "detach", "burn", "squash" - defaults to "detach" + Agent string // Who is performing the detach + Reason string // Optional reason for the detach +} + +// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation. +// Returns the updated issue. +func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) { + // Fetch the pinned bead first to get previous state + issue, err := b.Show(pinnedBeadID) + if err != nil { + return nil, fmt.Errorf("fetching pinned bead: %w", err) + } + + // Get current attachment info for audit + attachment := ParseAttachmentFields(issue) + if attachment == nil { + return issue, nil // Nothing to detach + } + + // Log the detach operation + operation := opts.Operation + if operation == "" { + operation = "detach" + } + entry := DetachAuditEntry{ + Timestamp: currentTimestamp(), + Operation: operation, + PinnedBeadID: pinnedBeadID, + DetachedMolecule: attachment.AttachedMolecule, + DetachedBy: opts.Agent, + Reason: opts.Reason, + PreviousState: issue.Status, + } + if err := b.LogDetachAudit(entry); err != nil { + // Log error but don't fail the detach operation + fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err) + } + + // Clear attachment fields by passing nil + newDesc := SetAttachmentFields(issue, nil) + + // Update the issue + if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil { + return nil, fmt.Errorf("updating pinned bead: %w", err) + } + + // Re-fetch to return updated state + return b.Show(pinnedBeadID) +} + +// LogDetachAudit appends an audit entry to the audit log file. +// The audit log is stored in .beads/audit.log as JSONL format. +func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error { + auditPath := filepath.Join(b.workDir, ".beads", "audit.log") + + // Marshal entry to JSON + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshaling audit entry: %w", err) + } + + // Append to audit log file + f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening audit log: %w", err) + } + defer f.Close() + + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("writing audit entry: %w", err) + } + + return nil +} diff --git a/internal/cmd/molecule_attach.go b/internal/cmd/molecule_attach.go index 982ad3d6..b5731e8c 100644 --- a/internal/cmd/molecule_attach.go +++ b/internal/cmd/molecule_attach.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" ) func runMoleculeAttach(cmd *cobra.Command, args []string) error { @@ -59,8 +60,11 @@ func runMoleculeDetach(cmd *cobra.Command, args []string) error { previousMolecule := attachment.AttachedMolecule - // Detach the molecule - _, err = b.DetachMolecule(pinnedBeadID) + // Detach the molecule with audit logging + _, err = b.DetachMoleculeWithAudit(pinnedBeadID, beads.DetachOptions{ + Operation: "detach", + Agent: detectCurrentAgent(), + }) if err != nil { return fmt.Errorf("detaching molecule: %w", err) } @@ -126,3 +130,20 @@ func runMoleculeAttachment(cmd *cobra.Command, args []string) error { return nil } + +// detectCurrentAgent returns the current agent identity based on working directory. +// Returns empty string if identity cannot be determined. +func detectCurrentAgent() string { + cwd, err := os.Getwd() + if err != nil { + return "" + } + + townRoot, err := workspace.FindFromCwd() + if err != nil || townRoot == "" { + return "" + } + + ctx := detectRole(cwd, townRoot) + return buildAgentIdentity(ctx) +} diff --git a/internal/cmd/molecule_lifecycle.go b/internal/cmd/molecule_lifecycle.go index 5a9b5676..79978544 100644 --- a/internal/cmd/molecule_lifecycle.go +++ b/internal/cmd/molecule_lifecycle.go @@ -201,8 +201,12 @@ func runMoleculeBurn(cmd *cobra.Command, args []string) error { moleculeID := attachment.AttachedMolecule - // Detach the molecule (this "burns" it by removing the attachment) - _, err = b.DetachMolecule(handoff.ID) + // Detach the molecule with audit logging (this "burns" it by removing the attachment) + _, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{ + Operation: "burn", + Agent: target, + Reason: "molecule burned by agent", + }) if err != nil { return fmt.Errorf("detaching molecule: %w", err) } @@ -334,8 +338,12 @@ squashed_at: %s style.Dim.Render("Warning:"), err) } - // Detach the molecule from the handoff bead - _, err = b.DetachMolecule(handoff.ID) + // Detach the molecule from the handoff bead with audit logging + _, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{ + Operation: "squash", + Agent: target, + Reason: fmt.Sprintf("molecule squashed to digest %s", digestIssue.ID), + }) if err != nil { return fmt.Errorf("detaching molecule: %w", err) }