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:
166
cmd/bd/audit.go
Normal file
166
cmd/bd/audit.go
Normal 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)
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
28
commands/audit.md
Normal 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
134
internal/audit/audit.go
Normal 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
internal/audit/audit_test.go
Normal file
54
internal/audit/audit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user