claude-attribution 1.8.0 → 1.9.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 +67 -37
- package/package.json +2 -2
- package/src/__tests__/claude-projects.test.ts +42 -0
- package/src/__tests__/copilot-session.test.ts +141 -0
- package/src/__tests__/differ.test.ts +14 -2
- package/src/__tests__/git-notes.test.ts +28 -0
- package/src/__tests__/installed-repos.test.ts +2 -1
- package/src/__tests__/integration-helpers.ts +12 -1
- package/src/__tests__/integration.test.ts +140 -1
- package/src/__tests__/runtime.test.ts +12 -1
- package/src/__tests__/transcript.test.ts +61 -0
- package/src/attribution/commit.ts +68 -39
- package/src/attribution/differ.ts +76 -20
- package/src/attribution/git-notes.ts +26 -0
- package/src/attribution/runtime.ts +8 -7
- package/src/metrics/claude-projects.ts +48 -0
- package/src/metrics/collect.ts +113 -57
- package/src/metrics/copilot-session.ts +383 -0
- package/src/metrics/local-session.ts +12 -0
- package/src/metrics/session-metrics.ts +22 -0
- package/src/metrics/transcript.ts +56 -17
- package/src/setup/installed-repos.ts +4 -4
- package/src/setup/uninstall.ts +7 -2
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Claude Code writes JSONL transcript files to:
|
|
5
5
|
* ~/.claude/projects/<project-key>/<session-id>.jsonl
|
|
6
6
|
*
|
|
7
|
-
* where <project-key> is
|
|
8
|
-
* Each line is a JSON object with `type: "
|
|
7
|
+
* where <project-key> is a sanitized absolute repo path.
|
|
8
|
+
* Each line is a JSON object with `type: "user" | "assistant" | ...`.
|
|
9
9
|
* Assistant messages include `message.model` and `message.usage` (token counts).
|
|
10
10
|
*
|
|
11
11
|
* Subagent transcripts are stored in:
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { readFile, readdir } from "fs/promises";
|
|
18
18
|
import { existsSync } from "fs";
|
|
19
19
|
import { join, resolve } from "path";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveClaudeProjectDir } from "./claude-projects.ts";
|
|
21
21
|
|
|
22
22
|
export interface ModelUsage {
|
|
23
23
|
modelShort: "Opus" | "Sonnet" | "Haiku" | "Unknown";
|
|
@@ -50,12 +50,22 @@ export interface TranscriptResult {
|
|
|
50
50
|
toolCounts: Record<string, number>;
|
|
51
51
|
/** Skill names invoked (from Skill tool_use blocks with input.skill). */
|
|
52
52
|
skillNames: string[];
|
|
53
|
+
/** Subagent counts keyed by agent type/name when available. */
|
|
54
|
+
agentCounts?: Record<string, number>;
|
|
55
|
+
/** Source provider for the session metrics. */
|
|
56
|
+
provider?: "claude" | "copilot";
|
|
57
|
+
/** Whether cost can be estimated from the available local data. */
|
|
58
|
+
costMode?: "estimated" | "unavailable";
|
|
59
|
+
/** Explanation shown when cost cannot be estimated. */
|
|
60
|
+
costDescription?: string;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
interface TranscriptEntry {
|
|
56
64
|
type?: string;
|
|
57
65
|
timestamp?: string;
|
|
66
|
+
toolUseResult?: unknown;
|
|
58
67
|
message?: {
|
|
68
|
+
role?: string;
|
|
59
69
|
model?: string;
|
|
60
70
|
usage?: {
|
|
61
71
|
input_tokens?: number;
|
|
@@ -63,7 +73,9 @@ interface TranscriptEntry {
|
|
|
63
73
|
cache_creation_input_tokens?: number;
|
|
64
74
|
cache_read_input_tokens?: number;
|
|
65
75
|
};
|
|
66
|
-
content?:
|
|
76
|
+
content?:
|
|
77
|
+
| string
|
|
78
|
+
| Array<{
|
|
67
79
|
type?: string;
|
|
68
80
|
name?: string;
|
|
69
81
|
input?: Record<string, unknown>;
|
|
@@ -83,20 +95,44 @@ function modelShort(full: string): ModelUsage["modelShort"] {
|
|
|
83
95
|
return "Unknown";
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
function contentBlocks(entry: TranscriptEntry): Array<{
|
|
99
|
+
type?: string;
|
|
100
|
+
name?: string;
|
|
101
|
+
input?: Record<string, unknown>;
|
|
102
|
+
}> {
|
|
103
|
+
return Array.isArray(entry.message?.content) ? entry.message.content : [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isToolResultEntry(entry: TranscriptEntry): boolean {
|
|
107
|
+
return (
|
|
108
|
+
entry.toolUseResult !== undefined ||
|
|
109
|
+
contentBlocks(entry).some((block) => block.type === "tool_result")
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isHumanPromptEntry(entry: TranscriptEntry): boolean {
|
|
114
|
+
return (
|
|
115
|
+
(entry.type === "human" || entry.type === "user") && !isToolResultEntry(entry)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function timedMessageType(entry: TranscriptEntry): TimedMessage["type"] | null {
|
|
120
|
+
if (entry.type === "assistant") return "assistant";
|
|
121
|
+
if (isHumanPromptEntry(entry)) return "human";
|
|
122
|
+
if (isToolResultEntry(entry)) return "tool_result";
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
/**
|
|
87
127
|
* Derive the Claude Code projects directory key from an absolute repo path.
|
|
88
128
|
*
|
|
89
129
|
* Claude Code stores transcripts at `~/.claude/projects/<key>/` where <key>
|
|
90
|
-
* is the absolute path
|
|
91
|
-
*
|
|
130
|
+
* is derived from the absolute repo path. Current Claude builds sanitize path
|
|
131
|
+
* punctuation like dots as well as path separators. For example:
|
|
132
|
+
* /Users/alice.smith/Code/my-repo → -Users-alice-smith-Code-my-repo
|
|
92
133
|
*
|
|
93
134
|
* This key is used to locate the transcript JSONL file for a given session.
|
|
94
135
|
*/
|
|
95
|
-
function projectKey(repoRoot: string): string {
|
|
96
|
-
// Claude Code replaces '/' with '-' in the path
|
|
97
|
-
return repoRoot.replace(/\//g, "-");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
136
|
async function parseTranscriptFile(filePath: string): Promise<{
|
|
101
137
|
entries: TranscriptEntry[];
|
|
102
138
|
humanCount: number;
|
|
@@ -117,13 +153,14 @@ async function parseTranscriptFile(filePath: string): Promise<{
|
|
|
117
153
|
try {
|
|
118
154
|
const entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
119
155
|
entries.push(entry);
|
|
120
|
-
if (entry
|
|
121
|
-
|
|
156
|
+
if (isHumanPromptEntry(entry)) humanCount++;
|
|
157
|
+
const messageType = timedMessageType(entry);
|
|
158
|
+
if (messageType && entry.timestamp) {
|
|
122
159
|
const ms = new Date(entry.timestamp).getTime();
|
|
123
|
-
if (!isNaN(ms)) timedMessages.push({ type:
|
|
160
|
+
if (!isNaN(ms)) timedMessages.push({ type: messageType, ts: ms });
|
|
124
161
|
}
|
|
125
162
|
if (entry.type === "assistant") {
|
|
126
|
-
for (const block of entry
|
|
163
|
+
for (const block of contentBlocks(entry)) {
|
|
127
164
|
if (block.type !== "tool_use" || !block.name) continue;
|
|
128
165
|
toolCounts.set(block.name, (toolCounts.get(block.name) ?? 0) + 1);
|
|
129
166
|
if (
|
|
@@ -237,8 +274,8 @@ export async function parseTranscript(
|
|
|
237
274
|
repoRoot?: string,
|
|
238
275
|
): Promise<TranscriptResult | null> {
|
|
239
276
|
const root = repoRoot ?? resolve(process.cwd());
|
|
240
|
-
const
|
|
241
|
-
|
|
277
|
+
const transcriptDir = await resolveClaudeProjectDir(root);
|
|
278
|
+
if (!transcriptDir) return null;
|
|
242
279
|
const mainFile = join(transcriptDir, `${sessionId}.jsonl`);
|
|
243
280
|
|
|
244
281
|
if (!existsSync(mainFile)) return null;
|
|
@@ -317,5 +354,7 @@ export async function parseTranscript(
|
|
|
317
354
|
humanMinutes,
|
|
318
355
|
toolCounts: Object.fromEntries(toolCounts),
|
|
319
356
|
skillNames,
|
|
357
|
+
provider: "claude",
|
|
358
|
+
costMode: "estimated",
|
|
320
359
|
};
|
|
321
360
|
}
|
|
@@ -102,13 +102,13 @@ export async function registerInstalledRepo(
|
|
|
102
102
|
export async function unregisterInstalledRepo(
|
|
103
103
|
repoRoot: string,
|
|
104
104
|
homeDir = homedir(),
|
|
105
|
-
): Promise<
|
|
105
|
+
): Promise<boolean> {
|
|
106
106
|
const normalizedPath = await normalizeRepoPath(repoRoot);
|
|
107
107
|
const records = await readInstalledRepoRegistry(homeDir);
|
|
108
108
|
const filtered = records.filter((record) => record.path !== normalizedPath);
|
|
109
|
-
if (filtered.length
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
if (filtered.length === records.length) return false;
|
|
110
|
+
await writeInstalledRepoRegistry(filtered, homeDir);
|
|
111
|
+
return true;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export function isTrackedRepoPathValid(repoRoot: string): boolean {
|
package/src/setup/uninstall.ts
CHANGED
|
@@ -213,8 +213,13 @@ async function main() {
|
|
|
213
213
|
console.log("✓ Removed .claude/attribution-state/installed-version");
|
|
214
214
|
}
|
|
215
215
|
try {
|
|
216
|
-
await unregisterInstalledRepo(targetRepo)
|
|
217
|
-
|
|
216
|
+
if (await unregisterInstalledRepo(targetRepo)) {
|
|
217
|
+
console.log("✓ Removed repo from installed repo registry");
|
|
218
|
+
} else {
|
|
219
|
+
console.log(
|
|
220
|
+
"ℹ Repo was not tracked in installed repo registry; nothing to update",
|
|
221
|
+
);
|
|
222
|
+
}
|
|
218
223
|
} catch (error) {
|
|
219
224
|
console.warn(
|
|
220
225
|
`⚠ Could not update installed repo registry: ${(error as Error).message}`,
|