claude-attribution 1.0.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 +431 -0
- package/bin/claude-attribution +9 -0
- package/package.json +26 -0
- package/src/__tests__/differ.test.ts +250 -0
- package/src/attribution/checkpoint.ts +148 -0
- package/src/attribution/commit.ts +163 -0
- package/src/attribution/differ.ts +154 -0
- package/src/attribution/git-notes.ts +185 -0
- package/src/attribution/otel.ts +233 -0
- package/src/cli.ts +109 -0
- package/src/commands/pr.ts +164 -0
- package/src/export/pr-summary.ts +204 -0
- package/src/hooks/post-tool-use.ts +105 -0
- package/src/hooks/pre-tool-use.ts +95 -0
- package/src/hooks/stop.ts +33 -0
- package/src/hooks/subagent.ts +72 -0
- package/src/lib/hooks.ts +60 -0
- package/src/metrics/calculate.ts +21 -0
- package/src/metrics/collect.ts +369 -0
- package/src/metrics/mark-start.ts +40 -0
- package/src/metrics/transcript.ts +245 -0
- package/src/run.sh +25 -0
- package/src/setup/install.ts +321 -0
- package/src/setup/templates/hooks.json +57 -0
- package/src/setup/templates/metrics-command.md +27 -0
- package/src/setup/templates/post-commit.sh +4 -0
- package/src/setup/templates/pr-command.md +33 -0
- package/src/setup/templates/pre-push.sh +4 -0
- package/src/setup/templates/start-command.md +25 -0
- package/src/setup/uninstall.ts +175 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Claude Code transcript files for token and model usage.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code writes JSONL transcript files to:
|
|
5
|
+
* ~/.claude/projects/<project-key>/<session-id>.jsonl
|
|
6
|
+
*
|
|
7
|
+
* where <project-key> is the absolute repo path with '/' replaced by '-'.
|
|
8
|
+
* Each line is a JSON object with `type: "human" | "assistant" | ...`.
|
|
9
|
+
* Assistant messages include `message.model` and `message.usage` (token counts).
|
|
10
|
+
*
|
|
11
|
+
* Subagent transcripts are stored in:
|
|
12
|
+
* ~/.claude/projects/<project-key>/<session-id>/subagents/<agent-id>.jsonl
|
|
13
|
+
*
|
|
14
|
+
* This module reads both main and subagent transcripts, merges them by model,
|
|
15
|
+
* and returns aggregated token/model usage + human prompt count.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, readdir } from "fs/promises";
|
|
18
|
+
import { existsSync } from "fs";
|
|
19
|
+
import { join, resolve } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
|
|
22
|
+
export interface ModelUsage {
|
|
23
|
+
modelShort: "Opus" | "Sonnet" | "Haiku" | "Unknown";
|
|
24
|
+
modelFull: string;
|
|
25
|
+
calls: number;
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
cacheCreationTokens: number;
|
|
29
|
+
cacheReadTokens: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TranscriptResult {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
byModel: ModelUsage[];
|
|
35
|
+
totals: {
|
|
36
|
+
totalCalls: number;
|
|
37
|
+
totalInputTokens: number;
|
|
38
|
+
totalOutputTokens: number;
|
|
39
|
+
totalCacheCreationTokens: number;
|
|
40
|
+
totalCacheReadTokens: number;
|
|
41
|
+
};
|
|
42
|
+
humanPromptCount: number;
|
|
43
|
+
/** Active session time in minutes (idle gaps >15 min are excluded). */
|
|
44
|
+
activeMinutes: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface TranscriptEntry {
|
|
48
|
+
type?: string;
|
|
49
|
+
timestamp?: string;
|
|
50
|
+
message?: {
|
|
51
|
+
model?: string;
|
|
52
|
+
usage?: {
|
|
53
|
+
input_tokens?: number;
|
|
54
|
+
output_tokens?: number;
|
|
55
|
+
cache_creation_input_tokens?: number;
|
|
56
|
+
cache_read_input_tokens?: number;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function modelShort(full: string): ModelUsage["modelShort"] {
|
|
62
|
+
if (/opus/i.test(full)) return "Opus";
|
|
63
|
+
if (/sonnet/i.test(full)) return "Sonnet";
|
|
64
|
+
if (/haiku/i.test(full)) return "Haiku";
|
|
65
|
+
return "Unknown";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive the Claude Code projects directory key from an absolute repo path.
|
|
70
|
+
*
|
|
71
|
+
* Claude Code stores transcripts at `~/.claude/projects/<key>/` where <key>
|
|
72
|
+
* is the absolute path with every '/' replaced by '-'. For example:
|
|
73
|
+
* /Users/alice/Code/my-repo → -Users-alice-Code-my-repo
|
|
74
|
+
*
|
|
75
|
+
* This key is used to locate the transcript JSONL file for a given session.
|
|
76
|
+
*/
|
|
77
|
+
function projectKey(repoRoot: string): string {
|
|
78
|
+
// Claude Code replaces '/' with '-' in the path
|
|
79
|
+
return repoRoot.replace(/\//g, "-");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function parseTranscriptFile(filePath: string): Promise<{
|
|
83
|
+
entries: TranscriptEntry[];
|
|
84
|
+
humanCount: number;
|
|
85
|
+
timestamps: number[];
|
|
86
|
+
}> {
|
|
87
|
+
const raw = await readFile(filePath, "utf8");
|
|
88
|
+
const entries: TranscriptEntry[] = [];
|
|
89
|
+
let humanCount = 0;
|
|
90
|
+
const timestamps: number[] = [];
|
|
91
|
+
|
|
92
|
+
for (const line of raw.split("\n")) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed) continue;
|
|
95
|
+
try {
|
|
96
|
+
const entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
97
|
+
entries.push(entry);
|
|
98
|
+
if (entry.type === "human") humanCount++;
|
|
99
|
+
if (entry.timestamp) {
|
|
100
|
+
const ms = new Date(entry.timestamp).getTime();
|
|
101
|
+
if (!isNaN(ms)) timestamps.push(ms);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Skip malformed lines
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { entries, humanCount, timestamps };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compute active session time in minutes from a sorted list of timestamps.
|
|
113
|
+
*
|
|
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.
|
|
117
|
+
*/
|
|
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
|
|
122
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
123
|
+
const gap = sorted[i] - sorted[i - 1];
|
|
124
|
+
if (gap < IDLE_THRESHOLD_MS) totalMs += gap;
|
|
125
|
+
}
|
|
126
|
+
return Math.round(totalMs / 60_000);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function aggregateEntries(entries: TranscriptEntry[]): Map<string, ModelUsage> {
|
|
130
|
+
const byModel = new Map<string, ModelUsage>();
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.type !== "assistant") continue;
|
|
134
|
+
const model = entry.message?.model;
|
|
135
|
+
const usage = entry.message?.usage;
|
|
136
|
+
if (!model || !usage) continue;
|
|
137
|
+
|
|
138
|
+
const existing = byModel.get(model) ?? {
|
|
139
|
+
modelShort: modelShort(model),
|
|
140
|
+
modelFull: model,
|
|
141
|
+
calls: 0,
|
|
142
|
+
inputTokens: 0,
|
|
143
|
+
outputTokens: 0,
|
|
144
|
+
cacheCreationTokens: 0,
|
|
145
|
+
cacheReadTokens: 0,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
existing.calls++;
|
|
149
|
+
existing.inputTokens += usage.input_tokens ?? 0;
|
|
150
|
+
existing.outputTokens += usage.output_tokens ?? 0;
|
|
151
|
+
existing.cacheCreationTokens += usage.cache_creation_input_tokens ?? 0;
|
|
152
|
+
existing.cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
153
|
+
|
|
154
|
+
byModel.set(model, existing);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return byModel;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse transcript data for a session, merging main and subagent token usage.
|
|
162
|
+
*
|
|
163
|
+
* Reads the main session transcript and all subagent transcripts from the
|
|
164
|
+
* ~/.claude/projects/<key>/ directory structure. Aggregates token counts by model
|
|
165
|
+
* (Opus / Sonnet / Haiku / Unknown) and counts human prompt turns.
|
|
166
|
+
*
|
|
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.
|
|
169
|
+
*
|
|
170
|
+
* Returns null if the session transcript file doesn't exist (session not found).
|
|
171
|
+
*/
|
|
172
|
+
export async function parseTranscript(
|
|
173
|
+
sessionId: string,
|
|
174
|
+
repoRoot?: string,
|
|
175
|
+
): Promise<TranscriptResult | null> {
|
|
176
|
+
const root = repoRoot ?? resolve(process.cwd());
|
|
177
|
+
const key = projectKey(root);
|
|
178
|
+
const transcriptDir = join(homedir(), ".claude", "projects", key);
|
|
179
|
+
const mainFile = join(transcriptDir, `${sessionId}.jsonl`);
|
|
180
|
+
|
|
181
|
+
if (!existsSync(mainFile)) return null;
|
|
182
|
+
|
|
183
|
+
const {
|
|
184
|
+
entries: mainEntries,
|
|
185
|
+
humanCount,
|
|
186
|
+
timestamps: mainTimestamps,
|
|
187
|
+
} = await parseTranscriptFile(mainFile);
|
|
188
|
+
const combined = aggregateEntries(mainEntries);
|
|
189
|
+
const allTimestamps: number[] = [...mainTimestamps];
|
|
190
|
+
|
|
191
|
+
// Merge subagent transcripts
|
|
192
|
+
const subagentDir = join(transcriptDir, sessionId, "subagents");
|
|
193
|
+
if (existsSync(subagentDir)) {
|
|
194
|
+
for (const file of (await readdir(subagentDir)).filter((f) =>
|
|
195
|
+
f.endsWith(".jsonl"),
|
|
196
|
+
)) {
|
|
197
|
+
const { entries, timestamps } = await parseTranscriptFile(
|
|
198
|
+
join(subagentDir, file),
|
|
199
|
+
);
|
|
200
|
+
allTimestamps.push(...timestamps);
|
|
201
|
+
for (const [model, usage] of aggregateEntries(entries)) {
|
|
202
|
+
const existing = combined.get(model);
|
|
203
|
+
if (!existing) {
|
|
204
|
+
combined.set(model, usage);
|
|
205
|
+
} else {
|
|
206
|
+
existing.calls += usage.calls;
|
|
207
|
+
existing.inputTokens += usage.inputTokens;
|
|
208
|
+
existing.outputTokens += usage.outputTokens;
|
|
209
|
+
existing.cacheCreationTokens += usage.cacheCreationTokens;
|
|
210
|
+
existing.cacheReadTokens += usage.cacheReadTokens;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const byModel = [...combined.values()].sort((a, b) =>
|
|
217
|
+
a.modelShort.localeCompare(b.modelShort),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const totals = byModel.reduce(
|
|
221
|
+
(acc, m) => ({
|
|
222
|
+
totalCalls: acc.totalCalls + m.calls,
|
|
223
|
+
totalInputTokens: acc.totalInputTokens + m.inputTokens,
|
|
224
|
+
totalOutputTokens: acc.totalOutputTokens + m.outputTokens,
|
|
225
|
+
totalCacheCreationTokens:
|
|
226
|
+
acc.totalCacheCreationTokens + m.cacheCreationTokens,
|
|
227
|
+
totalCacheReadTokens: acc.totalCacheReadTokens + m.cacheReadTokens,
|
|
228
|
+
}),
|
|
229
|
+
{
|
|
230
|
+
totalCalls: 0,
|
|
231
|
+
totalInputTokens: 0,
|
|
232
|
+
totalOutputTokens: 0,
|
|
233
|
+
totalCacheCreationTokens: 0,
|
|
234
|
+
totalCacheReadTokens: 0,
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
sessionId,
|
|
240
|
+
byModel,
|
|
241
|
+
totals,
|
|
242
|
+
humanPromptCount: humanCount,
|
|
243
|
+
activeMinutes: computeActiveMinutes(allTimestamps),
|
|
244
|
+
};
|
|
245
|
+
}
|
package/src/run.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# TypeScript runtime detector: try bun, then tsx, then npx tsx (no install required).
|
|
3
|
+
#
|
|
4
|
+
# Caches the detected runtime to /tmp/claude-attribution-runtime so that the
|
|
5
|
+
# `command -v` probe only runs once per machine reboot instead of on every hook
|
|
6
|
+
# invocation (hooks run on every Claude Code tool call).
|
|
7
|
+
#
|
|
8
|
+
# To force re-detection (e.g., after installing bun): rm /tmp/claude-attribution-runtime
|
|
9
|
+
CACHE="/tmp/claude-attribution-runtime"
|
|
10
|
+
if [ ! -f "$CACHE" ]; then
|
|
11
|
+
if command -v bun >/dev/null 2>&1; then
|
|
12
|
+
echo "bun" > "$CACHE"
|
|
13
|
+
elif command -v tsx >/dev/null 2>&1; then
|
|
14
|
+
echo "tsx" > "$CACHE"
|
|
15
|
+
else
|
|
16
|
+
echo "npx_tsx" > "$CACHE"
|
|
17
|
+
fi
|
|
18
|
+
fi
|
|
19
|
+
RUNTIME=$(cat "$CACHE")
|
|
20
|
+
case "$RUNTIME" in
|
|
21
|
+
bun) exec bun "$@" ;;
|
|
22
|
+
tsx) exec tsx "$@" ;;
|
|
23
|
+
npx_tsx) exec npx --yes tsx "$@" ;;
|
|
24
|
+
*) exec bun "$@" ;;
|
|
25
|
+
esac
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer — run once per repo to add claude-attribution hooks.
|
|
3
|
+
*
|
|
4
|
+
* Usage: bun src/setup/install.ts [/path/to/target-repo]
|
|
5
|
+
*
|
|
6
|
+
* If no path is given, installs into the current working directory.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
readFile,
|
|
10
|
+
writeFile,
|
|
11
|
+
appendFile,
|
|
12
|
+
copyFile,
|
|
13
|
+
chmod,
|
|
14
|
+
mkdir,
|
|
15
|
+
} from "fs/promises";
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { execFile } from "child_process";
|
|
18
|
+
import { promisify } from "util";
|
|
19
|
+
import { resolve, join, dirname } from "path";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
const ATTRIBUTION_ROOT = resolve(
|
|
25
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
26
|
+
"..",
|
|
27
|
+
"..",
|
|
28
|
+
);
|
|
29
|
+
const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
|
|
30
|
+
|
|
31
|
+
interface HookEntry {
|
|
32
|
+
matcher: string;
|
|
33
|
+
hooks: Array<{ type: string; command: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type HooksConfig = Record<string, HookEntry[]>;
|
|
37
|
+
|
|
38
|
+
interface ClaudeSettings {
|
|
39
|
+
hooks?: HooksConfig;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function mergeHooks(
|
|
44
|
+
settingsPath: string,
|
|
45
|
+
newHooks: HooksConfig,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
let settings: ClaudeSettings = {};
|
|
48
|
+
if (existsSync(settingsPath)) {
|
|
49
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
50
|
+
settings = JSON.parse(raw) as ClaudeSettings;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const existing = settings.hooks ?? {};
|
|
54
|
+
|
|
55
|
+
for (const [event, entries] of Object.entries(newHooks)) {
|
|
56
|
+
const current = existing[event] ?? [];
|
|
57
|
+
// Remove any existing claude-attribution entries, then push all new ones
|
|
58
|
+
const filtered = current.filter(
|
|
59
|
+
(e) => !e.hooks.some((h) => h.command.includes("claude-attribution")),
|
|
60
|
+
);
|
|
61
|
+
filtered.push(...entries);
|
|
62
|
+
existing[event] = filtered;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
settings.hooks = existing;
|
|
66
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detect whether the repo uses Husky or Lefthook to manage git hooks.
|
|
71
|
+
* These tools own .git/hooks/ themselves — we must not write there directly.
|
|
72
|
+
* Instead we register via their config files.
|
|
73
|
+
*/
|
|
74
|
+
function detectHookManager(repoRoot: string): "husky" | "lefthook" | "none" {
|
|
75
|
+
if (existsSync(join(repoRoot, ".husky"))) return "husky";
|
|
76
|
+
if (
|
|
77
|
+
existsSync(join(repoRoot, "lefthook.yml")) ||
|
|
78
|
+
existsSync(join(repoRoot, "lefthook.yaml")) ||
|
|
79
|
+
existsSync(join(repoRoot, "lefthook.json")) ||
|
|
80
|
+
existsSync(join(repoRoot, ".lefthook.yml")) ||
|
|
81
|
+
existsSync(join(repoRoot, ".lefthook.json"))
|
|
82
|
+
)
|
|
83
|
+
return "lefthook";
|
|
84
|
+
return "none";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function installPrePushHook(repoRoot: string): Promise<void> {
|
|
88
|
+
const manager = detectHookManager(repoRoot);
|
|
89
|
+
const runLine = `"${CLI_BIN}" hook pre-push || true`;
|
|
90
|
+
|
|
91
|
+
if (manager === "husky") {
|
|
92
|
+
const huskyHook = join(repoRoot, ".husky", "pre-push");
|
|
93
|
+
if (existsSync(huskyHook)) {
|
|
94
|
+
const existing = await readFile(huskyHook, "utf8");
|
|
95
|
+
if (!existing.includes("claude-attribution")) {
|
|
96
|
+
await writeFile(
|
|
97
|
+
huskyHook,
|
|
98
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
|
|
99
|
+
);
|
|
100
|
+
console.log(" (appended to existing .husky/pre-push)");
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
await writeFile(
|
|
104
|
+
huskyHook,
|
|
105
|
+
`#!/bin/sh\n# claude-attribution\n${runLine}\n`,
|
|
106
|
+
);
|
|
107
|
+
await chmod(huskyHook, 0o755);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (manager === "lefthook") {
|
|
113
|
+
console.log("");
|
|
114
|
+
console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
|
|
115
|
+
console.log(" pre-push:");
|
|
116
|
+
console.log(" commands:");
|
|
117
|
+
console.log(" claude-attribution:");
|
|
118
|
+
console.log(` run: ${runLine}`);
|
|
119
|
+
console.log("");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Plain git hooks
|
|
124
|
+
const hookDest = join(repoRoot, ".git", "hooks", "pre-push");
|
|
125
|
+
const rawTemplate = await readFile(
|
|
126
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pre-push.sh"),
|
|
127
|
+
"utf8",
|
|
128
|
+
);
|
|
129
|
+
const template = rawTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
|
|
130
|
+
|
|
131
|
+
if (existsSync(hookDest)) {
|
|
132
|
+
const existing = await readFile(hookDest, "utf8");
|
|
133
|
+
if (!existing.includes("claude-attribution")) {
|
|
134
|
+
await writeFile(
|
|
135
|
+
hookDest,
|
|
136
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + template,
|
|
137
|
+
);
|
|
138
|
+
await chmod(hookDest, 0o755);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Already ours — replace
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await writeFile(hookDest, template);
|
|
145
|
+
await chmod(hookDest, 0o755);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function installGitHook(repoRoot: string): Promise<void> {
|
|
149
|
+
const manager = detectHookManager(repoRoot);
|
|
150
|
+
const runLine = `"${CLI_BIN}" hook post-commit || true`;
|
|
151
|
+
|
|
152
|
+
if (manager === "husky") {
|
|
153
|
+
// Husky: add post-commit file to .husky/ directory
|
|
154
|
+
const huskyHook = join(repoRoot, ".husky", "post-commit");
|
|
155
|
+
if (existsSync(huskyHook)) {
|
|
156
|
+
const existing = await readFile(huskyHook, "utf8");
|
|
157
|
+
if (!existing.includes("claude-attribution")) {
|
|
158
|
+
await writeFile(
|
|
159
|
+
huskyHook,
|
|
160
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + runLine + "\n",
|
|
161
|
+
);
|
|
162
|
+
console.log(" (appended to existing .husky/post-commit)");
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
await writeFile(
|
|
166
|
+
huskyHook,
|
|
167
|
+
`#!/bin/sh\n# claude-attribution\n${runLine}\n`,
|
|
168
|
+
);
|
|
169
|
+
await chmod(huskyHook, 0o755);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (manager === "lefthook") {
|
|
175
|
+
// Lefthook: instruct the user — auto-editing YAML is fragile
|
|
176
|
+
console.log("");
|
|
177
|
+
console.log(" ⚠️ Lefthook detected. Add this to lefthook.yml manually:");
|
|
178
|
+
console.log(" post-commit:");
|
|
179
|
+
console.log(" commands:");
|
|
180
|
+
console.log(" claude-attribution:");
|
|
181
|
+
console.log(` run: ${runLine}`);
|
|
182
|
+
console.log("");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Plain git hooks
|
|
187
|
+
const hookDest = join(repoRoot, ".git", "hooks", "post-commit");
|
|
188
|
+
const template = await readFile(
|
|
189
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "post-commit.sh"),
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
const newContent = template.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
|
|
193
|
+
|
|
194
|
+
if (existsSync(hookDest)) {
|
|
195
|
+
const existing = await readFile(hookDest, "utf8");
|
|
196
|
+
if (!existing.includes("claude-attribution")) {
|
|
197
|
+
await writeFile(
|
|
198
|
+
hookDest,
|
|
199
|
+
existing.trimEnd() + "\n\n# claude-attribution\n" + newContent,
|
|
200
|
+
);
|
|
201
|
+
await chmod(hookDest, 0o755);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Already ours — replace
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await writeFile(hookDest, newContent);
|
|
208
|
+
await chmod(hookDest, 0o755);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function main() {
|
|
212
|
+
const targetRepo = resolve(process.argv[2] ?? process.cwd());
|
|
213
|
+
|
|
214
|
+
if (!existsSync(join(targetRepo, ".git"))) {
|
|
215
|
+
console.error(`Error: ${targetRepo} is not a git repository`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log(`Installing claude-attribution into: ${targetRepo}`);
|
|
220
|
+
console.log(`CLI binary: ${CLI_BIN}`);
|
|
221
|
+
|
|
222
|
+
// 1. Merge hooks into .claude/settings.json
|
|
223
|
+
const claudeDir = join(targetRepo, ".claude");
|
|
224
|
+
await mkdir(claudeDir, { recursive: true });
|
|
225
|
+
await mkdir(join(claudeDir, "attribution-state"), { recursive: true });
|
|
226
|
+
await mkdir(join(claudeDir, "logs", "sessions"), { recursive: true });
|
|
227
|
+
|
|
228
|
+
// Ensure .claude/logs/ is gitignored — tool usage logs contain session data
|
|
229
|
+
// that shouldn't land in version control.
|
|
230
|
+
const gitignorePath = join(targetRepo, ".gitignore");
|
|
231
|
+
const existingGitignore = existsSync(gitignorePath)
|
|
232
|
+
? await readFile(gitignorePath, "utf8")
|
|
233
|
+
: "";
|
|
234
|
+
if (!existingGitignore.includes(".claude/logs")) {
|
|
235
|
+
const prefix = existingGitignore.endsWith("\n") ? "" : "\n";
|
|
236
|
+
await appendFile(
|
|
237
|
+
gitignorePath,
|
|
238
|
+
`${prefix}# claude-attribution tool usage logs\n.claude/logs/\n`,
|
|
239
|
+
);
|
|
240
|
+
console.log("✓ Added .claude/logs/ to .gitignore");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
244
|
+
const hooksTemplate = await readFile(
|
|
245
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "hooks.json"),
|
|
246
|
+
"utf8",
|
|
247
|
+
);
|
|
248
|
+
const hooksConfig = JSON.parse(
|
|
249
|
+
hooksTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN),
|
|
250
|
+
) as HooksConfig;
|
|
251
|
+
|
|
252
|
+
await mergeHooks(settingsPath, hooksConfig);
|
|
253
|
+
console.log("✓ Merged hooks into .claude/settings.json");
|
|
254
|
+
|
|
255
|
+
// 2. Install post-commit and pre-push git hooks
|
|
256
|
+
await installGitHook(targetRepo);
|
|
257
|
+
console.log("✓ Installed .git/hooks/post-commit");
|
|
258
|
+
await installPrePushHook(targetRepo);
|
|
259
|
+
console.log(
|
|
260
|
+
"✓ Installed .git/hooks/pre-push (pushes attribution notes to origin)",
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Ensure git push also pushes notes by default
|
|
264
|
+
try {
|
|
265
|
+
await execFileAsync(
|
|
266
|
+
"git",
|
|
267
|
+
[
|
|
268
|
+
"config",
|
|
269
|
+
"--add",
|
|
270
|
+
"remote.origin.push",
|
|
271
|
+
"refs/notes/claude-attribution:refs/notes/claude-attribution",
|
|
272
|
+
],
|
|
273
|
+
{ cwd: targetRepo },
|
|
274
|
+
);
|
|
275
|
+
console.log(
|
|
276
|
+
"✓ Configured remote.origin.push for attribution notes refspec",
|
|
277
|
+
);
|
|
278
|
+
} catch {
|
|
279
|
+
console.log(
|
|
280
|
+
" (skipped git config remote.origin.push — no origin remote or git unavailable)",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3. Install /metrics slash command
|
|
285
|
+
const commandsDir = join(claudeDir, "commands");
|
|
286
|
+
await mkdir(commandsDir, { recursive: true });
|
|
287
|
+
const metricsTemplate = await readFile(
|
|
288
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "metrics-command.md"),
|
|
289
|
+
"utf8",
|
|
290
|
+
);
|
|
291
|
+
const metricsContent = metricsTemplate.replace(
|
|
292
|
+
/\{\{CLI_BIN\}\}/g,
|
|
293
|
+
() => CLI_BIN,
|
|
294
|
+
);
|
|
295
|
+
await writeFile(join(commandsDir, "metrics.md"), metricsContent);
|
|
296
|
+
console.log("✓ Installed .claude/commands/metrics.md (/metrics command)");
|
|
297
|
+
|
|
298
|
+
const startTemplate = await readFile(
|
|
299
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "start-command.md"),
|
|
300
|
+
"utf8",
|
|
301
|
+
);
|
|
302
|
+
const startContent = startTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
|
|
303
|
+
await writeFile(join(commandsDir, "start.md"), startContent);
|
|
304
|
+
console.log("✓ Installed .claude/commands/start.md (/start command)");
|
|
305
|
+
|
|
306
|
+
const prTemplate = await readFile(
|
|
307
|
+
join(ATTRIBUTION_ROOT, "src", "setup", "templates", "pr-command.md"),
|
|
308
|
+
"utf8",
|
|
309
|
+
);
|
|
310
|
+
const prContent = prTemplate.replace(/\{\{CLI_BIN\}\}/g, () => CLI_BIN);
|
|
311
|
+
await writeFile(join(commandsDir, "pr.md"), prContent);
|
|
312
|
+
console.log("✓ Installed .claude/commands/pr.md (/pr command)");
|
|
313
|
+
|
|
314
|
+
console.log("\nDone! claude-attribution is active for this repo.");
|
|
315
|
+
console.log("Use /pr in Claude Code to create a PR with metrics embedded.");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
main().catch((err) => {
|
|
319
|
+
console.error("Installation failed:", err);
|
|
320
|
+
process.exit(1);
|
|
321
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PreToolUse": [
|
|
3
|
+
{
|
|
4
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "\"{{CLI_BIN}}\" hook pre-tool-use"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"PostToolUse": [
|
|
14
|
+
{
|
|
15
|
+
"matcher": ".*",
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "\"{{CLI_BIN}}\" hook post-tool-use"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"SubagentStart": [
|
|
25
|
+
{
|
|
26
|
+
"matcher": ".*",
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "\"{{CLI_BIN}}\" hook subagent"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"SubagentStop": [
|
|
36
|
+
{
|
|
37
|
+
"matcher": ".*",
|
|
38
|
+
"hooks": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "\"{{CLI_BIN}}\" hook subagent"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"Stop": [
|
|
47
|
+
{
|
|
48
|
+
"matcher": ".*",
|
|
49
|
+
"hooks": [
|
|
50
|
+
{
|
|
51
|
+
"type": "command",
|
|
52
|
+
"command": "\"{{CLI_BIN}}\" hook stop"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Generate Claude Code Metrics
|
|
2
|
+
|
|
3
|
+
Generate session metrics for PR documentation.
|
|
4
|
+
|
|
5
|
+
## Instructions
|
|
6
|
+
|
|
7
|
+
Run the metrics calculator:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
"{{CLI_BIN}}" metrics
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This outputs PR-ready markdown covering:
|
|
14
|
+
- **Tools Used** — all tool invocations by count
|
|
15
|
+
- **Agents/Skills** — subagent invocations
|
|
16
|
+
- **Model Usage** — API calls and tokens by model
|
|
17
|
+
- **Human prompts** — count of human turns (steering effort proxy)
|
|
18
|
+
- **Code Attribution** — AI / human / mixed line counts from git notes (accurate, not gross write counts)
|
|
19
|
+
- **Files Modified** — per-file AI %
|
|
20
|
+
|
|
21
|
+
To run with a specific session ID:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
"{{CLI_BIN}}" metrics <session-id>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Session IDs are in `.claude/logs/tool-usage.jsonl`.
|