From cdf9203e30224cea80c6a3e95e7ea984d3c7fd29 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Sat, 7 Feb 2026 08:44:21 -0800 Subject: [PATCH 1/3] add plan to vc --- 2026-02-07-implement-the-following-plan.txt | 364 ++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 2026-02-07-implement-the-following-plan.txt diff --git a/2026-02-07-implement-the-following-plan.txt b/2026-02-07-implement-the-following-plan.txt new file mode 100644 index 0000000..a69000e --- /dev/null +++ b/2026-02-07-implement-the-following-plan.txt @@ -0,0 +1,364 @@ + +╭─── Claude Code v2.1.34 ──────────────────────────────────────────────────────╮ +│ │ Tips for getting started │ +│ Welcome back! │ Run /init to create a CLAUDE.md file with… │ +│ │ ────────────────────────────────────────── │ +│ ▐▛███▜▌ │ Recent activity │ +│ ▝▜█████▛▘ │ No recent activity │ +│ ▘▘ ▝▝ │ │ +│ │ │ +│ Opus 4.6 · Claude Pro │ │ +│ ~/Documents/GitHub/hookshot │ │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Plan to implement │ +│ │ +│ Add Agent Trace Schema Support to Hookshot │ +│ │ +│ Context │ +│ │ +│ https://github.com/cursor/agent-trace is an open specification (v0.1.0) for │ +│ tracing AI-generated code. It defines a vendor-neutral format for recording │ +│ which parts of code were written by AI vs humans, linking to agent │ +│ conversations, and tracking line-level attribution. │ +│ │ +│ Hookshot already intercepts file edits from AI coding agents (Claude Code, │ +│ Cursor, Cascade, Droid) via its OnAfterFileEdit unified handler. Adding │ +│ agent-trace support lets hookshot users automatically generate trace records │ +│ when AI agents edit files — connecting the security/observability layer │ +│ (hookshot) with the attribution layer (agent-trace). │ +│ │ +│ Plan │ +│ │ +│ 1. Create agenttrace/ package with Go types mirroring the schema │ +│ │ +│ New file: agenttrace/types.go │ +│ │ +│ Define Go structs matching the agent-trace JSON schema: │ +│ - TraceRecord — top-level record (version, id, timestamp, vcs, tool, files, │ +│ metadata) │ +│ - File — path + conversations array │ +│ - Conversation — url, contributor, ranges, related resources │ +│ - Range — start_line, end_line, content_hash, optional contributor override │ +│ - Contributor — type (human/ai/mixed/unknown) + model_id │ +│ - Tool — name + version │ +│ - Vcs — type (git/jj/hg/svn) + revision │ +│ - RelatedResource — type + url │ +│ - String constants for ContributorType and VcsType enums │ +│ │ +│ All types use json struct tags matching the spec exactly. Optional fields │ +│ use omitempty. │ +│ │ +│ New file: agenttrace/doc.go — Package documentation with usage examples. │ +│ │ +│ 2. Add a Recorder for building trace records from hookshot hooks │ +│ │ +│ New file: agenttrace/recorder.go │ +│ │ +│ A Recorder accumulates file edit information across multiple hook │ +│ invocations and builds a TraceRecord: │ +│ │ +│ type Recorder struct { ... } │ +│ │ +│ func NewRecorder(tool *Tool) *Recorder │ +│ func (r *Recorder) Record(file, conversationURL string, contributor │ +│ Contributor, ranges []Range) │ +│ func (r *Recorder) Build(vcs *Vcs) TraceRecord │ +│ func (r *Recorder) Reset() │ +│ │ +│ - Record() adds a conversation entry for a file │ +│ - Build() produces a complete TraceRecord with UUID, timestamp, and all │ +│ accumulated data │ +│ - Reset() clears accumulated data for the next trace cycle │ +│ │ +│ No import of the root hookshot package (avoids circular deps). Users call it │ +│ from their hookshot handlers directly. │ +│ │ +│ 3. Add file-based storage for trace records │ +│ │ +│ New file: agenttrace/store.go │ +│ │ +│ Simple JSON file I/O: │ +│ │ +│ func WriteTrace(path string, record TraceRecord) error // Write single │ +│ trace to file │ +│ func ReadTrace(path string) (TraceRecord, error) // Read single │ +│ trace from file │ +│ │ +│ Uses only stdlib (encoding/json, os). Maintains the zero-dependency │ +│ constraint. │ +│ │ +│ 4. Add tests │ +│ │ +│ New file: agenttrace/types_test.go — JSON marshal/unmarshal round-trip │ +│ tests, validation of the example from the spec. │ +│ │ +│ New file: agenttrace/recorder_test.go — Test Record/Build/Reset workflow. │ +│ │ +│ New file: agenttrace/store_test.go — Test WriteTrace/ReadTrace round-trip. │ +│ │ +│ 5. Update example to demonstrate integration │ +│ │ +│ Modified file: examples/multi-hook/main.go — Add a trace recording example │ +│ in the handleAfterFileEdit function showing how to use agenttrace.Recorder │ +│ from a hookshot file edit hook. │ +│ │ +│ 6. Update package docs │ +│ │ +│ Modified file: doc.go — Add hookshot/agenttrace to the packages list. │ +│ │ +│ Files to Create │ +│ │ +│ - agenttrace/doc.go │ +│ - agenttrace/types.go │ +│ - agenttrace/recorder.go │ +│ - agenttrace/store.go │ +│ - agenttrace/types_test.go │ +│ - agenttrace/recorder_test.go │ +│ - agenttrace/store_test.go │ +│ │ +│ Files to Modify │ +│ │ +│ - examples/multi-hook/main.go — Add agenttrace usage in file edit handler │ +│ - doc.go — Mention new package │ +│ │ +│ Verification │ +│ │ +│ 1. go build ./... — Ensure everything compiles │ +│ 2. go test ./... — All tests pass (existing + new) │ +│ 3. go vet ./... — No lint issues │ +╰──────────────────────────────────────────────────────────────────────────────╯ + +⏺ I'll start by reading the existing codebase to understand the structure, then + implement the plan. + +⏺ Explore(Explore hookshot codebase structure) + ⎿  Done (22 tool uses · 53.9k tokens · 44s) + +⏺ Now let me read the key files I need to understand for the implementation. + +⏺ Read 4 files (ctrl+o to expand) + +⏺ Now let me also fetch the agent-trace spec to get the exact schema right. + +⏺ Fetch(https://raw.githubusercontent.com/cursor/agent-trace/main/README.md) + ⎿  Received 15.1KB (200 OK) + +⏺ Read 1 file (ctrl+o to expand) + +⏺ Now I have all the information I need. Let me also check existing test + patterns quickly. + +⏺ Read 1 file (ctrl+o to expand) + +⏺ Now I have a thorough understanding of the codebase. Let me create all the + files. I'll create them in parallel where possible. + +⏺ Bash(mkdir -p /Users/tan/Documents/GitHub/hookshot/agenttrace) + ⎿  (No content) + +⏺ Write(agenttrace/doc.go) + ⎿  Wrote 53 lines to agenttrace/doc.go + 1 // Package agenttrace provides types and utilities for generatin + g Agent Trace + 2 // records, an open specification for tracing AI-generated code. + 3 // + 4 // Agent Trace (https://github.com/cursor/agent-trace) defines a + vendor-neutral + 5 // JSON format for recording which parts of code were written by + AI vs humans, + 6 // linking to agent conversations, and tracking line-level attri + bution. + 7 // + 8 // # Types + 9 // + 10 // The package mirrors the Agent Trace v0.1.0 JSON schema: + … +43 lines (ctrl+o to expand) + +⏺ Write(agenttrace/types.go) + ⎿  Wrote 79 lines to agenttrace/types.go + 1 package agenttrace + 2 + 3 // ContributorType identifies whether code was written by a huma + n, AI, or a mix. + 4 type ContributorType string + 5 + 6 const ( + 7 ContributorHuman ContributorType = "human" + 8 ContributorAI ContributorType = "ai" + 9 ContributorMixed ContributorType = "mixed" + 10 ContributorUnknown ContributorType = "unknown" + … +69 lines (ctrl+o to expand) + +⏺ Write(agenttrace/recorder.go) + ⎿  Wrote 85 lines to agenttrace/recorder.go + 1 package agenttrace + 2 + 3 import ( + 4 "crypto/rand" + 5 "fmt" + 6 "sync" + 7 "time" + 8 ) + 9 + 10 // Version is the Agent Trace specification version implemented + by this package. + … +75 lines (ctrl+o to expand) + +⏺ Write(agenttrace/store.go) + ⎿  Wrote 31 lines to agenttrace/store.go + 1 package agenttrace + 2 + 3 import ( + 4 "encoding/json" + 5 "os" + 6 ) + 7 + 8 // WriteTrace writes a single trace record to the given file pat + h as + 9 // indented JSON. It creates or truncates the file. + 10 func WriteTrace(path string, record TraceRecord) error { + … +21 lines (ctrl+o to expand) + +⏺ Now let me create the test files. + +⏺ Write(agenttrace/types_test.go) + ⎿  Wrote 215 lines to agenttrace/types_test.go + 1 package agenttrace + 2 + 3 import ( + 4 "encoding/json" + 5 "testing" + 6 ) + 7 + 8 func TestTraceRecordRoundTrip(t *testing.T) { + 9 record := TraceRecord{ + 10 Version: "0.1.0", + … +205 lines (ctrl+o to expand) + +⏺ Write(agenttrace/recorder_test.go) + ⎿  Wrote 154 lines to agenttrace/recorder_test.go + 1 package agenttrace + 2 + 3 import ( + 4 "strings" + 5 "testing" + 6 ) + 7 + 8 func TestNewRecorder(t *testing.T) { + 9 rec := NewRecorder(&Tool{Name: "test-tool", Version: "1.0"}) + 10 if rec == nil { + … +144 lines (ctrl+o to expand) + +⏺ Write(agenttrace/store_test.go) + ⎿  Wrote 130 lines to agenttrace/store_test.go + 1 package agenttrace + 2 + 3 import ( + 4 "os" + 5 "path/filepath" + 6 "testing" + 7 ) + 8 + 9 func TestWriteAndReadTrace(t *testing.T) { + 10 dir := t.TempDir() + … +120 lines (ctrl+o to expand) + +⏺ Now let me update the existing files — the example and the root doc.go. + +⏺ Update(examples/multi-hook/main.go) + ⎿  Added 4 lines + 60 "strings" + 61 + 62 "github.com/CorridorSecurity/hookshot" + 63 + "github.com/CorridorSecurity/hookshot/agenttrace" + 64 "github.com/CorridorSecurity/hookshot/cascade" + 65 "github.com/CorridorSecurity/hookshot/claude" + 66 "github.com/CorridorSecurity/hookshot/cursor" + 67 "github.com/CorridorSecurity/hookshot/droid" + 68 ) + 69 + 70 +// traceRecorder accumulates AI file edit events for agent-trac + +e attribution. + 71 +var traceRecorder = agenttrace.NewRecorder(&agenttrace.Tool{Nam + +e: "hookshot-example"}) + 72 + + 73 func main() { + 74 // ========================================================== + ================ + 75 // UNIFIED HANDLERS + +⏺ Update(examples/multi-hook/main.go) + ⎿  Added 6 lines + 153 // Log file edits + 154 fmt.Printf("File edited: %s\n", ctx.FilePath) + 155 + 156 + // Record the edit for agent-trace attribution + 157 + traceRecorder.Record(ctx.FilePath, "", + 158 + agenttrace.Contributor{Type: agenttrace.ContributorAI}, + 159 + nil, // no line-level ranges in this example + 160 + ) + 161 + + 162 // Claude Code: Add context if TODO found + 163 if ctx.Platform == hookshot.PlatformClaude { + 164 for _, edit := range ctx.Edits { + +⏺ Update(doc.go) + ⎿  Added 1 line + 112 // - hookshot (this package): Core Run/Register/RunCommand f + unctions + 113 // - hookshot/claude: Types and helpers for Claude Code hook + s + 114 // - hookshot/cursor: Types and helpers for Cursor hooks + 115 +// - hookshot/agenttrace: Agent Trace schema types, recorder + +, and file I/O + 116 // - hookshot/build: Cross-platform build tool + 117 // - hookshot/internal: Internal JSON I/O (not for external + use) + 118 // + +⏺ Now let me verify everything compiles and tests pass. + +⏺ Bash(go build ./...) + ⎿  (No content) + +⏺ Bash(go vet ./...) + ⎿  (No content) + +⏺ Bash(go test ./...) + ⎿ ok github.com/CorridorSecurity/hookshot 0.341s + ok github.com/CorridorSecurity/hookshot/agenttrace 0.663s + ? github.com/CorridorSecurity/hookshot/build [no test files] + … +8 lines (ctrl+o to expand) + + +⏺ All builds pass, no vet issues, and all tests pass. Here's a summary of what + was implemented: + + Files Created + + - agenttrace/doc.go — Package documentation with usage examples for recording, + storing, and integrating with hookshot + - agenttrace/types.go — Go structs matching the Agent Trace v0.1.0 JSON + schema: TraceRecord, File, Conversation, Range, Contributor, Tool, Vcs, + RelatedResource, plus enum constants for ContributorType and VcsType + - agenttrace/recorder.go — Thread-safe Recorder with Record(), Build(), and + Reset() methods for accumulating file edits and producing trace records with + auto-generated UUIDs and timestamps + - agenttrace/store.go — WriteTrace() and ReadTrace() for JSON file I/O (stdlib + only) + - agenttrace/types_test.go — JSON marshal/unmarshal round-trip tests, minimal + record test, enum value tests, contributor override test + - agenttrace/recorder_test.go — Tests for Record/Build/Reset workflow, + multiple records, nil params, unique IDs, timestamp format + - agenttrace/store_test.go — Tests for WriteTrace/ReadTrace round-trip, error + cases, indented output + + Files Modified + + - examples/multi-hook/main.go — Added traceRecorder and agent-trace recording + in handleAfterFileEdit + - doc.go — Added hookshot/agenttrace to the packages list + +✻ Cooked for 3m 26s \ No newline at end of file From 7dce4cb7a58f35698c3f2542dd0afb5a3ce5f0ca Mon Sep 17 00:00:00 2001 From: Tanuj Date: Sat, 7 Feb 2026 08:44:26 -0800 Subject: [PATCH 2/3] Revert "add plan to vc" This reverts commit cdf9203e30224cea80c6a3e95e7ea984d3c7fd29. --- 2026-02-07-implement-the-following-plan.txt | 364 -------------------- 1 file changed, 364 deletions(-) delete mode 100644 2026-02-07-implement-the-following-plan.txt diff --git a/2026-02-07-implement-the-following-plan.txt b/2026-02-07-implement-the-following-plan.txt deleted file mode 100644 index a69000e..0000000 --- a/2026-02-07-implement-the-following-plan.txt +++ /dev/null @@ -1,364 +0,0 @@ - -╭─── Claude Code v2.1.34 ──────────────────────────────────────────────────────╮ -│ │ Tips for getting started │ -│ Welcome back! │ Run /init to create a CLAUDE.md file with… │ -│ │ ────────────────────────────────────────── │ -│ ▐▛███▜▌ │ Recent activity │ -│ ▝▜█████▛▘ │ No recent activity │ -│ ▘▘ ▝▝ │ │ -│ │ │ -│ Opus 4.6 · Claude Pro │ │ -│ ~/Documents/GitHub/hookshot │ │ -╰──────────────────────────────────────────────────────────────────────────────╯ - -╭──────────────────────────────────────────────────────────────────────────────╮ -│ Plan to implement │ -│ │ -│ Add Agent Trace Schema Support to Hookshot │ -│ │ -│ Context │ -│ │ -│ https://github.com/cursor/agent-trace is an open specification (v0.1.0) for │ -│ tracing AI-generated code. It defines a vendor-neutral format for recording │ -│ which parts of code were written by AI vs humans, linking to agent │ -│ conversations, and tracking line-level attribution. │ -│ │ -│ Hookshot already intercepts file edits from AI coding agents (Claude Code, │ -│ Cursor, Cascade, Droid) via its OnAfterFileEdit unified handler. Adding │ -│ agent-trace support lets hookshot users automatically generate trace records │ -│ when AI agents edit files — connecting the security/observability layer │ -│ (hookshot) with the attribution layer (agent-trace). │ -│ │ -│ Plan │ -│ │ -│ 1. Create agenttrace/ package with Go types mirroring the schema │ -│ │ -│ New file: agenttrace/types.go │ -│ │ -│ Define Go structs matching the agent-trace JSON schema: │ -│ - TraceRecord — top-level record (version, id, timestamp, vcs, tool, files, │ -│ metadata) │ -│ - File — path + conversations array │ -│ - Conversation — url, contributor, ranges, related resources │ -│ - Range — start_line, end_line, content_hash, optional contributor override │ -│ - Contributor — type (human/ai/mixed/unknown) + model_id │ -│ - Tool — name + version │ -│ - Vcs — type (git/jj/hg/svn) + revision │ -│ - RelatedResource — type + url │ -│ - String constants for ContributorType and VcsType enums │ -│ │ -│ All types use json struct tags matching the spec exactly. Optional fields │ -│ use omitempty. │ -│ │ -│ New file: agenttrace/doc.go — Package documentation with usage examples. │ -│ │ -│ 2. Add a Recorder for building trace records from hookshot hooks │ -│ │ -│ New file: agenttrace/recorder.go │ -│ │ -│ A Recorder accumulates file edit information across multiple hook │ -│ invocations and builds a TraceRecord: │ -│ │ -│ type Recorder struct { ... } │ -│ │ -│ func NewRecorder(tool *Tool) *Recorder │ -│ func (r *Recorder) Record(file, conversationURL string, contributor │ -│ Contributor, ranges []Range) │ -│ func (r *Recorder) Build(vcs *Vcs) TraceRecord │ -│ func (r *Recorder) Reset() │ -│ │ -│ - Record() adds a conversation entry for a file │ -│ - Build() produces a complete TraceRecord with UUID, timestamp, and all │ -│ accumulated data │ -│ - Reset() clears accumulated data for the next trace cycle │ -│ │ -│ No import of the root hookshot package (avoids circular deps). Users call it │ -│ from their hookshot handlers directly. │ -│ │ -│ 3. Add file-based storage for trace records │ -│ │ -│ New file: agenttrace/store.go │ -│ │ -│ Simple JSON file I/O: │ -│ │ -│ func WriteTrace(path string, record TraceRecord) error // Write single │ -│ trace to file │ -│ func ReadTrace(path string) (TraceRecord, error) // Read single │ -│ trace from file │ -│ │ -│ Uses only stdlib (encoding/json, os). Maintains the zero-dependency │ -│ constraint. │ -│ │ -│ 4. Add tests │ -│ │ -│ New file: agenttrace/types_test.go — JSON marshal/unmarshal round-trip │ -│ tests, validation of the example from the spec. │ -│ │ -│ New file: agenttrace/recorder_test.go — Test Record/Build/Reset workflow. │ -│ │ -│ New file: agenttrace/store_test.go — Test WriteTrace/ReadTrace round-trip. │ -│ │ -│ 5. Update example to demonstrate integration │ -│ │ -│ Modified file: examples/multi-hook/main.go — Add a trace recording example │ -│ in the handleAfterFileEdit function showing how to use agenttrace.Recorder │ -│ from a hookshot file edit hook. │ -│ │ -│ 6. Update package docs │ -│ │ -│ Modified file: doc.go — Add hookshot/agenttrace to the packages list. │ -│ │ -│ Files to Create │ -│ │ -│ - agenttrace/doc.go │ -│ - agenttrace/types.go │ -│ - agenttrace/recorder.go │ -│ - agenttrace/store.go │ -│ - agenttrace/types_test.go │ -│ - agenttrace/recorder_test.go │ -│ - agenttrace/store_test.go │ -│ │ -│ Files to Modify │ -│ │ -│ - examples/multi-hook/main.go — Add agenttrace usage in file edit handler │ -│ - doc.go — Mention new package │ -│ │ -│ Verification │ -│ │ -│ 1. go build ./... — Ensure everything compiles │ -│ 2. go test ./... — All tests pass (existing + new) │ -│ 3. go vet ./... — No lint issues │ -╰──────────────────────────────────────────────────────────────────────────────╯ - -⏺ I'll start by reading the existing codebase to understand the structure, then - implement the plan. - -⏺ Explore(Explore hookshot codebase structure) - ⎿  Done (22 tool uses · 53.9k tokens · 44s) - -⏺ Now let me read the key files I need to understand for the implementation. - -⏺ Read 4 files (ctrl+o to expand) - -⏺ Now let me also fetch the agent-trace spec to get the exact schema right. - -⏺ Fetch(https://raw.githubusercontent.com/cursor/agent-trace/main/README.md) - ⎿  Received 15.1KB (200 OK) - -⏺ Read 1 file (ctrl+o to expand) - -⏺ Now I have all the information I need. Let me also check existing test - patterns quickly. - -⏺ Read 1 file (ctrl+o to expand) - -⏺ Now I have a thorough understanding of the codebase. Let me create all the - files. I'll create them in parallel where possible. - -⏺ Bash(mkdir -p /Users/tan/Documents/GitHub/hookshot/agenttrace) - ⎿  (No content) - -⏺ Write(agenttrace/doc.go) - ⎿  Wrote 53 lines to agenttrace/doc.go - 1 // Package agenttrace provides types and utilities for generatin - g Agent Trace - 2 // records, an open specification for tracing AI-generated code. - 3 // - 4 // Agent Trace (https://github.com/cursor/agent-trace) defines a - vendor-neutral - 5 // JSON format for recording which parts of code were written by - AI vs humans, - 6 // linking to agent conversations, and tracking line-level attri - bution. - 7 // - 8 // # Types - 9 // - 10 // The package mirrors the Agent Trace v0.1.0 JSON schema: - … +43 lines (ctrl+o to expand) - -⏺ Write(agenttrace/types.go) - ⎿  Wrote 79 lines to agenttrace/types.go - 1 package agenttrace - 2 - 3 // ContributorType identifies whether code was written by a huma - n, AI, or a mix. - 4 type ContributorType string - 5 - 6 const ( - 7 ContributorHuman ContributorType = "human" - 8 ContributorAI ContributorType = "ai" - 9 ContributorMixed ContributorType = "mixed" - 10 ContributorUnknown ContributorType = "unknown" - … +69 lines (ctrl+o to expand) - -⏺ Write(agenttrace/recorder.go) - ⎿  Wrote 85 lines to agenttrace/recorder.go - 1 package agenttrace - 2 - 3 import ( - 4 "crypto/rand" - 5 "fmt" - 6 "sync" - 7 "time" - 8 ) - 9 - 10 // Version is the Agent Trace specification version implemented - by this package. - … +75 lines (ctrl+o to expand) - -⏺ Write(agenttrace/store.go) - ⎿  Wrote 31 lines to agenttrace/store.go - 1 package agenttrace - 2 - 3 import ( - 4 "encoding/json" - 5 "os" - 6 ) - 7 - 8 // WriteTrace writes a single trace record to the given file pat - h as - 9 // indented JSON. It creates or truncates the file. - 10 func WriteTrace(path string, record TraceRecord) error { - … +21 lines (ctrl+o to expand) - -⏺ Now let me create the test files. - -⏺ Write(agenttrace/types_test.go) - ⎿  Wrote 215 lines to agenttrace/types_test.go - 1 package agenttrace - 2 - 3 import ( - 4 "encoding/json" - 5 "testing" - 6 ) - 7 - 8 func TestTraceRecordRoundTrip(t *testing.T) { - 9 record := TraceRecord{ - 10 Version: "0.1.0", - … +205 lines (ctrl+o to expand) - -⏺ Write(agenttrace/recorder_test.go) - ⎿  Wrote 154 lines to agenttrace/recorder_test.go - 1 package agenttrace - 2 - 3 import ( - 4 "strings" - 5 "testing" - 6 ) - 7 - 8 func TestNewRecorder(t *testing.T) { - 9 rec := NewRecorder(&Tool{Name: "test-tool", Version: "1.0"}) - 10 if rec == nil { - … +144 lines (ctrl+o to expand) - -⏺ Write(agenttrace/store_test.go) - ⎿  Wrote 130 lines to agenttrace/store_test.go - 1 package agenttrace - 2 - 3 import ( - 4 "os" - 5 "path/filepath" - 6 "testing" - 7 ) - 8 - 9 func TestWriteAndReadTrace(t *testing.T) { - 10 dir := t.TempDir() - … +120 lines (ctrl+o to expand) - -⏺ Now let me update the existing files — the example and the root doc.go. - -⏺ Update(examples/multi-hook/main.go) - ⎿  Added 4 lines - 60 "strings" - 61 - 62 "github.com/CorridorSecurity/hookshot" - 63 + "github.com/CorridorSecurity/hookshot/agenttrace" - 64 "github.com/CorridorSecurity/hookshot/cascade" - 65 "github.com/CorridorSecurity/hookshot/claude" - 66 "github.com/CorridorSecurity/hookshot/cursor" - 67 "github.com/CorridorSecurity/hookshot/droid" - 68 ) - 69 - 70 +// traceRecorder accumulates AI file edit events for agent-trac - +e attribution. - 71 +var traceRecorder = agenttrace.NewRecorder(&agenttrace.Tool{Nam - +e: "hookshot-example"}) - 72 + - 73 func main() { - 74 // ========================================================== - ================ - 75 // UNIFIED HANDLERS - -⏺ Update(examples/multi-hook/main.go) - ⎿  Added 6 lines - 153 // Log file edits - 154 fmt.Printf("File edited: %s\n", ctx.FilePath) - 155 - 156 + // Record the edit for agent-trace attribution - 157 + traceRecorder.Record(ctx.FilePath, "", - 158 + agenttrace.Contributor{Type: agenttrace.ContributorAI}, - 159 + nil, // no line-level ranges in this example - 160 + ) - 161 + - 162 // Claude Code: Add context if TODO found - 163 if ctx.Platform == hookshot.PlatformClaude { - 164 for _, edit := range ctx.Edits { - -⏺ Update(doc.go) - ⎿  Added 1 line - 112 // - hookshot (this package): Core Run/Register/RunCommand f - unctions - 113 // - hookshot/claude: Types and helpers for Claude Code hook - s - 114 // - hookshot/cursor: Types and helpers for Cursor hooks - 115 +// - hookshot/agenttrace: Agent Trace schema types, recorder - +, and file I/O - 116 // - hookshot/build: Cross-platform build tool - 117 // - hookshot/internal: Internal JSON I/O (not for external - use) - 118 // - -⏺ Now let me verify everything compiles and tests pass. - -⏺ Bash(go build ./...) - ⎿  (No content) - -⏺ Bash(go vet ./...) - ⎿  (No content) - -⏺ Bash(go test ./...) - ⎿ ok github.com/CorridorSecurity/hookshot 0.341s - ok github.com/CorridorSecurity/hookshot/agenttrace 0.663s - ? github.com/CorridorSecurity/hookshot/build [no test files] - … +8 lines (ctrl+o to expand) - - -⏺ All builds pass, no vet issues, and all tests pass. Here's a summary of what - was implemented: - - Files Created - - - agenttrace/doc.go — Package documentation with usage examples for recording, - storing, and integrating with hookshot - - agenttrace/types.go — Go structs matching the Agent Trace v0.1.0 JSON - schema: TraceRecord, File, Conversation, Range, Contributor, Tool, Vcs, - RelatedResource, plus enum constants for ContributorType and VcsType - - agenttrace/recorder.go — Thread-safe Recorder with Record(), Build(), and - Reset() methods for accumulating file edits and producing trace records with - auto-generated UUIDs and timestamps - - agenttrace/store.go — WriteTrace() and ReadTrace() for JSON file I/O (stdlib - only) - - agenttrace/types_test.go — JSON marshal/unmarshal round-trip tests, minimal - record test, enum value tests, contributor override test - - agenttrace/recorder_test.go — Tests for Record/Build/Reset workflow, - multiple records, nil params, unique IDs, timestamp format - - agenttrace/store_test.go — Tests for WriteTrace/ReadTrace round-trip, error - cases, indented output - - Files Modified - - - examples/multi-hook/main.go — Added traceRecorder and agent-trace recording - in handleAfterFileEdit - - doc.go — Added hookshot/agenttrace to the packages list - -✻ Cooked for 3m 26s \ No newline at end of file From c4054618982c5ca90f7a4f88c7727c20c1f222e2 Mon Sep 17 00:00:00 2001 From: Tanuj Date: Sat, 7 Feb 2026 08:57:57 -0800 Subject: [PATCH 3/3] add agenttrace schema for full hook traces --- agenttrace/doc.go | 52 +++++++++ agenttrace/recorder.go | 84 ++++++++++++++ agenttrace/recorder_test.go | 153 ++++++++++++++++++++++++++ agenttrace/store.go | 30 +++++ agenttrace/store_test.go | 129 ++++++++++++++++++++++ agenttrace/types.go | 78 +++++++++++++ agenttrace/types_test.go | 214 ++++++++++++++++++++++++++++++++++++ doc.go | 1 + examples/multi-hook/main.go | 10 ++ 9 files changed, 751 insertions(+) create mode 100644 agenttrace/doc.go create mode 100644 agenttrace/recorder.go create mode 100644 agenttrace/recorder_test.go create mode 100644 agenttrace/store.go create mode 100644 agenttrace/store_test.go create mode 100644 agenttrace/types.go create mode 100644 agenttrace/types_test.go 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 {