diff --git a/agenttrace/doc.go b/agenttrace/doc.go new file mode 100644 index 0000000..c9121d8 --- /dev/null +++ b/agenttrace/doc.go @@ -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 diff --git a/agenttrace/recorder.go b/agenttrace/recorder.go new file mode 100644 index 0000000..da92e4a --- /dev/null +++ b/agenttrace/recorder.go @@ -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]) +} diff --git a/agenttrace/recorder_test.go b/agenttrace/recorder_test.go new file mode 100644 index 0000000..255690d --- /dev/null +++ b/agenttrace/recorder_test.go @@ -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) + } +} diff --git a/agenttrace/store.go b/agenttrace/store.go new file mode 100644 index 0000000..00bd0d9 --- /dev/null +++ b/agenttrace/store.go @@ -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 +} diff --git a/agenttrace/store_test.go b/agenttrace/store_test.go new file mode 100644 index 0000000..1eaa100 --- /dev/null +++ b/agenttrace/store_test.go @@ -0,0 +1,129 @@ +package agenttrace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteAndReadTrace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trace.json") + + record := TraceRecord{ + Version: "0.1.0", + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: "2026-01-25T10:00:00Z", + Vcs: &Vcs{Type: VcsGit, Revision: "abc123"}, + Tool: &Tool{Name: "claude-code", Version: "1.0.0"}, + Files: []File{ + { + Path: "src/main.go", + Conversations: []Conversation{ + { + URL: "https://example.com/conv/1", + Contributor: &Contributor{Type: ContributorAI}, + Ranges: []Range{{StartLine: 1, EndLine: 50}}, + }, + }, + }, + }, + } + + if err := WriteTrace(path, record); err != nil { + t.Fatalf("WriteTrace failed: %v", err) + } + + got, err := ReadTrace(path) + if err != nil { + t.Fatalf("ReadTrace failed: %v", err) + } + + if got.Version != record.Version { + t.Errorf("Version = %q, want %q", got.Version, record.Version) + } + if got.ID != record.ID { + t.Errorf("ID = %q, want %q", got.ID, record.ID) + } + if got.Timestamp != record.Timestamp { + t.Errorf("Timestamp = %q, want %q", got.Timestamp, record.Timestamp) + } + if got.Vcs.Type != VcsGit { + t.Errorf("Vcs.Type = %q, want %q", got.Vcs.Type, VcsGit) + } + if got.Tool.Name != "claude-code" { + t.Errorf("Tool.Name = %q, want %q", got.Tool.Name, "claude-code") + } + if len(got.Files) != 1 { + t.Fatalf("len(Files) = %d, want 1", len(got.Files)) + } + if got.Files[0].Path != "src/main.go" { + t.Errorf("Path = %q, want %q", got.Files[0].Path, "src/main.go") + } +} + +func TestReadTraceFileNotFound(t *testing.T) { + _, err := ReadTrace("/nonexistent/path/trace.json") + if err == nil { + t.Error("ReadTrace should fail for nonexistent file") + } +} + +func TestReadTraceInvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + os.WriteFile(path, []byte("not json"), 0644) + + _, err := ReadTrace(path) + if err == nil { + t.Error("ReadTrace should fail for invalid JSON") + } +} + +func TestWriteTraceCreatesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "trace.json") + + // WriteTrace should fail if parent directory doesn't exist (expected behavior). + record := TraceRecord{ + Version: "0.1.0", + ID: "test-id", + Timestamp: "2026-01-25T10:00:00Z", + Files: []File{}, + } + + err := WriteTrace(path, record) + if err == nil { + t.Error("WriteTrace should fail when parent directory doesn't exist") + } +} + +func TestWriteTraceIndentedJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trace.json") + + record := TraceRecord{ + Version: "0.1.0", + ID: "test-id", + Timestamp: "2026-01-25T10:00:00Z", + Files: []File{}, + } + + if err := WriteTrace(path, record); err != nil { + t.Fatalf("WriteTrace failed: %v", err) + } + + data, _ := os.ReadFile(path) + content := string(data) + + // Verify the output is indented (contains newlines and spaces). + if len(content) < 10 { + t.Fatal("Output too short to be indented JSON") + } + if content[0] != '{' { + t.Errorf("Expected JSON to start with '{', got %q", string(content[0])) + } + if content[len(content)-1] != '\n' { + t.Error("Expected trailing newline") + } +} diff --git a/agenttrace/types.go b/agenttrace/types.go new file mode 100644 index 0000000..2ecd3e4 --- /dev/null +++ b/agenttrace/types.go @@ -0,0 +1,78 @@ +package agenttrace + +// ContributorType identifies whether code was written by a human, AI, or a mix. +type ContributorType string + +const ( + ContributorHuman ContributorType = "human" + ContributorAI ContributorType = "ai" + ContributorMixed ContributorType = "mixed" + ContributorUnknown ContributorType = "unknown" +) + +// VcsType identifies the version control system. +type VcsType string + +const ( + VcsGit VcsType = "git" + VcsJJ VcsType = "jj" + VcsHg VcsType = "hg" + VcsSvn VcsType = "svn" +) + +// TraceRecord is the top-level Agent Trace record. +type TraceRecord struct { + Version string `json:"version"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + Vcs *Vcs `json:"vcs,omitempty"` + Tool *Tool `json:"tool,omitempty"` + Files []File `json:"files"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// File represents a traced file with its associated conversations. +type File struct { + Path string `json:"path"` + Conversations []Conversation `json:"conversations"` +} + +// Conversation links a conversation URL to the code ranges it produced. +type Conversation struct { + URL string `json:"url,omitempty"` + Contributor *Contributor `json:"contributor,omitempty"` + Ranges []Range `json:"ranges"` + Related []RelatedResource `json:"related,omitempty"` +} + +// Range identifies a line range within a file attributed to a conversation. +type Range struct { + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + ContentHash string `json:"content_hash,omitempty"` + Contributor *Contributor `json:"contributor,omitempty"` +} + +// Contributor describes who wrote the code. +type Contributor struct { + Type ContributorType `json:"type"` + ModelID string `json:"model_id,omitempty"` +} + +// Tool identifies the AI coding tool that produced the code. +type Tool struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// Vcs holds version control information for the trace. +type Vcs struct { + Type VcsType `json:"type"` + Revision string `json:"revision"` +} + +// RelatedResource links to an external resource associated with a conversation. +type RelatedResource struct { + Type string `json:"type"` + URL string `json:"url"` +} diff --git a/agenttrace/types_test.go b/agenttrace/types_test.go new file mode 100644 index 0000000..c44a919 --- /dev/null +++ b/agenttrace/types_test.go @@ -0,0 +1,214 @@ +package agenttrace + +import ( + "encoding/json" + "testing" +) + +func TestTraceRecordRoundTrip(t *testing.T) { + record := TraceRecord{ + Version: "0.1.0", + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: "2026-01-25T10:00:00Z", + Vcs: &Vcs{Type: VcsGit, Revision: "abc123"}, + Tool: &Tool{Name: "claude-code", Version: "1.0.0"}, + Files: []File{ + { + Path: "src/app.ts", + Conversations: []Conversation{ + { + URL: "https://example.com/conversation/1", + Contributor: &Contributor{Type: ContributorAI, ModelID: "anthropic/claude-sonnet-4-5-20250929"}, + Ranges: []Range{ + {StartLine: 1, EndLine: 50}, + {StartLine: 75, EndLine: 100, ContentHash: "sha256:abc"}, + }, + Related: []RelatedResource{ + {Type: "issue", URL: "https://github.com/org/repo/issues/1"}, + }, + }, + }, + }, + }, + Metadata: map[string]any{"source": "hookshot"}, + } + + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var got TraceRecord + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if got.Version != record.Version { + t.Errorf("Version = %q, want %q", got.Version, record.Version) + } + if got.ID != record.ID { + t.Errorf("ID = %q, want %q", got.ID, record.ID) + } + if got.Timestamp != record.Timestamp { + t.Errorf("Timestamp = %q, want %q", got.Timestamp, record.Timestamp) + } + if got.Vcs.Type != VcsGit { + t.Errorf("Vcs.Type = %q, want %q", got.Vcs.Type, VcsGit) + } + if got.Vcs.Revision != "abc123" { + t.Errorf("Vcs.Revision = %q, want %q", got.Vcs.Revision, "abc123") + } + if got.Tool.Name != "claude-code" { + t.Errorf("Tool.Name = %q, want %q", got.Tool.Name, "claude-code") + } + if len(got.Files) != 1 { + t.Fatalf("len(Files) = %d, want 1", len(got.Files)) + } + if got.Files[0].Path != "src/app.ts" { + t.Errorf("Files[0].Path = %q, want %q", got.Files[0].Path, "src/app.ts") + } + if len(got.Files[0].Conversations) != 1 { + t.Fatalf("len(Conversations) = %d, want 1", len(got.Files[0].Conversations)) + } + + conv := got.Files[0].Conversations[0] + if conv.URL != "https://example.com/conversation/1" { + t.Errorf("Conversation.URL = %q, want %q", conv.URL, "https://example.com/conversation/1") + } + if conv.Contributor.Type != ContributorAI { + t.Errorf("Contributor.Type = %q, want %q", conv.Contributor.Type, ContributorAI) + } + if conv.Contributor.ModelID != "anthropic/claude-sonnet-4-5-20250929" { + t.Errorf("Contributor.ModelID = %q, want %q", conv.Contributor.ModelID, "anthropic/claude-sonnet-4-5-20250929") + } + if len(conv.Ranges) != 2 { + t.Fatalf("len(Ranges) = %d, want 2", len(conv.Ranges)) + } + if conv.Ranges[0].StartLine != 1 || conv.Ranges[0].EndLine != 50 { + t.Errorf("Range[0] = {%d, %d}, want {1, 50}", conv.Ranges[0].StartLine, conv.Ranges[0].EndLine) + } + if conv.Ranges[1].ContentHash != "sha256:abc" { + t.Errorf("Range[1].ContentHash = %q, want %q", conv.Ranges[1].ContentHash, "sha256:abc") + } + if len(conv.Related) != 1 { + t.Fatalf("len(Related) = %d, want 1", len(conv.Related)) + } + if conv.Related[0].Type != "issue" { + t.Errorf("Related[0].Type = %q, want %q", conv.Related[0].Type, "issue") + } +} + +func TestMinimalTraceRecord(t *testing.T) { + // Matches the minimal valid example from the spec. + record := TraceRecord{ + Version: "0.1.0", + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: "2026-01-25T10:00:00Z", + Files: []File{ + { + Path: "src/app.ts", + Conversations: []Conversation{ + { + Contributor: &Contributor{Type: ContributorAI}, + Ranges: []Range{{StartLine: 1, EndLine: 50}}, + }, + }, + }, + }, + } + + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Verify omitempty: optional fields should not appear. + var raw map[string]any + json.Unmarshal(data, &raw) + + if _, ok := raw["vcs"]; ok { + t.Error("vcs should be omitted when nil") + } + if _, ok := raw["tool"]; ok { + t.Error("tool should be omitted when nil") + } + if _, ok := raw["metadata"]; ok { + t.Error("metadata should be omitted when nil") + } + + // Verify round-trip. + var got TraceRecord + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + if got.Version != "0.1.0" { + t.Errorf("Version = %q, want %q", got.Version, "0.1.0") + } + if len(got.Files) != 1 { + t.Fatalf("len(Files) = %d, want 1", len(got.Files)) + } +} + +func TestContributorTypeValues(t *testing.T) { + tests := []struct { + ct ContributorType + want string + }{ + {ContributorHuman, "human"}, + {ContributorAI, "ai"}, + {ContributorMixed, "mixed"}, + {ContributorUnknown, "unknown"}, + } + + for _, tt := range tests { + if string(tt.ct) != tt.want { + t.Errorf("ContributorType = %q, want %q", tt.ct, tt.want) + } + } +} + +func TestVcsTypeValues(t *testing.T) { + tests := []struct { + vt VcsType + want string + }{ + {VcsGit, "git"}, + {VcsJJ, "jj"}, + {VcsHg, "hg"}, + {VcsSvn, "svn"}, + } + + for _, tt := range tests { + if string(tt.vt) != tt.want { + t.Errorf("VcsType = %q, want %q", tt.vt, tt.want) + } + } +} + +func TestRangeWithContributorOverride(t *testing.T) { + conv := Conversation{ + Contributor: &Contributor{Type: ContributorAI}, + Ranges: []Range{ + {StartLine: 1, EndLine: 10}, + {StartLine: 11, EndLine: 20, Contributor: &Contributor{Type: ContributorHuman}}, + }, + } + + data, err := json.Marshal(conv) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var got Conversation + json.Unmarshal(data, &got) + + if got.Ranges[0].Contributor != nil { + t.Error("Range[0].Contributor should be nil (omitted)") + } + if got.Ranges[1].Contributor == nil { + t.Fatal("Range[1].Contributor should not be nil") + } + if got.Ranges[1].Contributor.Type != ContributorHuman { + t.Errorf("Range[1].Contributor.Type = %q, want %q", got.Ranges[1].Contributor.Type, ContributorHuman) + } +} diff --git a/doc.go b/doc.go index d7eeeba..511c483 100644 --- a/doc.go +++ b/doc.go @@ -112,6 +112,7 @@ // - hookshot (this package): Core Run/Register/RunCommand functions // - hookshot/claude: Types and helpers for Claude Code hooks // - hookshot/cursor: Types and helpers for Cursor hooks +// - hookshot/agenttrace: Agent Trace schema types, recorder, and file I/O // - hookshot/build: Cross-platform build tool // - hookshot/internal: Internal JSON I/O (not for external use) // diff --git a/examples/multi-hook/main.go b/examples/multi-hook/main.go index 2b1ce0b..cd5d214 100644 --- a/examples/multi-hook/main.go +++ b/examples/multi-hook/main.go @@ -60,12 +60,16 @@ import ( "strings" "github.com/CorridorSecurity/hookshot" + "github.com/CorridorSecurity/hookshot/agenttrace" "github.com/CorridorSecurity/hookshot/cascade" "github.com/CorridorSecurity/hookshot/claude" "github.com/CorridorSecurity/hookshot/cursor" "github.com/CorridorSecurity/hookshot/droid" ) +// traceRecorder accumulates AI file edit events for agent-trace attribution. +var traceRecorder = agenttrace.NewRecorder(&agenttrace.Tool{Name: "hookshot-example"}) + func main() { // ========================================================================== // UNIFIED HANDLERS @@ -149,6 +153,12 @@ func handleAfterFileEdit(ctx hookshot.FileEditContext) hookshot.FileEditDecision // Log file edits fmt.Printf("File edited: %s\n", ctx.FilePath) + // Record the edit for agent-trace attribution + traceRecorder.Record(ctx.FilePath, "", + agenttrace.Contributor{Type: agenttrace.ContributorAI}, + nil, // no line-level ranges in this example + ) + // Claude Code: Add context if TODO found if ctx.Platform == hookshot.PlatformClaude { for _, edit := range ctx.Edits {