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
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)
}
+29 -18
View File
@@ -11,25 +11,27 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/compact" "github.com/steveyegge/beads/internal/compact"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
var ( var (
compactDryRun bool compactDryRun bool
compactTier int compactTier int
compactAll bool compactAll bool
compactID string compactID string
compactForce bool compactForce bool
compactBatch int compactBatch int
compactWorkers int compactWorkers int
compactStats bool compactStats bool
compactAnalyze bool compactAnalyze bool
compactApply bool compactApply bool
compactAuto bool compactAuto bool
compactSummary string compactSummary string
compactActor string compactActor string
compactLimit int compactAudit bool
compactLimit int
) )
var compactCmd = &cobra.Command{ var compactCmd = &cobra.Command{
@@ -68,7 +70,7 @@ Examples:
# Statistics # Statistics
bd compact --stats # Show 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 // Compact modifies data unless --stats or --analyze or --dry-run
if !compactStats && !compactAnalyze && !compactDryRun { if !compactStats && !compactAnalyze && !compactDryRun {
CheckReadonly("compact") CheckReadonly("compact")
@@ -156,6 +158,12 @@ Examples:
// Handle auto mode (legacy) // Handle auto mode (legacy)
if compactAuto { 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 // Validation checks
if compactID != "" && compactAll { if compactID != "" && compactAll {
fmt.Fprintf(os.Stderr, "Error: cannot use --id and --all together\n") fmt.Fprintf(os.Stderr, "Error: cannot use --id and --all together\n")
@@ -190,9 +198,11 @@ Examples:
} }
config := &compact.Config{ config := &compact.Config{
APIKey: apiKey, APIKey: apiKey,
Concurrency: compactWorkers, Concurrency: compactWorkers,
DryRun: compactDryRun, DryRun: compactDryRun,
AuditEnabled: auditEnabled,
Actor: compactActor,
} }
compactor, err := compact.New(sqliteStore, apiKey, config) 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(&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(&compactApply, "apply", false, "Apply mode: accept agent-provided summary")
compactCmd.Flags().BoolVar(&compactAuto, "auto", false, "Auto mode: AI-powered compaction (legacy)") 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(&compactSummary, "summary", "", "Path to summary file (use '-' for stdin)")
compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail") compactCmd.Flags().StringVar(&compactActor, "actor", "agent", "Actor name for audit trail")
compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)") compactCmd.Flags().IntVar(&compactLimit, "limit", 0, "Limit number of candidates (0 = no limit)")
+3 -3
View File
@@ -428,7 +428,7 @@ func runPreCommitHook() int {
} }
// Stage all tracked JSONL files // 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 { if _, err := os.Stat(f); err == nil {
gitAdd := exec.Command("git", "add", f) gitAdd := exec.Command("git", "add", f)
_ = gitAdd.Run() // Ignore errors - file may not exist _ = gitAdd.Run() // Ignore errors - file may not exist
@@ -498,7 +498,7 @@ func runPrePushHook() int {
// Check for uncommitted JSONL changes // Check for uncommitted JSONL changes
files := []string{} 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 // Check if file exists or is tracked
if _, err := os.Stat(f); err == nil { if _, err := os.Stat(f); err == nil {
files = append(files, f) files = append(files, f)
@@ -644,7 +644,7 @@ func isRebaseInProgress() bool {
// hasBeadsJSONL checks if any JSONL file exists in .beads/. // hasBeadsJSONL checks if any JSONL file exists in .beads/.
func hasBeadsJSONL() bool { 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 { if _, err := os.Stat(f); err == nil {
return true return true
} }
+23 -4
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 // Create metadata.json for --no-db mode
cfg := configfile.DefaultConfig() cfg := configfile.DefaultConfig()
if err := cfg.Save(beadsDir); err != nil { 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) fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err)
// Non-fatal - continue anyway // 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 // 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 return nil // No database found, safe to init
} }
// landingThePlaneSection is the "landing the plane" instructions for AI agents // landingThePlaneSection is the "landing the plane" instructions for AI agents
// This gets appended to AGENTS.md and @AGENTS.md during bd init // This gets appended to AGENTS.md and @AGENTS.md during bd init
const landingThePlaneSection = ` const landingThePlaneSection = `
@@ -1608,17 +1627,17 @@ func updateAgentFile(filename string, verbose bool) error {
// File doesn't exist - create it with basic structure // File doesn't exist - create it with basic structure
newContent := fmt.Sprintf(`# Agent Instructions 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 ## Quick Reference
` + "```bash" + ` `+"```bash"+`
bd ready # Find available work bd ready # Find available work
bd show <id> # View issue details bd show <id> # View issue details
bd update <id> --status in_progress # Claim work bd update <id> --status in_progress # Claim work
bd close <id> # Complete work bd close <id> # Complete work
bd sync # Sync with git bd sync # Sync with git
` + "```" + ` `+"```"+`
%s %s
`, landingThePlaneSection) `, landingThePlaneSection)
+11 -10
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 // Check if this looks like a merge driver failure
errStr := err.Error() errStr := err.Error()
if strings.Contains(errStr, "merge driver") || if strings.Contains(errStr, "merge driver") ||
strings.Contains(errStr, "no such file or directory") || strings.Contains(errStr, "no such file or directory") ||
strings.Contains(errStr, "MERGE DRIVER INVOKED") { 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, "\nThis may be caused by an incorrect merge driver configuration.\n")
fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n") fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n")
} }
@@ -951,6 +951,7 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
syncFiles := []string{ syncFiles := []string{
filepath.Join(beadsDir, "issues.jsonl"), filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "deletions.jsonl"), filepath.Join(beadsDir, "deletions.jsonl"),
filepath.Join(beadsDir, "interactions.jsonl"),
filepath.Join(beadsDir, "metadata.json"), filepath.Join(beadsDir, "metadata.json"),
} }
@@ -1794,18 +1795,18 @@ func pullFromExternalBeadsRepo(ctx context.Context, beadsDir string) error {
// SyncIntegrityResult contains the results of a pre-sync integrity check. // SyncIntegrityResult contains the results of a pre-sync integrity check.
// bd-hlsw.1: Pre-sync integrity check // bd-hlsw.1: Pre-sync integrity check
type SyncIntegrityResult struct { type SyncIntegrityResult struct {
ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"` ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"`
PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"` PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"`
OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"` OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"`
HasProblems bool `json:"has_problems"` HasProblems bool `json:"has_problems"`
} }
// ForcedPushCheck detects if sync branch has diverged from remote. // ForcedPushCheck detects if sync branch has diverged from remote.
type ForcedPushCheck struct { type ForcedPushCheck struct {
Detected bool `json:"detected"` Detected bool `json:"detected"`
LocalRef string `json:"local_ref,omitempty"` LocalRef string `json:"local_ref,omitempty"`
RemoteRef string `json:"remote_ref,omitempty"` RemoteRef string `json:"remote_ref,omitempty"`
Message string `json:"message"` Message string `json:"message"`
} }
// PrefixMismatch detects issues with wrong prefix in JSONL. // PrefixMismatch detects issues with wrong prefix in JSONL.
+28
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
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
}
+54
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)
}
}
+9 -3
View File
@@ -15,9 +15,11 @@ const (
// Config holds configuration for the compaction process. // Config holds configuration for the compaction process.
type Config struct { type Config struct {
APIKey string APIKey string
Concurrency int Concurrency int
DryRun bool DryRun bool
AuditEnabled bool
Actor string
} }
// Compactor handles issue compaction using AI summarization. // 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{ return &Compactor{
store: store, store: store,
+20 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/option"
"github.com/steveyegge/beads/internal/audit"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
@@ -32,6 +33,8 @@ type HaikuClient struct {
tier1Template *template.Template tier1Template *template.Template
maxRetries int maxRetries int
initialBackoff time.Duration initialBackoff time.Duration
auditEnabled bool
auditActor string
} }
// NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey. // 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 "", 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) { func (h *HaikuClient) callWithRetry(ctx context.Context, prompt string) (string, error) {