Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions agenttrace/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Package agenttrace provides types and utilities for generating Agent Trace
// records, an open specification for tracing AI-generated code.
//
// Agent Trace (https://github.com/cursor/agent-trace) defines a vendor-neutral
// JSON format for recording which parts of code were written by AI vs humans,
// linking to agent conversations, and tracking line-level attribution.
//
// # Types
//
// The package mirrors the Agent Trace v0.1.0 JSON schema:
//
// - [TraceRecord]: Top-level record with version, id, timestamp, files
// - [File]: A file path with associated conversations
// - [Conversation]: Links to a conversation with ranges and contributor info
// - [Range]: Line range within a file attributed to a conversation
// - [Contributor]: Attribution type (human, ai, mixed, unknown) with optional model
// - [Tool]: The AI tool that produced the code (name and version)
// - [Vcs]: Version control info (type and revision)
//
// # Recording Traces
//
// Use [Recorder] to accumulate file edits and produce a [TraceRecord]:
//
// rec := agenttrace.NewRecorder(&agenttrace.Tool{Name: "claude-code", Version: "1.0"})
//
// rec.Record("src/main.go", "https://example.com/conversation/123",
// agenttrace.Contributor{Type: agenttrace.ContributorAI, ModelID: "anthropic/claude-sonnet-4-5-20250929"},
// []agenttrace.Range{{StartLine: 10, EndLine: 25}},
// )
//
// trace := rec.Build(&agenttrace.Vcs{Type: agenttrace.VcsGit, Revision: "abc123"})
//
// # Storing Traces
//
// Use [WriteTrace] and [ReadTrace] for JSON file I/O:
//
// err := agenttrace.WriteTrace("traces/trace.json", trace)
// loaded, err := agenttrace.ReadTrace("traces/trace.json")
//
// # Integration with Hookshot
//
// Use the Recorder from hookshot's [OnAfterFileEdit] handler:
//
// rec := agenttrace.NewRecorder(&agenttrace.Tool{Name: "claude-code"})
//
// hookshot.OnAfterFileEdit(func(ctx hookshot.FileEditContext) hookshot.FileEditDecision {
// rec.Record(ctx.FilePath, "", agenttrace.Contributor{Type: agenttrace.ContributorAI}, nil)
// return hookshot.FileEditOK()
// })
//
// See https://github.com/cursor/agent-trace for the full specification.
package agenttrace
84 changes: 84 additions & 0 deletions agenttrace/recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package agenttrace

import (
"crypto/rand"
"fmt"
"sync"
"time"
)

// Version is the Agent Trace specification version implemented by this package.
const Version = "0.1.0"

// Recorder accumulates file edit information and builds TraceRecord values.
// It is safe for concurrent use.
type Recorder struct {
mu sync.Mutex
tool *Tool
files map[string][]Conversation // keyed by file path
}

// NewRecorder creates a Recorder that tags traces with the given tool info.
// The tool parameter may be nil if tool information is not available.
func NewRecorder(tool *Tool) *Recorder {
return &Recorder{
tool: tool,
files: make(map[string][]Conversation),
}
}

// Record adds a conversation entry for a file. If ranges is nil or empty,
// the conversation is recorded without specific line attribution.
func (r *Recorder) Record(file, conversationURL string, contributor Contributor, ranges []Range) {
conv := Conversation{
URL: conversationURL,
Ranges: ranges,
}
conv.Contributor = &contributor

r.mu.Lock()
r.files[file] = append(r.files[file], conv)
r.mu.Unlock()
}

// Build produces a complete TraceRecord with a UUID, timestamp, and all
// accumulated file data. The vcs parameter may be nil if VCS info is not
// available.
func (r *Recorder) Build(vcs *Vcs) TraceRecord {
r.mu.Lock()
defer r.mu.Unlock()

files := make([]File, 0, len(r.files))
for path, convs := range r.files {
files = append(files, File{
Path: path,
Conversations: convs,
})
}

return TraceRecord{
Version: Version,
ID: newUUID(),
Timestamp: time.Now().UTC().Format(time.RFC3339),
Vcs: vcs,
Tool: r.tool,
Files: files,
}
}

// Reset clears all accumulated data so the Recorder can be reused.
func (r *Recorder) Reset() {
r.mu.Lock()
r.files = make(map[string][]Conversation)
r.mu.Unlock()
}

// newUUID generates a v4 UUID string.
func newUUID() string {
var uuid [16]byte
_, _ = rand.Read(uuid[:])
uuid[6] = (uuid[6] & 0x0f) | 0x40 // version 4
uuid[8] = (uuid[8] & 0x3f) | 0x80 // variant 1
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
}
153 changes: 153 additions & 0 deletions agenttrace/recorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package agenttrace

import (
"strings"
"testing"
)

func TestNewRecorder(t *testing.T) {
rec := NewRecorder(&Tool{Name: "test-tool", Version: "1.0"})
if rec == nil {
t.Fatal("NewRecorder returned nil")
}
}

