claude-attribution 1.2.5 → 1.2.8

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.
@@ -29,8 +29,11 @@ const execFileAsync = promisify(execFile);
29
29
  export interface MetricsData {
30
30
  repoRoot: string;
31
31
  sessionId: string;
32
+ /** Notable non-file-op tools used (Bash, Read, Write, Edit etc. excluded). */
32
33
  toolCounts: Map<string, number>;
33
34
  agentCounts: Map<string, number>;
35
+ /** Slash command skills invoked (e.g. ["pr", "metrics"]). */
36
+ skillNames: string[];
34
37
  transcript: TranscriptResult | null;
35
38
  attributions: AttributionResult[];
36
39
  lastSeenByFile: Map<string, FileAttribution>;
@@ -43,6 +46,44 @@ export interface MetricsData {
43
46
  } | null;
44
47
  }
45
48
 
49
+ /**
50
+ * Tools that are routine file/task operations — excluded from the metrics
51
+ * display because they add noise without conveying meaningful intent.
52
+ * Skill invocations are tracked separately by skill name.
53
+ */
54
+ const BORING_TOOLS = new Set([
55
+ // File operations
56
+ "Read",
57
+ "Write",
58
+ "Edit",
59
+ "Glob",
60
+ "Grep",
61
+ "NotebookEdit",
62
+ "MultiEdit",
63
+ // Task/todo management
64
+ "TaskCreate",
65
+ "TaskUpdate",
66
+ "TaskGet",
67
+ "TaskList",
68
+ "TaskOutput",
69
+ "TaskStop",
70
+ // Planning & worktree lifecycle
71
+ "EnterPlanMode",
72
+ "ExitPlanMode",
73
+ "EnterWorktree",
74
+ "ExitWorktree",
75
+ // Cron
76
+ "CronCreate",
77
+ "CronDelete",
78
+ "CronList",
79
+ // Shell (too generic — any meaningful external calls are implicit in the PR)
80
+ "Bash",
81
+ // Skill is tracked by name in skillNames, not toolCounts
82
+ "Skill",
83
+ // Agent is tracked in agentCounts via agent-activity.jsonl
84
+ "Agent",
85
+ ]);
86
+
46
87
  async function readSessionStart(repoRoot: string): Promise<Date | null> {
47
88
  const markerPath = join(
48
89
  repoRoot,
@@ -201,7 +242,7 @@ export async function collectMetrics(
201
242
  join(logDir, "tool-usage.jsonl"),
202
243
  sessionId,
203
244
  sessionStart ?? undefined,
204
- ) as Promise<{ tool?: string }[]>,
245
+ ) as Promise<{ tool?: string; skill?: string }[]>,
205
246
  readJsonlForSession(
206
247
  join(logDir, "agent-activity.jsonl"),
207
248
  sessionId,
@@ -212,10 +253,18 @@ export async function collectMetrics(
212
253
  getMinimapTotals(root),
213
254
  ]);
214
255
 
215
- // Tool counts
256
+ // Tool counts — skip boring file ops and infrastructure tools.
257
+ // Skill invocations are tracked separately by name.
216
258
  const toolCounts = new Map<string, number>();
259
+ const skillNames: string[] = [];
217
260
  for (const e of toolEntries) {
218
- if (e.tool) toolCounts.set(e.tool, (toolCounts.get(e.tool) ?? 0) + 1);
261
+ if (!e.tool) continue;
262
+ if (e.tool === "Skill") {
263
+ if (e.skill) skillNames.push(e.skill);
264
+ continue;
265
+ }
266
+ if (BORING_TOOLS.has(e.tool)) continue;
267
+ toolCounts.set(e.tool, (toolCounts.get(e.tool) ?? 0) + 1);
219
268
  }
220
269
 
221
270
  // Agent counts (SubagentStart events only)
@@ -261,6 +310,7 @@ export async function collectMetrics(
261
310
  sessionId,
262
311
  toolCounts,
263
312
  agentCounts,
313
+ skillNames,
264
314
  transcript,
265
315
  attributions,
266
316
  lastSeenByFile,
@@ -269,11 +319,36 @@ export async function collectMetrics(
269
319
  };
270
320
  }
271
321
 
322
+ /** Format a session time summary. Returns empty string when no time data. */
323
+ function formatSessionLine(transcript: TranscriptResult): string {
324
+ const parts: string[] = [];
325
+
326
+ parts.push(
327
+ `${transcript.humanPromptCount} prompt${transcript.humanPromptCount === 1 ? "" : "s"}`,
328
+ );
329
+
330
+ const {
331
+ activeMinutes: total,
332
+ aiMinutes: ai,
333
+ humanMinutes: human,
334
+ } = transcript;
335
+ if (total > 0) {
336
+ if (human > 0) {
337
+ parts.push(`${total}m total (${ai}m AI · ${human}m human)`);
338
+ } else {
339
+ parts.push(`${total}m`);
340
+ }
341
+ }
342
+
343
+ return parts.join(" · ");
344
+ }
345
+
272
346
  export function renderMetrics(data: MetricsData): string {
273
347
  const {
274
348
  repoRoot,
275
349
  toolCounts,
276
350
  agentCounts,
351
+ skillNames,
277
352
  transcript,
278
353
  lastSeenByFile,
279
354
  allTranscripts,
@@ -286,7 +361,7 @@ export function renderMetrics(data: MetricsData): string {
286
361
  out("## Claude Code Metrics");
287
362
  out();
288
363
 
289
- // Headline: AI% + active time (most important stat, shown first)
364
+ // Headline: AI% (most important stat, shown first)
290
365
  const allFileStats = [...lastSeenByFile.values()];
291
366
  const hasAttribution = allFileStats.length > 0;
292
367
  if (minimapTotals && minimapTotals.total > 0) {
@@ -304,30 +379,26 @@ export function renderMetrics(data: MetricsData): string {
304
379
  minimapTotals.total > 0
305
380
  ? Math.round((prTotal / minimapTotals.total) * 100)
306
381
  : 0;
307
- const activePart =
308
- transcript && transcript.activeMinutes > 0
309
- ? ` · Active: ${transcript.activeMinutes}m`
310
- : "";
311
382
  out(
312
- `**This PR:** ${prTotal} lines changed (${codebasePct}% of codebase) · ${prPctAi}% Claude edits · ${prAi} AI lines${activePart}`,
383
+ `**This PR:** ${prTotal} lines changed (${codebasePct}% of codebase) · ${prPctAi}% Claude edits · ${prAi} AI lines`,
313
384
  );
314
385
  }
315
386
  out();
316
387
  } else if (hasAttribution) {
317
388
  const { ai, total, pctAi } = aggregateTotals(allFileStats);
318
- const activePart =
319
- transcript && transcript.activeMinutes > 0
320
- ? ` · Active: ${transcript.activeMinutes}m`
321
- : "";
322
- out(
323
- `**AI contribution: ~${pctAi}%** (${ai} of ${total} committed lines)${activePart}`,
324
- );
325
- out();
326
- } else if (transcript && transcript.activeMinutes > 0) {
327
- out(`**Active session time:** ${transcript.activeMinutes}m`);
389
+ out(`**AI contribution: ~${pctAi}%** (${ai} of ${total} committed lines)`);
328
390
  out();
329
391
  }
330
392
 
393
+ // Session: prompts + time breakdown
394
+ if (transcript) {
395
+ const sessionLine = formatSessionLine(transcript);
396
+ if (sessionLine) {
397
+ out(`**Session:** ${sessionLine}`);
398
+ out();
399
+ }
400
+ }
401
+
331
402
  // Model usage table
332
403
  if (transcript) {
333
404
  out("| Model | Calls | Input | Output | Cache |");
@@ -344,8 +415,6 @@ export function renderMetrics(data: MetricsData): string {
344
415
  `| **Total** | ${t.totalCalls} | ${kFormat(t.totalInputTokens)} | ${kFormat(t.totalOutputTokens)} | ${kFormat(totalCache)} |`,
345
416
  );
346
417
  out();
347
- out(`**Human prompts (steering effort):** ${transcript.humanPromptCount}`);
348
- out();
349
418
  }
350
419
 
351
420
  // Multi-session rollup (shown when multiple Claude sessions contributed)
@@ -362,6 +431,8 @@ export function renderMetrics(data: MetricsData): string {
362
431
  t.totals.totalCacheCreationTokens +
363
432
  t.totals.totalCacheReadTokens,
364
433
  humanPromptCount: acc.humanPromptCount + t.humanPromptCount,
434
+ aiMinutes: acc.aiMinutes + t.aiMinutes,
435
+ humanMinutes: acc.humanMinutes + t.humanMinutes,
365
436
  activeMinutes: acc.activeMinutes + t.activeMinutes,
366
437
  }),
367
438
  {
@@ -370,6 +441,8 @@ export function renderMetrics(data: MetricsData): string {
370
441
  totalOutputTokens: 0,
371
442
  totalCacheTokens: 0,
372
443
  humanPromptCount: 0,
444
+ aiMinutes: 0,
445
+ humanMinutes: 0,
373
446
  activeMinutes: 0,
374
447
  },
375
448
  );
@@ -379,24 +452,35 @@ export function renderMetrics(data: MetricsData): string {
379
452
  `| ${agg.totalCalls} | ${kFormat(agg.totalInputTokens)} | ${kFormat(agg.totalOutputTokens)} | ${kFormat(agg.totalCacheTokens)} |`,
380
453
  );
381
454
  out();
382
- out(`**Total human prompts:** ${agg.humanPromptCount}`);
383
- if (agg.activeMinutes > 0) {
384
- out(`**Total active session time:** ${agg.activeMinutes}m`);
455
+ const aggSessionLine = [
456
+ `${agg.humanPromptCount} prompt${agg.humanPromptCount === 1 ? "" : "s"}`,
457
+ ...(agg.activeMinutes > 0
458
+ ? agg.humanMinutes > 0
459
+ ? [
460
+ `${agg.activeMinutes}m total (${agg.aiMinutes}m AI · ${agg.humanMinutes}m human)`,
461
+ ]
462
+ : [`${agg.activeMinutes}m`]
463
+ : []),
464
+ ].join(" · ");
465
+ if (aggSessionLine) {
466
+ out(`**Total session:** ${aggSessionLine}`);
385
467
  }
386
468
  out();
387
469
  }
388
470
 
389
- // <details> block — tools, agents, per-file breakdown
471
+ // <details> block — skills, agents, notable tools, per-file breakdown
390
472
  const claudeFiles = [...lastSeenByFile.entries()].filter(
391
473
  ([, stats]) => stats.ai > 0 || stats.mixed > 0,
392
474
  );
393
- const hasTools = toolCounts.size > 0;
475
+ const hasSkills = skillNames.length > 0;
394
476
  const hasAgents = agentCounts.size > 0;
477
+ const hasNotableTools = toolCounts.size > 0;
395
478
  const hasFiles = claudeFiles.length > 0;
396
479
 
397
480
  const summaryParts: string[] = [];
398
- if (hasTools) summaryParts.push("Tools");
481
+ if (hasSkills) summaryParts.push("Skills");
399
482
  if (hasAgents) summaryParts.push("Agents");
483
+ if (hasNotableTools) summaryParts.push("Tools");
400
484
  if (hasFiles) summaryParts.push("Files");
401
485
  if (summaryParts.length === 0) summaryParts.push("Details");
402
486
 
@@ -404,27 +488,36 @@ export function renderMetrics(data: MetricsData): string {
404
488
  out(`<summary>${summaryParts.join(" · ")}</summary>`);
405
489
  out();
406
490
 
407
- if (hasTools) {
408
- const toolLine = [...toolCounts.entries()]
409
- .sort((a, b) => b[1] - a[1])
410
- .map(([tool, count]) => `${tool} ×${count}`)
411
- .join(", ");
412
- out(`**Tools:** ${toolLine}`);
413
- out();
414
- } else {
415
- out("_No tool usage logs found_");
491
+ if (hasSkills) {
492
+ // Deduplicate and show slash command names
493
+ const unique = [...new Set(skillNames)];
494
+ out(`**Skills:** ${unique.map((s) => `/${s}`).join(", ")}`);
416
495
  out();
417
496
  }
418
497
 
419
498
  if (hasAgents) {
420
499
  const agentLine = [...agentCounts.entries()]
421
500
  .sort((a, b) => b[1] - a[1])
422
- .map(([agent, count]) => `${agent} ×${count}`)
501
+ .map(([agent, count]) => (count > 1 ? `${agent} ×${count}` : agent))
423
502
  .join(", ");
424
503
  out(`**Agents:** ${agentLine}`);
425
504
  out();
426
505
  }
427
506
 
507
+ if (hasNotableTools) {
508
+ const toolLine = [...toolCounts.entries()]
509
+ .sort((a, b) => b[1] - a[1])
510
+ .map(([tool, count]) => (count > 1 ? `${tool} ×${count}` : tool))
511
+ .join(", ");
512
+ out(`**External tools:** ${toolLine}`);
513
+ out();
514
+ }
515
+
516
+ if (!hasSkills && !hasAgents && !hasNotableTools) {
517
+ out("_No tool usage logs found_");
518
+ out();
519
+ }
520
+
428
521
  if (hasFiles) {
429
522
  out("#### Files");
430
523
  out();
@@ -12,7 +12,7 @@
12
12
  * ~/.claude/projects/<project-key>/<session-id>/subagents/<agent-id>.jsonl
13
13
  *
14
14
  * This module reads both main and subagent transcripts, merges them by model,
15
- * and returns aggregated token/model usage + human prompt count.
15
+ * and returns aggregated token/model usage + human prompt count + time breakdown.
16
16
  */
17
17
  import { readFile, readdir } from "fs/promises";
18
18
  import { existsSync } from "fs";
@@ -40,8 +40,12 @@ export interface TranscriptResult {
40
40
  totalCacheReadTokens: number;
41
41
  };
42
42
  humanPromptCount: number;
43
- /** Active session time in minutes (idle gaps >15 min are excluded). */
43
+ /** Total active session time in minutes (idle gaps >15 min excluded). */
44
44
  activeMinutes: number;
45
+ /** Minutes Claude was actively processing (human→assistant gaps). */
46
+ aiMinutes: number;
47
+ /** Minutes the human was active between Claude responses (>30s gaps, <15m). */
48
+ humanMinutes: number;
45
49
  }
46
50
 
47
51
  interface TranscriptEntry {
@@ -58,6 +62,11 @@ interface TranscriptEntry {
58
62
  };
59
63
  }
60
64
 
65
+ interface TimedMessage {
66
+ type: string;
67
+ ts: number;
68
+ }
69
+
61
70
  function modelShort(full: string): ModelUsage["modelShort"] {
62
71
  if (/opus/i.test(full)) return "Opus";
63
72
  if (/sonnet/i.test(full)) return "Sonnet";
@@ -82,12 +91,12 @@ function projectKey(repoRoot: string): string {
82
91
  async function parseTranscriptFile(filePath: string): Promise<{
83
92
  entries: TranscriptEntry[];
84
93
  humanCount: number;
85
- timestamps: number[];
94
+ timedMessages: TimedMessage[];
86
95
  }> {
87
96
  const raw = await readFile(filePath, "utf8");
88
97
  const entries: TranscriptEntry[] = [];
89
98
  let humanCount = 0;
90
- const timestamps: number[] = [];
99
+ const timedMessages: TimedMessage[] = [];
91
100
 
92
101
  for (const line of raw.split("\n")) {
93
102
  const trimmed = line.trim();
@@ -96,34 +105,62 @@ async function parseTranscriptFile(filePath: string): Promise<{
96
105
  const entry = JSON.parse(trimmed) as TranscriptEntry;
97
106
  entries.push(entry);
98
107
  if (entry.type === "human") humanCount++;
99
- if (entry.timestamp) {
108
+ if (entry.type && entry.timestamp) {
100
109
  const ms = new Date(entry.timestamp).getTime();
101
- if (!isNaN(ms)) timestamps.push(ms);
110
+ if (!isNaN(ms)) timedMessages.push({ type: entry.type, ts: ms });
102
111
  }
103
112
  } catch {
104
113
  // Skip malformed lines
105
114
  }
106
115
  }
107
116
 
108
- return { entries, humanCount, timestamps };
117
+ return { entries, humanCount, timedMessages };
109
118
  }
110
119
 
111
120
  /**
112
- * Compute active session time in minutes from a sorted list of timestamps.
121
+ * Compute AI vs human time breakdown from a sequence of timed messages.
113
122
  *
114
- * Sums consecutive gaps only when the gap is under 15 minutes (900_000ms).
115
- * Gaps of 15+ minutes are treated as idle (away from keyboard, blocked on CI,
116
- * etc.) and excluded so they don't inflate the active time metric.
123
+ * - human→* gap: Claude is processing (AI time)
124
+ * - assistant→* gap <30s: automated tool result turnaround (AI time)
125
+ * - assistant→* gap 30s–15m: human reviewing/thinking (human time)
126
+ * - Any gap ≥15m: idle, excluded
117
127
  */
118
- function computeActiveMinutes(allTimestamps: number[]): number {
119
- const sorted = [...allTimestamps].sort((a, b) => a - b);
120
- let totalMs = 0;
121
- const IDLE_THRESHOLD_MS = 900_000; // 15 minutes
128
+ function computeTimeBreakdown(messages: TimedMessage[]): {
129
+ totalMinutes: number;
130
+ aiMinutes: number;
131
+ humanMinutes: number;
132
+ } {
133
+ const sorted = [...messages].sort((a, b) => a.ts - b.ts);
134
+ const IDLE_MS = 900_000; // 15 min
135
+ const AUTO_MS = 30_000; // 30 sec — automated tool turnaround
136
+
137
+ let aiMs = 0;
138
+ let humanMs = 0;
139
+
122
140
  for (let i = 1; i < sorted.length; i++) {
123
- const gap = (sorted[i] ?? 0) - (sorted[i - 1] ?? 0);
124
- if (gap < IDLE_THRESHOLD_MS) totalMs += gap;
141
+ const prev = sorted[i - 1]!;
142
+ const curr = sorted[i]!;
143
+ const gap = curr.ts - prev.ts;
144
+ if (gap >= IDLE_MS) continue; // idle gap, skip
145
+
146
+ if (prev.type === "human") {
147
+ // Claude is processing a message
148
+ aiMs += gap;
149
+ } else {
150
+ // Gap after an assistant message
151
+ if (gap < AUTO_MS) {
152
+ aiMs += gap; // automated tool result
153
+ } else {
154
+ humanMs += gap; // human reviewing / typing next message
155
+ }
156
+ }
125
157
  }
126
- return Math.round(totalMs / 60_000);
158
+
159
+ return {
160
+ aiMinutes: Math.round(aiMs / 60_000),
161
+ humanMinutes: Math.round(humanMs / 60_000),
162
+ totalMinutes: Math.round((aiMs + humanMs) / 60_000),
163
+ };
127
164
  }
128
165
 
129
166
  function aggregateEntries(entries: TranscriptEntry[]): Map<string, ModelUsage> {
@@ -164,8 +201,9 @@ function aggregateEntries(entries: TranscriptEntry[]): Map<string, ModelUsage> {
164
201
  * ~/.claude/projects/<key>/ directory structure. Aggregates token counts by model
165
202
  * (Opus / Sonnet / Haiku / Unknown) and counts human prompt turns.
166
203
  *
167
- * Human prompt count is used as a proxy for "steering effort" in the /metrics output
168
- * it represents how many times the developer had to direct Claude.
204
+ * Time breakdown uses only the main transcript's message sequence (human vs
205
+ * assistant), since subagent messages are automated orchestration not human
206
+ * interaction. Human→assistant gaps = AI time; assistant→human gaps >30s = human time.
169
207
  *
170
208
  * Returns null if the session transcript file doesn't exist (session not found).
171
209
  */
@@ -183,21 +221,17 @@ export async function parseTranscript(
183
221
  const {
184
222
  entries: mainEntries,
185
223
  humanCount,
186
- timestamps: mainTimestamps,
224
+ timedMessages,
187
225
  } = await parseTranscriptFile(mainFile);
188
226
  const combined = aggregateEntries(mainEntries);
189
- const allTimestamps: number[] = [...mainTimestamps];
190
227
 
191
- // Merge subagent transcripts
228
+ // Merge subagent transcripts (token counts only — exclude from time breakdown)
192
229
  const subagentDir = join(transcriptDir, sessionId, "subagents");
193
230
  if (existsSync(subagentDir)) {
194
231
  for (const file of (await readdir(subagentDir)).filter((f) =>
195
232
  f.endsWith(".jsonl"),
196
233
  )) {
197
- const { entries, timestamps } = await parseTranscriptFile(
198
- join(subagentDir, file),
199
- );
200
- allTimestamps.push(...timestamps);
234
+ const { entries } = await parseTranscriptFile(join(subagentDir, file));
201
235
  for (const [model, usage] of aggregateEntries(entries)) {
202
236
  const existing = combined.get(model);
203
237
  if (!existing) {
@@ -235,11 +269,16 @@ export async function parseTranscript(
235
269
  },
236
270
  );
237
271
 
272
+ const { totalMinutes, aiMinutes, humanMinutes } =
273
+ computeTimeBreakdown(timedMessages);
274
+
238
275
  return {
239
276
  sessionId,
240
277
  byModel,
241
278
  totals,
242
279
  humanPromptCount: humanCount,
243
- activeMinutes: computeActiveMinutes(allTimestamps),
280
+ activeMinutes: totalMinutes,
281
+ aiMinutes,
282
+ humanMinutes,
244
283
  };
245
284
  }