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.
@@ -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 the absolute repo path with '/' replaced by '-'.
8
- * Each line is a JSON object with `type: "human" | "assistant" | ...`.
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 { homedir } from "os";
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?: Array<{
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 with every '/' replaced by '-'. For example:
91
- * /Users/alice/Code/my-repo -Users-alice-Code-my-repo
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.type === "human") humanCount++;
121
- if (entry.type && entry.timestamp) {
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: entry.type, ts: ms });
160
+ if (!isNaN(ms)) timedMessages.push({ type: messageType, ts: ms });
124
161
  }
125
162
  if (entry.type === "assistant") {
126
- for (const block of entry.message?.content ?? []) {
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 key = projectKey(root);
241
- const transcriptDir = join(homedir(), ".claude", "projects", key);
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<void> {
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 !== records.length) {
110
- await writeInstalledRepoRegistry(filtered, homeDir);
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 {
@@ -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
- console.log("✓ Updated installed repo registry");
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}`,