feat(audit): add append-only agent audit trail (.beads/interactions.jsonl)

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 <id>: 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 <dchichkov@nvidia.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-20 03:24:51 -08:00
parent 3c08e5eb9d
commit 7b758271ed
10 changed files with 483 additions and 45 deletions

166
cmd/bd/audit.go Normal file
View File

@@ -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 <entry-id>",
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)
}

View File

@@ -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)")

View File

@@ -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
}

View File

@@ -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 <id> # View issue details
bd update <id> --status in_progress # Claim work
bd close <id> # Complete work
bd sync # Sync with git
` + "```" + `
`+"```"+`
%s
`, landingThePlaneSection)

View File

@@ -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.

28
commands/audit.md Normal file
View File

@@ -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`).

134
internal/audit/audit.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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) {