package audit import ( "bufio" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "time" "github.com/steveyegge/beads/internal/beads" ) const ( // FileName is the audit log file name stored under .beads/. FileName = "interactions.jsonl" idPrefix = "int-" ) // Entry is a generic append-only audit event. It is intentionally flexible: // use Kind + typed fields for common cases, and Extra for everything else. type Entry struct { ID string `json:"id"` Kind string `json:"kind"` CreatedAt time.Time `json:"created_at"` // Common metadata Actor string `json:"actor,omitempty"` IssueID string `json:"issue_id,omitempty"` // LLM call Model string `json:"model,omitempty"` Prompt string `json:"prompt,omitempty"` Response string `json:"response,omitempty"` Error string `json:"error,omitempty"` // Tool call ToolName string `json:"tool_name,omitempty"` ExitCode *int `json:"exit_code,omitempty"` // Labeling (append-only) ParentID string `json:"parent_id,omitempty"` Label string `json:"label,omitempty"` // "good" | "bad" | etc Reason string `json:"reason,omitempty"` // human / pipeline explanation Extra map[string]any `json:"extra,omitempty"` } func Path() (string, error) { beadsDir := beads.FindBeadsDir() if beadsDir == "" { return "", fmt.Errorf("no .beads directory found") } return filepath.Join(beadsDir, FileName), nil } // EnsureFile creates .beads/interactions.jsonl if it does not exist. func EnsureFile() (string, error) { p, err := Path() if err != nil { return "", err } if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil { return "", fmt.Errorf("failed to create .beads directory: %w", err) } _, statErr := os.Stat(p) if statErr == nil { return p, nil } if !os.IsNotExist(statErr) { return "", fmt.Errorf("failed to stat interactions log: %w", statErr) } // nolint:gosec // JSONL is intended to be shared via git across clones/tools. if err := os.WriteFile(p, []byte{}, 0644); err != nil { return "", fmt.Errorf("failed to create interactions log: %w", err) } return p, nil } // Append appends an event to .beads/interactions.jsonl as a single JSON line. // This is intentionally append-only: callers must not mutate existing lines. func Append(e *Entry) (string, error) { if e == nil { return "", fmt.Errorf("nil entry") } if e.Kind == "" { return "", fmt.Errorf("kind is required") } p, err := EnsureFile() if err != nil { return "", err } if e.ID == "" { e.ID, err = newID() if err != nil { return "", err } } if e.CreatedAt.IsZero() { e.CreatedAt = time.Now().UTC() } else { e.CreatedAt = e.CreatedAt.UTC() } f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) // nolint:gosec // intended permissions if err != nil { return "", fmt.Errorf("failed to open interactions log: %w", err) } defer func() { _ = f.Close() }() bw := bufio.NewWriter(f) enc := json.NewEncoder(bw) enc.SetEscapeHTML(false) if err := enc.Encode(e); err != nil { return "", fmt.Errorf("failed to write interactions log entry: %w", err) } if err := bw.Flush(); err != nil { return "", fmt.Errorf("failed to flush interactions log: %w", err) } return e.ID, nil } func newID() (string, error) { var b [4]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("failed to generate id: %w", err) } return idPrefix + hex.EncodeToString(b[:]), nil }