From 7b758271ed058365f092479dd5c5e6b50322d771 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 20 Dec 2025 03:24:51 -0800 Subject: [PATCH] feat(audit): add append-only agent audit trail (.beads/interactions.jsonl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements audit logging for agent interactions to support auditing and dataset generation (fixes #649). New features: - .beads/interactions.jsonl (append-only audit log) - bd audit record: log LLM calls, tool calls, or pipe JSON via stdin - bd audit label : append labels (good/bad) for dataset curation - bd compact --audit: optionally log LLM prompt/response during compaction - bd init: creates empty interactions.jsonl - bd sync: includes interactions.jsonl in staging Audit entries are append-only - labeling creates new entries that reference parent entries by ID. Closes #649 Co-authored-by: Dmitry Chichkov 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/audit.go | 166 ++++++++++++++++++++++++++++++++++ cmd/bd/compact.go | 47 ++++++---- cmd/bd/hooks.go | 6 +- cmd/bd/init.go | 27 +++++- cmd/bd/sync.go | 33 +++---- commands/audit.md | 28 ++++++ internal/audit/audit.go | 134 +++++++++++++++++++++++++++ internal/audit/audit_test.go | 54 +++++++++++ internal/compact/compactor.go | 12 ++- internal/compact/haiku.go | 21 ++++- 10 files changed, 483 insertions(+), 45 deletions(-) create mode 100644 cmd/bd/audit.go create mode 100644 commands/audit.md create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go diff --git a/cmd/bd/audit.go b/cmd/bd/audit.go new file mode 100644 index 00000000..2003842a --- /dev/null +++ b/cmd/bd/audit.go @@ -0,0 +1,166 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/audit" +) + +var ( + auditRecordKind string + auditRecordModel string + auditRecordPrompt string + auditRecordResponse string + auditRecordIssueID string + auditRecordToolName string + auditRecordExitCode int + auditRecordError string + auditRecordStdin bool + + auditLabelValue string + auditLabelReason string +) + +var auditCmd = &cobra.Command{ + Use: "audit", + Short: "Record and label agent interactions (append-only JSONL)", + Long: `Audit log entries are appended to .beads/interactions.jsonl. + +Each line is one event. This file is intended to be versioned in git and used for: +- auditing ("why did the agent do that?") +- dataset generation (SFT/RL fine-tuning) + +Entries are append-only. Labeling creates a new "label" entry that references a parent entry.`, +} + +var auditRecordCmd = &cobra.Command{ + Use: "record", + Short: "Append an audit interaction entry", + Run: func(cmd *cobra.Command, _ []string) { + var e audit.Entry + + // If stdin is piped and no explicit record fields were provided, assume stdin JSON. + // This matches "or pipe JSON via stdin" without requiring a flag. + fi, _ := os.Stdin.Stat() + stdinPiped := fi != nil && (fi.Mode()&os.ModeCharDevice) == 0 + noFieldsProvided := auditRecordKind == "" && + auditRecordModel == "" && + auditRecordPrompt == "" && + auditRecordResponse == "" && + auditRecordIssueID == "" && + auditRecordToolName == "" && + auditRecordExitCode < 0 && + auditRecordError == "" + + if auditRecordStdin || (stdinPiped && noFieldsProvided) { + b, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read stdin: %v\n", err) + os.Exit(1) + } + if err := json.Unmarshal(b, &e); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid JSON on stdin: %v\n", err) + os.Exit(1) + } + // Allow --actor to override/augment stdin. + if actor != "" { + e.Actor = actor + } + } else { + if auditRecordKind == "" { + fmt.Fprintf(os.Stderr, "Error: --kind is required\n") + os.Exit(1) + } + e = audit.Entry{ + Kind: auditRecordKind, + Actor: actor, + IssueID: auditRecordIssueID, + Model: auditRecordModel, + Prompt: auditRecordPrompt, + Response: auditRecordResponse, + ToolName: auditRecordToolName, + Error: auditRecordError, + } + if auditRecordExitCode >= 0 { + exit := auditRecordExitCode + e.ExitCode = &exit + } + } + + id, err := audit.Append(&e) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if jsonOutput { + outputJSON(map[string]any{ + "id": id, + "kind": e.Kind, + }) + return + } + + fmt.Println(id) + }, +} + +var auditLabelCmd = &cobra.Command{ + Use: "label ", + Short: "Append a label entry referencing an existing interaction", + Args: cobra.ExactArgs(1), + Run: func(_ *cobra.Command, args []string) { + parentID := args[0] + if auditLabelValue == "" { + fmt.Fprintf(os.Stderr, "Error: --label is required\n") + os.Exit(1) + } + e := audit.Entry{ + Kind: "label", + Actor: actor, + ParentID: parentID, + Label: auditLabelValue, + Reason: auditLabelReason, + } + + id, err := audit.Append(&e) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if jsonOutput { + outputJSON(map[string]any{ + "id": id, + "parent_id": parentID, + "label": auditLabelValue, + }) + return + } + + fmt.Println(id) + }, +} + +func init() { + auditRecordCmd.Flags().StringVar(&auditRecordKind, "kind", "", "Entry kind (e.g. llm_call, tool_call, label)") + auditRecordCmd.Flags().StringVar(&auditRecordModel, "model", "", "Model name (llm_call)") + auditRecordCmd.Flags().StringVar(&auditRecordPrompt, "prompt", "", "Prompt text (llm_call)") + auditRecordCmd.Flags().StringVar(&auditRecordResponse, "response", "", "Response text (llm_call)") + auditRecordCmd.Flags().StringVar(&auditRecordIssueID, "issue-id", "", "Related issue id (bd-...)") + auditRecordCmd.Flags().StringVar(&auditRecordToolName, "tool-name", "", "Tool name (tool_call)") + auditRecordCmd.Flags().IntVar(&auditRecordExitCode, "exit-code", -1, "Exit code (tool_call)") + auditRecordCmd.Flags().StringVar(&auditRecordError, "error", "", "Error string (llm_call/tool_call)") + auditRecordCmd.Flags().BoolVar(&auditRecordStdin, "stdin", false, "Read a JSON object from stdin (must match audit.Entry schema)") + + auditLabelCmd.Flags().StringVar(&auditLabelValue, "label", "", `Label value (e.g. "good" or "bad")`) + auditLabelCmd.Flags().StringVar(&auditLabelReason, "reason", "", "Reason for label") + + auditCmd.AddCommand(auditRecordCmd) + auditCmd.AddCommand(auditLabelCmd) + rootCmd.AddCommand(auditCmd) +} diff --git a/cmd/bd/compact.go b/cmd/bd/compact.go index 21c05342..3432dc3d 100644 --- a/cmd/bd/compact.go +++ b/cmd/bd/compact.go @@ -11,25 +11,27 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/compact" + "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) var ( - compactDryRun bool - compactTier int - compactAll bool - compactID string - compactForce bool - compactBatch int - compactWorkers int - compactStats bool - compactAnalyze bool - compactApply bool - compactAuto bool - compactSummary string - compactActor string - compactLimit int + compactDryRun bool + compactTier int + compactAll bool + compactID string + compactForce bool + compactBatch int + compactWorkers int + compactStats bool + compactAnalyze bool + compactApply bool + compactAuto bool + compactSummary string + compactActor string + compactAudit bool + compactLimit int ) var compactCmd = &cobra.Command{ @@ -68,7 +70,7 @@ Examples: # Statistics bd compact --stats # Show statistics `, - Run: func(_ *cobra.Command, _ []string) { + Run: func(cmd *cobra.Command, _ []string) { // Compact modifies data unless --stats or --analyze or --dry-run if !compactStats && !compactAnalyze && !compactDryRun { CheckReadonly("compact") @@ -156,6 +158,12 @@ Examples: // Handle auto mode (legacy) if compactAuto { + // If --audit not explicitly set, fall back to config audit.enabled + auditEnabled := compactAudit + if !cmd.Flags().Changed("audit") { + auditEnabled = config.GetBool("audit.enabled") + } + // Validation checks if compactID != "" && compactAll { fmt.Fprintf(os.Stderr, "Error: cannot use --id and --all together\n") @@ -190,9 +198,11 @@ Examples: } config := &compact.Config{ - APIKey: apiKey, - Concurrency: compactWorkers, - DryRun: compactDryRun, + APIKey: apiKey, + Concurrency: compactWorkers, + DryRun: compactDryRun, + AuditEnabled: auditEnabled, + Actor: compactActor, } compactor, err := compact.New(sqliteStore, apiKey, config) @@ -1091,6 +1101,7 @@ func init() { compactCmd.Flags().BoolVar(&compactAnalyze, "analyze", false, "Analyze mode: export candidates for agent review") compactCmd.Flags().BoolVar(&compactApply, "apply", false, "Apply mode: accept agent-provided summary") compactCmd.Flags().BoolVar(&compactAuto, "auto", false, "Auto mode: AI-powered compaction (legacy)") + compactCmd.Flags().BoolVar(&compactAudit, "audit", false, "Log LLM prompt/response to .beads/interactions.jsonl (or set config audit.enabled=true)") compactCmd.Flags().StringVar(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)") compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail") compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)") diff --git a/cmd/bd/hooks.go b/cmd/bd/hooks.go index 63132315..4cb2c9e6 100644 --- a/cmd/bd/hooks.go +++ b/cmd/bd/hooks.go @@ -428,7 +428,7 @@ func runPreCommitHook() int { } // Stage all tracked JSONL files - for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl"} { + for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { if _, err := os.Stat(f); err == nil { gitAdd := exec.Command("git", "add", f) _ = gitAdd.Run() // Ignore errors - file may not exist @@ -498,7 +498,7 @@ func runPrePushHook() int { // Check for uncommitted JSONL changes files := []string{} - for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl"} { + for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { // Check if file exists or is tracked if _, err := os.Stat(f); err == nil { files = append(files, f) @@ -644,7 +644,7 @@ func isRebaseInProgress() bool { // hasBeadsJSONL checks if any JSONL file exists in .beads/. func hasBeadsJSONL() bool { - for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl"} { + for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} { if _, err := os.Stat(f); err == nil { return true } diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 8109cb94..edc2aa4b 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -195,6 +195,16 @@ With --stealth: configures global git settings for invisible beads usage: } } + // Create empty interactions.jsonl file (append-only agent audit log) + interactionsPath := filepath.Join(beadsDir, "interactions.jsonl") + if _, err := os.Stat(interactionsPath); os.IsNotExist(err) { + // nolint:gosec // G306: JSONL file needs to be readable by other tools + if err := os.WriteFile(interactionsPath, []byte{}, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to create interactions.jsonl: %v\n", err) + os.Exit(1) + } + } + // Create metadata.json for --no-db mode cfg := configfile.DefaultConfig() if err := cfg.Save(beadsDir); err != nil { @@ -234,6 +244,16 @@ With --stealth: configures global git settings for invisible beads usage: fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err) // Non-fatal - continue anyway } + + // Ensure interactions.jsonl exists (append-only agent audit log) + interactionsPath := filepath.Join(beadsDir, "interactions.jsonl") + if _, err := os.Stat(interactionsPath); os.IsNotExist(err) { + // nolint:gosec // G306: JSONL file needs to be readable by other tools + if err := os.WriteFile(interactionsPath, []byte{}, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to create interactions.jsonl: %v\n", err) + // Non-fatal - continue anyway + } + } } // Ensure parent directory exists for the database @@ -1555,7 +1575,6 @@ Aborting.`, yellow("⚠"), dbPath, cyan("bd list"), prefix) return nil // No database found, safe to init } - // landingThePlaneSection is the "landing the plane" instructions for AI agents // This gets appended to AGENTS.md and @AGENTS.md during bd init const landingThePlaneSection = ` @@ -1608,17 +1627,17 @@ func updateAgentFile(filename string, verbose bool) error { // File doesn't exist - create it with basic structure newContent := fmt.Sprintf(`# Agent Instructions -This project uses **bd** (beads) for issue tracking. Run ` + "`bd onboard`" + ` to get started. +This project uses **bd** (beads) for issue tracking. Run `+"`bd onboard`"+` to get started. ## Quick Reference -` + "```bash" + ` +`+"```bash"+` bd ready # Find available work bd show # View issue details bd update --status in_progress # Claim work bd close # Complete work bd sync # Sync with git -` + "```" + ` +`+"```"+` %s `, landingThePlaneSection) diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 15716bfd..665ee4b5 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -589,8 +589,8 @@ Use --merge to merge the sync branch back to main branch.`, // Check if this looks like a merge driver failure errStr := err.Error() if strings.Contains(errStr, "merge driver") || - strings.Contains(errStr, "no such file or directory") || - strings.Contains(errStr, "MERGE DRIVER INVOKED") { + strings.Contains(errStr, "no such file or directory") || + strings.Contains(errStr, "MERGE DRIVER INVOKED") { fmt.Fprintf(os.Stderr, "\nThis may be caused by an incorrect merge driver configuration.\n") fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n") } @@ -826,14 +826,14 @@ func gitHasUpstream() bool { return false } branch := strings.TrimSpace(string(branchOutput)) - + // Check if remote and merge refs are configured remoteCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch)) - + remoteErr := remoteCmd.Run() mergeErr := mergeCmd.Run() - + return remoteErr == nil && mergeErr == nil } @@ -951,6 +951,7 @@ func gitCommitBeadsDir(ctx context.Context, message string) error { syncFiles := []string{ filepath.Join(beadsDir, "issues.jsonl"), filepath.Join(beadsDir, "deletions.jsonl"), + filepath.Join(beadsDir, "interactions.jsonl"), filepath.Join(beadsDir, "metadata.json"), } @@ -1107,7 +1108,7 @@ func gitPull(ctx context.Context) error { if !hasGitRemote(ctx) { return nil // Gracefully skip - local-only mode } - + // Get current branch name // Use symbolic-ref to work in fresh repos without commits (bd-flil) branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD") @@ -1116,7 +1117,7 @@ func gitPull(ctx context.Context) error { return fmt.Errorf("failed to get current branch: %w", err) } branch := strings.TrimSpace(string(branchOutput)) - + // Get remote name for current branch (usually "origin") remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) remoteOutput, err := remoteCmd.Output() @@ -1125,7 +1126,7 @@ func gitPull(ctx context.Context) error { remoteOutput = []byte("origin\n") } remote := strings.TrimSpace(string(remoteOutput)) - + // Pull with explicit remote and branch cmd := exec.CommandContext(ctx, "git", "pull", remote, branch) output, err := cmd.CombinedOutput() @@ -1794,18 +1795,18 @@ func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error { // SyncIntegrityResult contains the results of a pre-sync integrity check. // bd-hlsw.1: Pre-sync integrity check type SyncIntegrityResult struct { - ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"` - PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"` - OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"` - HasProblems bool `json:"has_problems"` + ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"` + PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"` + OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"` + HasProblems bool `json:"has_problems"` } // ForcedPushCheck detects if sync branch has diverged from remote. type ForcedPushCheck struct { - Detected bool `json:"detected"` - LocalRef string `json:"local_ref,omitempty"` - RemoteRef string `json:"remote_ref,omitempty"` - Message string `json:"message"` + Detected bool `json:"detected"` + LocalRef string `json:"local_ref,omitempty"` + RemoteRef string `json:"remote_ref,omitempty"` + Message string `json:"message"` } // PrefixMismatch detects issues with wrong prefix in JSONL. diff --git a/commands/audit.md b/commands/audit.md new file mode 100644 index 00000000..85340f92 --- /dev/null +++ b/commands/audit.md @@ -0,0 +1,28 @@ +--- +description: Log and label agent interactions (append-only JSONL) +argument-hint: record|label +--- + +Append-only audit logging for agent interactions (prompts, responses, tool calls) in `.beads/interactions.jsonl`. + +Each line is one event. Labeling is done by appending a new `"label"` event referencing a previous entry. + +## Usage + +- **Record an interaction**: + - `bd audit record --kind llm_call --model "claude-3-5-haiku" --prompt "..." --response "..."` + - `bd audit record --kind tool_call --tool-name "go test" --exit-code 1 --error "..." --issue-id bd-42` + +- **Pipe JSON via stdin**: + - `cat event.json | bd audit record` + +- **Label an entry**: + - `bd audit label int-a1b2 --label good --reason "Worked perfectly"` + - `bd audit label int-a1b2 --label bad --reason "Hallucinated a file path"` + +## Notes + +- Audit entries are **append-only** (no in-place edits). +- `bd sync` includes `.beads/interactions.jsonl` in the commit allowlist (like `issues.jsonl`). + + diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 00000000..c8f1e57a --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,134 @@ +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 +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 00000000..631c7387 --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,54 @@ +package audit + +import ( + "bufio" + "os" + "path/filepath" + "testing" +) + +func TestAppend_CreatesFileAndWritesJSONL(t *testing.T) { + tmp := t.TempDir() + beadsDir := filepath.Join(tmp, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + t.Fatalf("mkdir: %v", err) + } + // beads.FindBeadsDir() validates that the directory contains project files + // (db or *.jsonl). Create an empty issues.jsonl so BEADS_DIR is accepted. + issuesPath := filepath.Join(beadsDir, "issues.jsonl") + if err := os.WriteFile(issuesPath, []byte{}, 0644); err != nil { + t.Fatalf("write issues.jsonl: %v", err) + } + t.Setenv("BEADS_DIR", beadsDir) + + id1, err := Append(&Entry{Kind: "llm_call", Model: "test-model", Prompt: "p", Response: "r"}) + if err != nil { + t.Fatalf("append: %v", err) + } + if id1 == "" { + t.Fatalf("expected id") + } + _, err = Append(&Entry{Kind: "label", ParentID: id1, Label: "good", Reason: "ok"}) + if err != nil { + t.Fatalf("append label: %v", err) + } + + p := filepath.Join(beadsDir, FileName) + f, err := os.Open(p) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = f.Close() }() + + sc := bufio.NewScanner(f) + lines := 0 + for sc.Scan() { + lines++ + } + if err := sc.Err(); err != nil { + t.Fatalf("scan: %v", err) + } + if lines != 2 { + t.Fatalf("expected 2 lines, got %d", lines) + } +} diff --git a/internal/compact/compactor.go b/internal/compact/compactor.go index 59504e2c..104f5430 100644 --- a/internal/compact/compactor.go +++ b/internal/compact/compactor.go @@ -15,9 +15,11 @@ const ( // Config holds configuration for the compaction process. type Config struct { - APIKey string - Concurrency int - DryRun bool + APIKey string + Concurrency int + DryRun bool + AuditEnabled bool + Actor string } // Compactor handles issue compaction using AI summarization. @@ -53,6 +55,10 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor } } } + if haikuClient != nil { + haikuClient.auditEnabled = config.AuditEnabled + haikuClient.auditActor = config.Actor + } return &Compactor{ store: store, diff --git a/internal/compact/haiku.go b/internal/compact/haiku.go index 7803a5c1..58eec341 100644 --- a/internal/compact/haiku.go +++ b/internal/compact/haiku.go @@ -13,6 +13,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/steveyegge/beads/internal/audit" "github.com/steveyegge/beads/internal/types" ) @@ -32,6 +33,8 @@ type HaikuClient struct { tier1Template *template.Template maxRetries int initialBackoff time.Duration + auditEnabled bool + auditActor string } // NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey. @@ -67,7 +70,23 @@ func (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (s return "", fmt.Errorf("failed to render prompt: %w", err) } - return h.callWithRetry(ctx, prompt) + resp, callErr := h.callWithRetry(ctx, prompt) + if h.auditEnabled { + // Best-effort: never fail compaction because audit logging failed. + e := &audit.Entry{ + Kind: "llm_call", + Actor: h.auditActor, + IssueID: issue.ID, + Model: string(h.model), + Prompt: prompt, + Response: resp, + } + if callErr != nil { + e.Error = callErr.Error() + } + _, _ = audit.Append(e) + } + return resp, callErr } func (h *HaikuClient) callWithRetry(ctx context.Context, prompt string) (string, error) {