@kennykeni/agent-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # agent-trace
2
+
3
+ A TypeScript implementation of the [Agent Trace specification](https://agent-trace.org) ([GitHub](https://github.com/cursor/agent-trace)) for capturing AI code contributions from local coding tools.
4
+
5
+ ## Overview
6
+
7
+ agent-trace hooks into AI coding tools and records trace data as they edit files. Each trace captures what was changed, which model was used, and links back to the conversation that produced the code. Traces are written as JSONL to `.agent-trace/traces.jsonl` in the project root.
8
+
9
+ Supported providers:
10
+
11
+ - Cursor
12
+ - Claude Code
13
+ - OpenCode
14
+
15
+ ## Setup
16
+
17
+ Requires [Bun](https://bun.sh).
18
+
19
+ Install hooks in the current git repository:
20
+
21
+ ```bash
22
+ bunx @kennykeni/agent-trace init
23
+ ```
24
+
25
+ Install for specific providers:
26
+
27
+ ```bash
28
+ bunx @kennykeni/agent-trace init --providers cursor
29
+ bunx @kennykeni/agent-trace init --providers claude,opencode
30
+ ```
31
+
32
+ Install for a specific project:
33
+
34
+ ```bash
35
+ bunx @kennykeni/agent-trace init --target-root ~/my-project
36
+ ```
37
+
38
+ Preview what would be written:
39
+
40
+ ```bash
41
+ bunx @kennykeni/agent-trace init --dry-run
42
+ ```
43
+
44
+ Check installation status:
45
+
46
+ ```bash
47
+ bunx @kennykeni/agent-trace status
48
+ ```
49
+
50
+ ## What `init` does
51
+
52
+ Configures the target repo's provider settings:
53
+
54
+ | Provider | Config written |
55
+ | ---------- | ----------------------------------------- |
56
+ | Cursor | `.cursor/hooks.json` |
57
+ | Claude Code| `.claude/settings.json` |
58
+ | OpenCode | `.opencode/plugins/agent-trace.ts` |
59
+
60
+ ## How it works
61
+
62
+ 1. Provider hooks fire on tool events (file edits, shell commands, session lifecycle).
63
+ 2. The hook receives event JSON on stdin and routes it through a provider adapter.
64
+ 3. The adapter normalizes provider-specific payloads into internal trace events.
65
+ 4. The trace pipeline converts events into spec-compliant trace records.
66
+ 5. Records are appended to `.agent-trace/traces.jsonl`.
67
+
68
+ Additional artifacts are written by extensions under `.agent-trace/`:
69
+
70
+ - `raw/<provider>/<session>.jsonl` -- raw hook events (`raw-events` extension)
71
+ - `messages/<provider>/<session>.jsonl` -- captured chat messages (`messages` extension)
72
+ - `diffs/<provider>/<session>.patch` -- diff artifacts when available (`diffs` extension)
73
+ - `line-hashes/<provider>/<session>.jsonl` -- per-line content hashes (`line-hashes` extension)
74
+
75
+ ## Extensions
76
+
77
+ Extensions are pluggable modules that run alongside the core trace pipeline. Four are built in: `raw-events`, `diffs`, `messages`, and `line-hashes`. All extensions are enabled by default.
78
+
79
+ To control which extensions run, create `.agent-trace/config.json` in your project root:
80
+
81
+ ```json
82
+ { "extensions": ["diffs", "messages"] }
83
+ ```
84
+
85
+ - **File absent** -- all registered extensions run (default)
86
+ - **`"extensions": ["diffs", "messages"]`** -- only listed extensions run
87
+ - **`"extensions": []`** -- no extensions run (only `traces.jsonl` is written)
88
+ - **Malformed JSON** -- warning logged, all extensions run
89
+
90
+ ## Trace format
91
+
92
+ Traces follow the [Agent Trace spec](https://agent-trace.org). Each JSONL line contains:
93
+
94
+ - `version` -- spec version
95
+ - `id` -- unique trace ID (UUID)
96
+ - `timestamp` -- ISO 8601
97
+ - `vcs` -- version control info (type, revision)
98
+ - `tool` -- tool name and version
99
+ - `files[]` -- files with conversation and range attribution
100
+ - `metadata` -- optional implementation-specific data
101
+
102
+ Schema source: [`schemas.ts`](./src/core/schemas.ts)
103
+
104
+ ## Known limitations
105
+
106
+ - **Path normalization edge cases**: Relative path conversion is string-prefix based today; some sibling-path cases can produce `..` segments.
107
+ - **Bun-only**: The hook runtime and CLI require Bun. Node.js is not supported.
108
+ - **No VCS requirement**: Works without git. When git is available, traces include the current commit SHA. Without git, VCS info is omitted.
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ bun install
114
+ bun test
115
+ bun run check
116
+ ```
117
+
118
+ ## Spec reference
119
+
120
+ This project implements the [Agent Trace specification](https://agent-trace.org) authored by [Cursor](https://github.com/cursor/agent-trace). The spec defines a vendor-neutral format for recording AI contributions alongside human authorship in version-controlled codebases.
121
+
122
+ ## License
123
+
124
+ See the [Agent Trace spec](https://github.com/cursor/agent-trace) for specification licensing (CC BY 4.0).
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kennykeni/agent-trace",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "agent-trace": "src/cli.ts"
7
+ },
8
+ "files": [
9
+ "src/",
10
+ "!src/**/__tests__/"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/KennyKeni/agent-trace.git"
15
+ },
16
+ "scripts": {
17
+ "build": "bun run index.ts",
18
+ "dev": "bun run index.ts --dev",
19
+ "check": "tsc --noEmit && biome check .",
20
+ "check:fix": "biome check --write .",
21
+ "format": "biome format --write .",
22
+ "lint": "biome lint .",
23
+ "lint:fix": "biome lint --write .",
24
+ "test": "bun test"
25
+ },
26
+ "dependencies": {
27
+ "zod": "^3.24.0"
28
+ },
29
+ "devDependencies": {
30
+ "@biomejs/biome": "^2.3.14",
31
+ "@types/bun": "latest",
32
+ "marked": "^15.0.0",
33
+ "marked-shiki": "^1.2.0",
34
+ "shiki": "^3.0.0",
35
+ "zod-to-json-schema": "^3.24.0"
36
+ }
37
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { runHook } from "./core/trace-hook";
6
+ import "./extensions";
7
+ import "./providers";
8
+ import { getWorkspaceRoot } from "./core/trace-store";
9
+ import {
10
+ InstallError,
11
+ install,
12
+ parseArgs,
13
+ printInstallSummary,
14
+ } from "./install";
15
+ import { getPackageVersion } from "./install/utils";
16
+
17
+ function printHelp(): void {
18
+ console.log(`agent-trace - AI code attribution tracker
19
+
20
+ Usage:
21
+ agent-trace <command> [options]
22
+
23
+ Commands:
24
+ init Initialize hooks for Cursor, Claude Code, and OpenCode in a project
25
+ hook Run the trace hook (reads JSON from stdin)
26
+ status Show installed hook status
27
+ help Show this help message
28
+
29
+ Init options:
30
+ --providers <list> Comma-separated providers (cursor,claude,opencode) [default: all]
31
+ --target-root <dir> Target project root [default: current directory]
32
+ --dry-run Preview changes without writing
33
+ --latest Use latest version instead of pinning to current
34
+
35
+ Examples:
36
+ agent-trace init
37
+ agent-trace init --providers cursor
38
+ agent-trace init --providers opencode
39
+ agent-trace init --target-root ~/my-project
40
+ agent-trace status`);
41
+ }
42
+
43
+ function checkHookConfig(
44
+ path: string,
45
+ searchString: string,
46
+ ): "installed" | "not installed" {
47
+ if (!existsSync(path)) return "not installed";
48
+ try {
49
+ const content = readFileSync(path, "utf-8");
50
+ return content.includes(searchString) ? "installed" : "not installed";
51
+ } catch {
52
+ return "not installed";
53
+ }
54
+ }
55
+
56
+ function status(): void {
57
+ const root = getWorkspaceRoot();
58
+
59
+ const cursorPath = join(root, ".cursor", "hooks.json");
60
+ const claudePath = join(root, ".claude", "settings.json");
61
+ const opencodePath = join(root, ".opencode", "plugins", "agent-trace.ts");
62
+
63
+ const cursorStatus = checkHookConfig(
64
+ cursorPath,
65
+ "agent-trace hook --provider cursor",
66
+ );
67
+ const claudeStatus = checkHookConfig(
68
+ claudePath,
69
+ "agent-trace hook --provider claude",
70
+ );
71
+ const opencodeStatus = checkHookConfig(opencodePath, "agent-trace");
72
+ const traceDir = join(root, ".agent-trace");
73
+ const hasTraces = existsSync(join(traceDir, "traces.jsonl"));
74
+
75
+ console.log(`Workspace: ${root}\n`);
76
+ console.log(`Cursor: ${cursorStatus}`);
77
+ console.log(`Claude: ${claudeStatus}`);
78
+ console.log(`OpenCode: ${opencodeStatus}`);
79
+ console.log(`Traces: ${hasTraces ? "present" : "none"}`);
80
+ }
81
+
82
+ const command = process.argv[2];
83
+
84
+ switch (command) {
85
+ case "init": {
86
+ try {
87
+ const options = parseArgs(process.argv.slice(3));
88
+ const changes = install(options);
89
+ printInstallSummary(changes, options.targetRoots);
90
+ } catch (e) {
91
+ if (e instanceof InstallError) {
92
+ console.error(e.message);
93
+ process.exit(1);
94
+ }
95
+ throw e;
96
+ }
97
+ break;
98
+ }
99
+ case "hook":
100
+ await runHook();
101
+ break;
102
+ case "status":
103
+ status();
104
+ break;
105
+ case "--version":
106
+ case "-v":
107
+ console.log(getPackageVersion());
108
+ break;
109
+ case "help":
110
+ case "--help":
111
+ case "-h":
112
+ case undefined:
113
+ printHelp();
114
+ break;
115
+ default:
116
+ console.error(`Unknown command: ${command}`);
117
+ printHelp();
118
+ process.exit(1);
119
+ }
@@ -0,0 +1,138 @@
1
+ import { z } from "zod";
2
+ import { zodToJsonSchema } from "zod-to-json-schema";
3
+
4
+ export const SPEC_VERSION = "0.1.0";
5
+
6
+ const SCHEMA_BASE_URL = "https://agent-trace.dev/schemas/v1";
7
+
8
+ export const ContributorTypeSchema = z.enum([
9
+ "human",
10
+ "ai",
11
+ "mixed",
12
+ "unknown",
13
+ ]);
14
+
15
+ export const ToolSchema = z.object({
16
+ name: z
17
+ .string()
18
+ .optional()
19
+ .describe("Name of the tool that produced the code"),
20
+ version: z.string().optional().describe("Version of the tool"),
21
+ });
22
+
23
+ export const ContributorSchema = z.object({
24
+ type: ContributorTypeSchema.describe("The type of contributor"),
25
+ model_id: z
26
+ .string()
27
+ .max(250)
28
+ .optional()
29
+ .describe(
30
+ "The model's unique identifier following models.dev convention (e.g., 'anthropic/claude-opus-4-5-20251101')",
31
+ ),
32
+ });
33
+
34
+ export const RelatedResourceSchema = z.object({
35
+ type: z.string().describe("Type of related resource"),
36
+ url: z.string().url().describe("URL to the related resource"),
37
+ });
38
+
39
+ export const RangeSchema = z.object({
40
+ start_line: z.number().int().min(1).describe("1-indexed start line number"),
41
+ end_line: z.number().int().min(1).describe("1-indexed end line number"),
42
+ content_hash: z
43
+ .string()
44
+ .optional()
45
+ .describe("Hash of attributed content for position-independent tracking"),
46
+ contributor: ContributorSchema.optional().describe(
47
+ "Override contributor for this specific range (e.g., for agent handoffs)",
48
+ ),
49
+ });
50
+
51
+ export const ConversationSchema = z.object({
52
+ url: z
53
+ .string()
54
+ .url()
55
+ .optional()
56
+ .describe("URL to look up the conversation that produced this code"),
57
+ contributor: ContributorSchema.optional().describe(
58
+ "The contributor for ranges in this conversation (can be overridden per-range)",
59
+ ),
60
+ ranges: z
61
+ .array(RangeSchema)
62
+ .describe("Array of line ranges produced by this conversation"),
63
+ related: z
64
+ .array(RelatedResourceSchema)
65
+ .optional()
66
+ .describe("Other related resources"),
67
+ });
68
+
69
+ export const FileSchema = z.object({
70
+ path: z.string().describe("Relative file path from repository root"),
71
+ conversations: z
72
+ .array(ConversationSchema)
73
+ .describe("Array of conversations that contributed to this file"),
74
+ });
75
+
76
+ export const VcsTypeSchema = z.enum(["git", "jj", "hg", "svn"]);
77
+
78
+ export const VcsSchema = z.object({
79
+ type: VcsTypeSchema.describe(
80
+ "Version control system type (e.g., 'git', 'jj', 'hg')",
81
+ ),
82
+ revision: z
83
+ .string()
84
+ .describe(
85
+ "Revision identifier (e.g., git commit SHA, jj change ID, hg changeset)",
86
+ ),
87
+ });
88
+
89
+ export const TraceRecordSchema = z.object({
90
+ version: z
91
+ .string()
92
+ .regex(/^[0-9]+\.[0-9]+\.[0-9]+$/)
93
+ .describe("Agent Trace specification version (e.g., '0.1.0')"),
94
+ id: z.string().uuid().describe("Unique identifier for this trace record"),
95
+ timestamp: z
96
+ .string()
97
+ .datetime({ offset: true })
98
+ .describe("RFC 3339 timestamp when trace was recorded"),
99
+ vcs: VcsSchema.optional().describe(
100
+ "Version control system information for this trace",
101
+ ),
102
+ tool: ToolSchema.optional().describe("The tool that generated this trace"),
103
+ files: z.array(FileSchema).describe("Array of files with attributed ranges"),
104
+ metadata: z
105
+ .record(z.string(), z.unknown())
106
+ .optional()
107
+ .describe(
108
+ "Additional metadata for implementation-specific or vendor-specific data",
109
+ ),
110
+ });
111
+
112
+ export function generateJsonSchemas(): Record<string, object> {
113
+ const schemas: Record<string, object> = {};
114
+
115
+ // Trace Record Schema
116
+ schemas["trace-record.json"] = {
117
+ ...zodToJsonSchema(TraceRecordSchema, {
118
+ name: "TraceRecord",
119
+ $refStrategy: "none",
120
+ }),
121
+ $schema: "https://json-schema.org/draft/2020-12/schema",
122
+ $id: `${SCHEMA_BASE_URL}/trace-record.json`,
123
+ title: "Agent Trace Record",
124
+ };
125
+
126
+ return schemas;
127
+ }
128
+
129
+ export type ContributorType = z.infer<typeof ContributorTypeSchema>;
130
+ export type VcsType = z.infer<typeof VcsTypeSchema>;
131
+ export type Vcs = z.infer<typeof VcsSchema>;
132
+ export type Tool = z.infer<typeof ToolSchema>;
133
+ export type Contributor = z.infer<typeof ContributorSchema>;
134
+ export type Range = z.infer<typeof RangeSchema>;
135
+ export type Conversation = z.infer<typeof ConversationSchema>;
136
+ export type File = z.infer<typeof FileSchema>;
137
+ export type RelatedResource = z.infer<typeof RelatedResourceSchema>;
138
+ export type TraceRecord = z.infer<typeof TraceRecordSchema>;
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import {
6
+ appendTrace,
7
+ computeRangePositions,
8
+ createTrace,
9
+ getWorkspaceRoot,
10
+ tryReadFile,
11
+ } from "./trace-store";
12
+ import type {
13
+ Extension,
14
+ HookInput,
15
+ ProviderAdapter,
16
+ TraceEvent,
17
+ } from "./types";
18
+
19
+ export type { HookInput } from "./types";
20
+
21
+ const providerRegistry = new Map<string, ProviderAdapter>();
22
+
23
+ export function registerProvider(name: string, adapter: ProviderAdapter): void {
24
+ providerRegistry.set(name, adapter);
25
+ }
26
+
27
+ const extensionRegistry = new Map<string, Extension>();
28
+
29
+ export function registerExtension(ext: Extension): void {
30
+ extensionRegistry.set(ext.name, ext);
31
+ }
32
+
33
+ function parseProvider(argv: string[]): string | undefined {
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const arg = argv[i];
36
+ if (arg === "--provider") {
37
+ return argv[i + 1] ?? undefined;
38
+ }
39
+ if (arg?.startsWith("--provider=")) {
40
+ return arg.slice("--provider=".length) || undefined;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function loadExtensionConfig(root: string): string[] | null {
47
+ const configPath = join(root, ".agent-trace", "config.json");
48
+ let raw: string;
49
+ try {
50
+ raw = readFileSync(configPath, "utf-8");
51
+ } catch {
52
+ return null;
53
+ }
54
+ try {
55
+ const parsed = JSON.parse(raw);
56
+ if (
57
+ parsed &&
58
+ typeof parsed === "object" &&
59
+ Array.isArray(parsed.extensions)
60
+ ) {
61
+ return parsed.extensions.filter(
62
+ (e: unknown): e is string => typeof e === "string",
63
+ );
64
+ }
65
+ console.error(
66
+ "agent-trace: config.json missing 'extensions' array, running all extensions",
67
+ );
68
+ return null;
69
+ } catch {
70
+ console.error("agent-trace: malformed config.json, running all extensions");
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function activeExtensions(root: string): Extension[] {
76
+ const allowlist = loadExtensionConfig(root);
77
+ if (allowlist === null) return [...extensionRegistry.values()];
78
+
79
+ const active: Extension[] = [];
80
+ for (const name of allowlist) {
81
+ const ext = extensionRegistry.get(name);
82
+ if (ext) {
83
+ active.push(ext);
84
+ } else {
85
+ console.error(`agent-trace: unknown extension "${name}", skipping`);
86
+ }
87
+ }
88
+ return active;
89
+ }
90
+
91
+ function writeTrace(event: TraceEvent): void {
92
+ const appendIfTrace = (trace: ReturnType<typeof createTrace>): void => {
93
+ if (trace) appendTrace(trace);
94
+ };
95
+
96
+ switch (event.kind) {
97
+ case "file_edit": {
98
+ const edits = event.edits;
99
+ const fileContent = event.readContent
100
+ ? tryReadFile(event.filePath)
101
+ : undefined;
102
+ const rangePositions = edits.length
103
+ ? computeRangePositions(edits, fileContent)
104
+ : undefined;
105
+
106
+ appendIfTrace(
107
+ createTrace("ai", event.filePath, {
108
+ model: event.model,
109
+ rangePositions,
110
+ transcript: event.transcript,
111
+ tool: event.tool,
112
+ metadata: event.meta,
113
+ }),
114
+ );
115
+ break;
116
+ }
117
+ case "shell": {
118
+ appendIfTrace(
119
+ createTrace("ai", ".shell-history", {
120
+ model: event.model,
121
+ transcript: event.transcript,
122
+ tool: event.tool,
123
+ metadata: event.meta,
124
+ }),
125
+ );
126
+ break;
127
+ }
128
+ case "session_start": {
129
+ appendIfTrace(
130
+ createTrace("ai", ".sessions", {
131
+ model: event.model,
132
+ tool: event.tool,
133
+ metadata: { event: "session_start", ...event.meta },
134
+ }),
135
+ );
136
+ break;
137
+ }
138
+ case "session_end": {
139
+ appendIfTrace(
140
+ createTrace("ai", ".sessions", {
141
+ model: event.model,
142
+ tool: event.tool,
143
+ metadata: { event: "session_end", ...event.meta },
144
+ }),
145
+ );
146
+ break;
147
+ }
148
+ case "message":
149
+ break;
150
+ }
151
+ }
152
+
153
+ export async function runHook() {
154
+ if (providerRegistry.size === 0) {
155
+ console.error(
156
+ 'No providers registered. Import provider registrations before calling runHook() (e.g. import "./providers").',
157
+ );
158
+ process.exit(1);
159
+ }
160
+
161
+ const chunks: Buffer[] = [];
162
+ for await (const chunk of Bun.stdin.stream()) {
163
+ chunks.push(Buffer.from(chunk));
164
+ }
165
+
166
+ const json = Buffer.concat(chunks).toString("utf-8").trim();
167
+ if (!json) process.exit(0);
168
+
169
+ try {
170
+ const providerName = parseProvider(process.argv.slice(2));
171
+ if (!providerName) {
172
+ const registered = [...providerRegistry.keys()].join(", ");
173
+ console.error(
174
+ `Missing --provider flag. Registered providers: ${registered}`,
175
+ );
176
+ process.exit(1);
177
+ }
178
+
179
+ const adapter = providerRegistry.get(providerName);
180
+ if (!adapter) {
181
+ const registered = [...providerRegistry.keys()].join(", ");
182
+ console.error(
183
+ `Unknown provider "${providerName}". Registered providers: ${registered}`,
184
+ );
185
+ process.exit(1);
186
+ }
187
+
188
+ const input = JSON.parse(json) as HookInput;
189
+
190
+ process.env.AGENT_TRACE_PROVIDER = providerName;
191
+
192
+ const sessionId = adapter.sessionIdFor(input);
193
+ const extensions = activeExtensions(getWorkspaceRoot());
194
+
195
+ for (const ext of extensions) {
196
+ if (ext.onRawInput) {
197
+ try {
198
+ ext.onRawInput(providerName, sessionId, input);
199
+ } catch (e) {
200
+ console.error(`Extension error (${ext.name}/onRawInput):`, e);
201
+ }
202
+ }
203
+ }
204
+
205
+ const toolInfo = adapter.toolInfo?.();
206
+
207
+ const result = adapter.adapt(input);
208
+ if (result) {
209
+ const events = Array.isArray(result) ? result : [result];
210
+ for (const raw of events) {
211
+ const event = toolInfo ? { ...raw, tool: toolInfo } : raw;
212
+ writeTrace(event);
213
+ for (const ext of extensions) {
214
+ if (ext.onTraceEvent) {
215
+ try {
216
+ ext.onTraceEvent(event);
217
+ } catch (e) {
218
+ console.error(`Extension error (${ext.name}/onTraceEvent):`, e);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ } catch (e) {
225
+ console.error("Hook error:", e);
226
+ process.exit(1);
227
+ }
228
+ }
229
+
230
+ if (import.meta.main) void runHook();