func TestRecordAndBuild(t *testing.T) {
rec := NewRecorder(&Tool{Name: "claude-code", Version: "1.0"})

rec.Record("src/main.go", "https://example.com/conv/1",
Contributor{Type: ContributorAI, ModelID: "anthropic/claude-sonnet-4-5-20250929"},
[]Range{{StartLine: 10, EndLine: 25}},
)

trace := rec.Build(&Vcs{Type: VcsGit, Revision: "abc123"})

if trace.Version != Version {
t.Errorf("Version = %q, want %q", trace.Version, Version)
}
if trace.ID == "" {
t.Error("ID should not be empty")
}
if trace.Timestamp == "" {
t.Error("Timestamp should not be empty")
}
if trace.Tool.Name != "claude-code" {
t.Errorf("Tool.Name = %q, want %q", trace.Tool.Name, "claude-code")
}
if trace.Vcs.Type != VcsGit {
t.Errorf("Vcs.Type = %q, want %q", trace.Vcs.Type, VcsGit)
}
if trace.Vcs.Revision != "abc123" {
t.Errorf("Vcs.Revision = %q, want %q", trace.Vcs.Revision, "abc123")
}
if len(trace.Files) != 1 {
t.Fatalf("len(Files) = %d, want 1", len(trace.Files))
}
if trace.Files[0].Path != "src/main.go" {
t.Errorf("Files[0].Path = %q, want %q", trace.Files[0].Path, "src/main.go")
}
if len(trace.Files[0].Conversations) != 1 {
t.Fatalf("len(Conversations) = %d, want 1", len(trace.Files[0].Conversations))
}

conv := trace.Files[0].Conversations[0]
if conv.URL != "https://example.com/conv/1" {
t.Errorf("URL = %q, want %q", conv.URL, "https://example.com/conv/1")
}
if conv.Contributor.Type != ContributorAI {
t.Errorf("Contributor.Type = %q, want %q", conv.Contributor.Type, ContributorAI)
}
if len(conv.Ranges) != 1 {
t.Fatalf("len(Ranges) = %d, want 1", len(conv.Ranges))
}
if conv.Ranges[0].StartLine != 10 || conv.Ranges[0].EndLine != 25 {
t.Errorf("Range = {%d, %d}, want {10, 25}", conv.Ranges[0].StartLine, conv.Ranges[0].EndLine)
}
}

func TestMultipleRecords(t *testing.T) {
rec := NewRecorder(nil)

rec.Record("a.go", "", Contributor{Type: ContributorAI}, nil)
rec.Record("b.go", "", Contributor{Type: ContributorHuman}, nil)
rec.Record("a.go", "https://example.com/conv/2", Contributor{Type: ContributorMixed}, nil)

trace := rec.Build(nil)

if len(trace.Files) != 2 {
t.Fatalf("len(Files) = %d, want 2", len(trace.Files))
}

// Find a.go — it should have 2 conversations.
var aFile *File
for i := range trace.Files {
if trace.Files[i].Path == "a.go" {
aFile = &trace.Files[i]
break
}
}
if aFile == nil {
t.Fatal("File a.go not found")
}
if len(aFile.Conversations) != 2 {
t.Errorf("a.go conversations = %d, want 2", len(aFile.Conversations))
}
}

func TestBuildWithNilVcsAndTool(t *testing.T) {
rec := NewRecorder(nil)
rec.Record("file.go", "", Contributor{Type: ContributorUnknown}, nil)

trace := rec.Build(nil)

if trace.Vcs != nil {
t.Error("Vcs should be nil")
}
if trace.Tool != nil {
t.Error("Tool should be nil")
}
if trace.Version != Version {
t.Errorf("Version = %q, want %q", trace.Version, Version)
}
}

func TestReset(t *testing.T) {
rec := NewRecorder(&Tool{Name: "test"})

rec.Record("a.go", "", Contributor{Type: ContributorAI}, nil)
rec.Record("b.go", "", Contributor{Type: ContributorAI}, nil)

rec.Reset()

trace := rec.Build(nil)
if len(trace.Files) != 0 {
t.Errorf("after Reset, len(Files) = %d, want 0", len(trace.Files))
}
}

func TestBuildGeneratesUniqueIDs(t *testing.T) {
rec := NewRecorder(nil)
rec.Record("file.go", "", Contributor{Type: ContributorAI}, nil)

trace1 := rec.Build(nil)
trace2 := rec.Build(nil)

if trace1.ID == trace2.ID {
t.Errorf("Build should generate unique IDs, got same: %s", trace1.ID)
}
}

func TestBuildTimestampFormat(t *testing.T) {
rec := NewRecorder(nil)
rec.Record("file.go", "", Contributor{Type: ContributorAI}, nil)

trace := rec.Build(nil)

// RFC3339 timestamps contain "T" and end with "Z" (UTC).
if !strings.Contains(trace.Timestamp, "T") {
t.Errorf("Timestamp %q does not look like RFC3339", trace.Timestamp)
}
if !strings.HasSuffix(trace.Timestamp, "Z") {
t.Errorf("Timestamp %q should end with Z (UTC)", trace.Timestamp)
}
}
30 changes: 30 additions & 0 deletions agenttrace/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package agenttrace

import (
"encoding/json"
"os"
)

// WriteTrace writes a single trace record to the given file path as
// indented JSON. It creates or truncates the file.
func WriteTrace(path string, record TraceRecord) error {
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0644)
}

// ReadTrace reads a single trace record from the given file path.
func ReadTrace(path string) (TraceRecord, error) {
data, err := os.ReadFile(path)
if err != nil {
return TraceRecord{}, err
}
var record TraceRecord
if err := json.Unmarshal(data, &record); err != nil {
return TraceRecord{}, err
}
return record, nil
}
Loading