tokengolf 0.3.0 → 0.4.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/.husky/pre-commit +4 -0
- package/.prettierignore +2 -0
- package/.prettierrc +6 -0
- package/.vscode/settings.json +15 -0
- package/CHANGELOG.md +254 -0
- package/CLAUDE.md +136 -10
- package/README.md +89 -47
- package/assets/demo-hud.png +0 -0
- package/assets/scorecard.png +0 -0
- package/dist/cli.js +790 -103
- package/docs/assets/demo-hud.png +0 -0
- package/docs/assets/scorecard.png +0 -0
- package/docs/assets/tokengolf-bg-min.jpg +0 -0
- package/docs/index.html +1080 -0
- package/eslint.config.js +39 -0
- package/hooks/post-tool-use-failure.js +27 -0
- package/hooks/post-tool-use.js +11 -7
- package/hooks/pre-compact.js +9 -3
- package/hooks/session-end.js +168 -42
- package/hooks/session-start.js +31 -11
- package/hooks/session-stop.js +6 -2
- package/hooks/statusline.sh +16 -7
- package/hooks/stop.js +27 -0
- package/hooks/subagent-start.js +27 -0
- package/hooks/user-prompt-submit.js +8 -6
- package/package.json +16 -3
- package/src/cli.js +23 -6
- package/src/components/ActiveRun.js +76 -24
- package/src/components/ScoreCard.js +132 -37
- package/src/components/StartRun.js +156 -53
- package/src/components/StatsView.js +89 -37
- package/src/lib/__tests__/score.test.js +596 -0
- package/src/lib/cost.js +84 -21
- package/src/lib/demo.js +186 -0
- package/src/lib/install.js +92 -62
- package/src/lib/score.js +433 -136
- package/src/lib/store.js +11 -11
- package/.claude/settings.local.json +0 -36
package/src/lib/cost.js
CHANGED
|
@@ -4,9 +4,9 @@ import os from 'os';
|
|
|
4
4
|
|
|
5
5
|
// Pricing per million tokens (Anthropic list prices)
|
|
6
6
|
const PRICING = {
|
|
7
|
-
'claude-opus-4':
|
|
8
|
-
'claude-sonnet-4': { input:
|
|
9
|
-
'claude-haiku-4':
|
|
7
|
+
'claude-opus-4': { input: 15.0, output: 75.0, cacheWrite: 18.75, cacheRead: 1.5 },
|
|
8
|
+
'claude-sonnet-4': { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.3 },
|
|
9
|
+
'claude-haiku-4': { input: 0.8, output: 4.0, cacheWrite: 1.0, cacheRead: 0.08 },
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
function getPrice(model) {
|
|
@@ -33,14 +33,17 @@ export function parseCostFromTranscript(transcriptPath) {
|
|
|
33
33
|
const model = entry.message.model;
|
|
34
34
|
const p = getPrice(model);
|
|
35
35
|
const u = entry.message.usage;
|
|
36
|
-
const cost =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
const cost =
|
|
37
|
+
((u.input_tokens || 0) / 1e6) * p.input +
|
|
38
|
+
((u.output_tokens || 0) / 1e6) * p.output +
|
|
39
|
+
((u.cache_creation_input_tokens || 0) / 1e6) * p.cacheWrite +
|
|
40
|
+
((u.cache_read_input_tokens || 0) / 1e6) * p.cacheRead;
|
|
40
41
|
total += cost;
|
|
41
42
|
byModel[model] = (byModel[model] || 0) + cost;
|
|
42
43
|
}
|
|
43
|
-
} catch {
|
|
44
|
+
} catch {
|
|
45
|
+
/* skip malformed lines */
|
|
46
|
+
}
|
|
44
47
|
}
|
|
45
48
|
return total > 0 ? { total, byModel } : null;
|
|
46
49
|
} catch {
|
|
@@ -51,9 +54,13 @@ export function parseCostFromTranscript(transcriptPath) {
|
|
|
51
54
|
// Returns all transcript paths modified at or after sinceMs
|
|
52
55
|
function findTranscriptsSince(projectDir, sinceMs) {
|
|
53
56
|
try {
|
|
54
|
-
return fs
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
+
return fs
|
|
58
|
+
.readdirSync(projectDir)
|
|
59
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
60
|
+
.map((f) => ({
|
|
61
|
+
p: path.join(projectDir, f),
|
|
62
|
+
mtime: fs.statSync(path.join(projectDir, f)).mtimeMs,
|
|
63
|
+
}))
|
|
57
64
|
.filter(({ mtime }) => mtime >= sinceMs)
|
|
58
65
|
.map(({ p }) => p);
|
|
59
66
|
} catch {
|
|
@@ -71,7 +78,7 @@ export function parseThinkingFromTranscripts(paths) {
|
|
|
71
78
|
try {
|
|
72
79
|
const entry = JSON.parse(line);
|
|
73
80
|
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
74
|
-
const thinkBlocks = entry.message.content.filter(b => b.type === 'thinking');
|
|
81
|
+
const thinkBlocks = entry.message.content.filter((b) => b.type === 'thinking');
|
|
75
82
|
if (thinkBlocks.length > 0) {
|
|
76
83
|
invocations++;
|
|
77
84
|
for (const block of thinkBlocks) {
|
|
@@ -79,9 +86,13 @@ export function parseThinkingFromTranscripts(paths) {
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
|
-
} catch {
|
|
89
|
+
} catch {
|
|
90
|
+
/* skip malformed lines */
|
|
91
|
+
}
|
|
83
92
|
}
|
|
84
|
-
} catch {
|
|
93
|
+
} catch {
|
|
94
|
+
/* skip unreadable files */
|
|
95
|
+
}
|
|
85
96
|
}
|
|
86
97
|
return invocations > 0 ? { thinkingInvocations: invocations, thinkingTokens: tokens } : null;
|
|
87
98
|
}
|
|
@@ -101,19 +112,53 @@ function parseAllTranscripts(paths) {
|
|
|
101
112
|
return total > 0 ? { total, byModel } : null;
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
export function modelFamily(model) {
|
|
116
|
+
const m = (model || '').toLowerCase();
|
|
117
|
+
if (m.includes('haiku')) return 'haiku';
|
|
118
|
+
if (m.includes('sonnet')) return 'sonnet';
|
|
119
|
+
if (m.includes('opus')) return 'opus';
|
|
120
|
+
return 'unknown';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseModelSwitches(transcriptPath) {
|
|
124
|
+
try {
|
|
125
|
+
const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n');
|
|
126
|
+
let lastFamily = null;
|
|
127
|
+
let switches = 0;
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
try {
|
|
130
|
+
const entry = JSON.parse(line);
|
|
131
|
+
if (entry.type === 'assistant' && entry.message?.model) {
|
|
132
|
+
const family = modelFamily(entry.message.model);
|
|
133
|
+
if (lastFamily !== null && family !== lastFamily) switches++;
|
|
134
|
+
lastFamily = family;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
/* skip */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { switches };
|
|
141
|
+
} catch {
|
|
142
|
+
return { switches: 0 };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
export function findTranscript(sessionId, projectDir) {
|
|
105
147
|
if (sessionId) {
|
|
106
148
|
try {
|
|
107
149
|
const p = path.join(projectDir, `${sessionId}.jsonl`);
|
|
108
150
|
fs.accessSync(p);
|
|
109
151
|
return p;
|
|
110
|
-
} catch {
|
|
152
|
+
} catch {
|
|
153
|
+
/* fall through */
|
|
154
|
+
}
|
|
111
155
|
}
|
|
112
156
|
// Fall back to most recently modified transcript
|
|
113
157
|
try {
|
|
114
|
-
const files = fs
|
|
115
|
-
.
|
|
116
|
-
.
|
|
158
|
+
const files = fs
|
|
159
|
+
.readdirSync(projectDir)
|
|
160
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
161
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
|
|
117
162
|
.sort((a, b) => b.mtime - a.mtime);
|
|
118
163
|
return files.length ? path.join(projectDir, files[0].f) : null;
|
|
119
164
|
} catch {
|
|
@@ -127,9 +172,10 @@ export function autoDetectCost(run) {
|
|
|
127
172
|
|
|
128
173
|
// Scan all transcripts modified since session start to capture subagent sidechains
|
|
129
174
|
const sinceMs = run.startedAt ? new Date(run.startedAt).getTime() : 0;
|
|
130
|
-
const paths =
|
|
131
|
-
|
|
132
|
-
|
|
175
|
+
const paths =
|
|
176
|
+
sinceMs > 0
|
|
177
|
+
? findTranscriptsSince(projectDir, sinceMs)
|
|
178
|
+
: [findTranscript(run.sessionId, projectDir)].filter(Boolean);
|
|
133
179
|
|
|
134
180
|
const parsed = paths.length > 0 ? parseAllTranscripts(paths) : null;
|
|
135
181
|
|
|
@@ -140,10 +186,27 @@ export function autoDetectCost(run) {
|
|
|
140
186
|
// Always use parsed model breakdown (Stop hook doesn't capture it)
|
|
141
187
|
const modelBreakdown = parsed?.byModel ?? run.modelBreakdown ?? null;
|
|
142
188
|
const thinking = parseThinkingFromTranscripts(paths);
|
|
189
|
+
|
|
190
|
+
// Model switch detection: only on primary transcript (user-initiated switches)
|
|
191
|
+
const primaryPath = findTranscript(run.sessionId, projectDir);
|
|
192
|
+
const { switches: modelSwitches } = primaryPath
|
|
193
|
+
? parseModelSwitches(primaryPath)
|
|
194
|
+
: { switches: 0 };
|
|
195
|
+
|
|
196
|
+
// Distinct model families across ALL transcripts (includes subagents)
|
|
197
|
+
const families = new Set(
|
|
198
|
+
Object.keys(parsed?.byModel ?? {})
|
|
199
|
+
.map(modelFamily)
|
|
200
|
+
.filter((f) => f !== 'unknown')
|
|
201
|
+
);
|
|
202
|
+
const distinctModels = families.size;
|
|
203
|
+
|
|
143
204
|
return {
|
|
144
205
|
spent,
|
|
145
206
|
modelBreakdown,
|
|
146
207
|
thinkingInvocations: thinking?.thinkingInvocations ?? 0,
|
|
147
208
|
thinkingTokens: thinking?.thinkingTokens ?? 0,
|
|
209
|
+
modelSwitches,
|
|
210
|
+
distinctModels,
|
|
148
211
|
};
|
|
149
212
|
}
|
package/src/lib/demo.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const R = '\x1b[31m';
|
|
2
|
+
const G = '\x1b[32m';
|
|
3
|
+
const Y = '\x1b[33m';
|
|
4
|
+
const M = '\x1b[35m';
|
|
5
|
+
const C = '\x1b[36m';
|
|
6
|
+
const DIM = '\x1b[2m';
|
|
7
|
+
const BOLD = '\x1b[1m';
|
|
8
|
+
const RESET = '\x1b[0m';
|
|
9
|
+
|
|
10
|
+
function hudLine({ quest, model, cost, budget, ctxPct, effort, fainted, floor }) {
|
|
11
|
+
const m = (model || '').toLowerCase();
|
|
12
|
+
let modelName, modelEmoji;
|
|
13
|
+
if (m.includes('haiku')) {
|
|
14
|
+
modelName = 'Haiku';
|
|
15
|
+
modelEmoji = '🏹';
|
|
16
|
+
} else if (m.includes('sonnet')) {
|
|
17
|
+
modelName = 'Sonnet';
|
|
18
|
+
modelEmoji = '⚔️';
|
|
19
|
+
} else if (m.includes('opus')) {
|
|
20
|
+
modelName = 'Opus';
|
|
21
|
+
modelEmoji = '🧙';
|
|
22
|
+
} else {
|
|
23
|
+
modelName = '?';
|
|
24
|
+
modelEmoji = '?';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const labelParts = [`${modelEmoji} ${modelName}`];
|
|
28
|
+
if (effort) labelParts.push(effort.charAt(0).toUpperCase() + effort.slice(1));
|
|
29
|
+
const modelLabel = labelParts.join('·');
|
|
30
|
+
|
|
31
|
+
let tierEmoji;
|
|
32
|
+
if (cost < 0.1) tierEmoji = '💎';
|
|
33
|
+
else if (cost < 0.3) tierEmoji = '🥇';
|
|
34
|
+
else if (cost < 1.0) tierEmoji = '🥈';
|
|
35
|
+
else if (cost < 3.0) tierEmoji = '🥉';
|
|
36
|
+
else tierEmoji = '💸';
|
|
37
|
+
|
|
38
|
+
const sep = ` ${DIM}|${RESET} `;
|
|
39
|
+
let costStr, ratingStr;
|
|
40
|
+
|
|
41
|
+
if (budget) {
|
|
42
|
+
const pct = (cost / budget) * 100;
|
|
43
|
+
let rating, rc;
|
|
44
|
+
if (pct <= 25) {
|
|
45
|
+
rating = 'LEGENDARY';
|
|
46
|
+
rc = M;
|
|
47
|
+
} else if (pct <= 50) {
|
|
48
|
+
rating = 'EFFICIENT';
|
|
49
|
+
rc = C;
|
|
50
|
+
} else if (pct <= 75) {
|
|
51
|
+
rating = 'SOLID';
|
|
52
|
+
rc = G;
|
|
53
|
+
} else if (pct <= 100) {
|
|
54
|
+
rating = 'CLOSE CALL';
|
|
55
|
+
rc = Y;
|
|
56
|
+
} else {
|
|
57
|
+
rating = 'BUSTED';
|
|
58
|
+
rc = R;
|
|
59
|
+
}
|
|
60
|
+
costStr = `${tierEmoji} $${cost.toFixed(4)}/$${budget.toFixed(2)} ${pct.toFixed(0)}%`;
|
|
61
|
+
ratingStr = `${rc}${rating}${RESET}`;
|
|
62
|
+
} else {
|
|
63
|
+
costStr = `${tierEmoji} $${cost.toFixed(4)}`;
|
|
64
|
+
ratingStr = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let ctxStr = null;
|
|
68
|
+
if (ctxPct != null) {
|
|
69
|
+
if (ctxPct >= 90) ctxStr = `${R}📦 ${ctxPct}%${RESET}`;
|
|
70
|
+
else if (ctxPct >= 75) ctxStr = `${Y}🎒 ${ctxPct}%${RESET}`;
|
|
71
|
+
else if (ctxPct >= 50) ctxStr = `${G}🪶 ${ctxPct}%${RESET}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const icon = fainted ? '💤' : '⛳';
|
|
75
|
+
const prefix = `${BOLD}${C}${icon}${RESET}`;
|
|
76
|
+
const parts = [`${prefix} ${quest}`, costStr];
|
|
77
|
+
if (ratingStr) parts.push(ratingStr);
|
|
78
|
+
if (ctxStr) parts.push(ctxStr);
|
|
79
|
+
parts.push(`${C}${modelLabel}${RESET}`);
|
|
80
|
+
if (budget && floor) parts.push(`Floor ${floor}`);
|
|
81
|
+
|
|
82
|
+
return `${DIM} ───────────────${RESET}\n${parts.join(sep)}\n${DIM} ───────────────${RESET}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SCENARIOS = [
|
|
86
|
+
{
|
|
87
|
+
title: 'Flow mode (passive — no quest, no budget)',
|
|
88
|
+
hud: { quest: 'Flow', model: 'claude-sonnet-4-6', cost: 0.0034 },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
title: 'Roguelike · Sonnet · EFFICIENT',
|
|
92
|
+
hud: {
|
|
93
|
+
quest: 'add pagination to /users',
|
|
94
|
+
model: 'claude-sonnet-4-6',
|
|
95
|
+
cost: 0.54,
|
|
96
|
+
budget: 1.5,
|
|
97
|
+
ctxPct: 34,
|
|
98
|
+
floor: '2/5',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
title: 'Roguelike · Sonnet·High · LEGENDARY',
|
|
103
|
+
hud: {
|
|
104
|
+
quest: 'implement SSO with SAML',
|
|
105
|
+
model: 'claude-sonnet-4-6',
|
|
106
|
+
cost: 0.41,
|
|
107
|
+
budget: 2.0,
|
|
108
|
+
ctxPct: 29,
|
|
109
|
+
effort: 'high',
|
|
110
|
+
floor: '1/5',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
title: 'Roguelike · Opus · LEGENDARY · 🪶 context',
|
|
115
|
+
hud: {
|
|
116
|
+
quest: 'refactor auth middleware',
|
|
117
|
+
model: 'claude-opus-4-6',
|
|
118
|
+
cost: 0.82,
|
|
119
|
+
budget: 4.0,
|
|
120
|
+
ctxPct: 52,
|
|
121
|
+
floor: '3/5',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
title: 'Roguelike · Haiku · CLOSE CALL · 🎒 context',
|
|
126
|
+
hud: {
|
|
127
|
+
quest: 'fix N+1 query in dashboard',
|
|
128
|
+
model: 'claude-haiku-4-5-20251001',
|
|
129
|
+
cost: 0.46,
|
|
130
|
+
budget: 0.5,
|
|
131
|
+
ctxPct: 78,
|
|
132
|
+
floor: '4/5',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
title: 'Roguelike · BUSTED — over budget',
|
|
137
|
+
hud: {
|
|
138
|
+
quest: 'migrate postgres schema',
|
|
139
|
+
model: 'claude-sonnet-4-6',
|
|
140
|
+
cost: 2.41,
|
|
141
|
+
budget: 2.0,
|
|
142
|
+
floor: '2/5',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
title: 'Roguelike · Opus · EFFICIENT · 📦 overencumbered',
|
|
147
|
+
hud: {
|
|
148
|
+
quest: 'refactor entire API layer',
|
|
149
|
+
model: 'claude-opus-4-6',
|
|
150
|
+
cost: 3.1,
|
|
151
|
+
budget: 10.0,
|
|
152
|
+
ctxPct: 91,
|
|
153
|
+
floor: '3/5',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: 'Fainted 💤 — usage limit hit, run resumes next session',
|
|
158
|
+
hud: {
|
|
159
|
+
quest: 'write test suite for payments',
|
|
160
|
+
model: 'claude-sonnet-4-6',
|
|
161
|
+
cost: 1.22,
|
|
162
|
+
budget: 3.0,
|
|
163
|
+
ctxPct: 67,
|
|
164
|
+
fainted: true,
|
|
165
|
+
floor: '2/5',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
export function runDemo() {
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log(`${BOLD}${C}⛳ TokenGolf — HUD Demo${RESET}`);
|
|
173
|
+
console.log(`${DIM}Live statusline shown in every Claude Code session${RESET}`);
|
|
174
|
+
console.log('');
|
|
175
|
+
|
|
176
|
+
for (const { title, hud } of SCENARIOS) {
|
|
177
|
+
console.log(`${DIM}${title}${RESET}`);
|
|
178
|
+
console.log(hudLine(hud));
|
|
179
|
+
console.log('');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(
|
|
183
|
+
`${DIM}Run ${RESET}tokengolf start${DIM} to begin a roguelike run, or just open Claude Code — flow mode tracks automatically.${RESET}`
|
|
184
|
+
);
|
|
185
|
+
console.log('');
|
|
186
|
+
}
|
package/src/lib/install.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import os from
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
4
|
|
|
5
5
|
// Follow symlinks (npm link creates a symlink in the nvm/node bin dir)
|
|
6
6
|
// to find the actual project directory, then resolve hooks/ relative to it.
|
|
7
7
|
const realEntry = fs.realpathSync(process.argv[1]);
|
|
8
|
-
const HOOKS_DIR = path.resolve(path.dirname(realEntry),
|
|
9
|
-
const STATUSLINE_PATH = path.join(HOOKS_DIR,
|
|
10
|
-
const WRAPPER_PATH = path.join(HOOKS_DIR,
|
|
11
|
-
const CLAUDE_DIR = path.join(os.homedir(),
|
|
12
|
-
const CLAUDE_SETTINGS = path.join(CLAUDE_DIR,
|
|
8
|
+
const HOOKS_DIR = path.resolve(path.dirname(realEntry), '../hooks');
|
|
9
|
+
const STATUSLINE_PATH = path.join(HOOKS_DIR, 'statusline.sh');
|
|
10
|
+
const WRAPPER_PATH = path.join(HOOKS_DIR, 'statusline-wrapper.sh');
|
|
11
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
12
|
+
const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
|
|
13
13
|
|
|
14
14
|
export function installHooks() {
|
|
15
|
-
console.log(
|
|
15
|
+
console.log('\n⛳ TokenGolf — Installing Claude Code hooks\n');
|
|
16
16
|
|
|
17
17
|
let settings = {};
|
|
18
18
|
if (fs.existsSync(CLAUDE_SETTINGS)) {
|
|
19
19
|
try {
|
|
20
|
-
settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS,
|
|
21
|
-
console.log(
|
|
20
|
+
settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8'));
|
|
21
|
+
console.log(' ✓ Found ~/.claude/settings.json');
|
|
22
22
|
} catch {
|
|
23
|
-
console.log(
|
|
23
|
+
console.log(' ⚠️ Could not parse settings.json — starting fresh');
|
|
24
24
|
}
|
|
25
25
|
} else {
|
|
26
26
|
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
27
|
-
console.log(
|
|
27
|
+
console.log(' ℹ️ Creating ~/.claude/settings.json');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if (!settings.hooks) settings.hooks = {};
|
|
@@ -37,73 +37,106 @@ export function installHooks() {
|
|
|
37
37
|
!h._tg &&
|
|
38
38
|
!h.hooks?.some(
|
|
39
39
|
(e) =>
|
|
40
|
-
e.command?.includes(
|
|
41
|
-
e.command?.includes(
|
|
42
|
-
e.command?.includes(
|
|
43
|
-
e.command?.includes(
|
|
44
|
-
e.command?.includes(
|
|
45
|
-
e.command?.includes(
|
|
46
|
-
e.command?.includes(
|
|
47
|
-
|
|
40
|
+
e.command?.includes('tokengolf') ||
|
|
41
|
+
e.command?.includes('session-start.js') ||
|
|
42
|
+
e.command?.includes('session-stop.js') ||
|
|
43
|
+
e.command?.includes('session-end.js') ||
|
|
44
|
+
e.command?.includes('pre-compact.js') ||
|
|
45
|
+
e.command?.includes('post-tool-use.js') ||
|
|
46
|
+
e.command?.includes('post-tool-use-failure.js') ||
|
|
47
|
+
e.command?.includes('subagent-start.js') ||
|
|
48
|
+
e.command?.includes('stop.js') ||
|
|
49
|
+
e.command?.includes('user-prompt-submit.js')
|
|
50
|
+
)
|
|
48
51
|
);
|
|
49
52
|
settings.hooks[event] = [...filtered, { _tg: true, ...entry }];
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
// Remove Stop hook if present (
|
|
55
|
+
// Remove old session-stop.js Stop hook if present (superseded by session-end.js)
|
|
53
56
|
if (settings.hooks.Stop) {
|
|
54
57
|
settings.hooks.Stop = (settings.hooks.Stop || []).filter(
|
|
55
|
-
(h) =>
|
|
56
|
-
!h._tg && !h.hooks?.some((e) => e.command?.includes("session-stop.js")),
|
|
58
|
+
(h) => !h._tg && !h.hooks?.some((e) => e.command?.includes('session-stop.js'))
|
|
57
59
|
);
|
|
58
60
|
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
upsertHook(
|
|
63
|
+
upsertHook('SessionStart', {
|
|
62
64
|
hooks: [
|
|
63
65
|
{
|
|
64
|
-
type:
|
|
65
|
-
command: `node ${path.join(HOOKS_DIR,
|
|
66
|
+
type: 'command',
|
|
67
|
+
command: `node ${path.join(HOOKS_DIR, 'session-start.js')}`,
|
|
66
68
|
timeout: 5,
|
|
67
69
|
},
|
|
68
70
|
],
|
|
69
71
|
});
|
|
70
72
|
|
|
71
|
-
upsertHook(
|
|
72
|
-
matcher:
|
|
73
|
+
upsertHook('PostToolUse', {
|
|
74
|
+
matcher: '',
|
|
73
75
|
hooks: [
|
|
74
76
|
{
|
|
75
|
-
type:
|
|
76
|
-
command: `node ${path.join(HOOKS_DIR,
|
|
77
|
+
type: 'command',
|
|
78
|
+
command: `node ${path.join(HOOKS_DIR, 'post-tool-use.js')}`,
|
|
77
79
|
timeout: 5,
|
|
78
80
|
},
|
|
79
81
|
],
|
|
80
82
|
});
|
|
81
83
|
|
|
82
|
-
upsertHook(
|
|
84
|
+
upsertHook('UserPromptSubmit', {
|
|
83
85
|
hooks: [
|
|
84
86
|
{
|
|
85
|
-
type:
|
|
86
|
-
command: `node ${path.join(HOOKS_DIR,
|
|
87
|
+
type: 'command',
|
|
88
|
+
command: `node ${path.join(HOOKS_DIR, 'user-prompt-submit.js')}`,
|
|
87
89
|
timeout: 5,
|
|
88
90
|
},
|
|
89
91
|
],
|
|
90
92
|
});
|
|
91
93
|
|
|
92
|
-
upsertHook(
|
|
94
|
+
upsertHook('SessionEnd', {
|
|
93
95
|
hooks: [
|
|
94
96
|
{
|
|
95
|
-
type:
|
|
96
|
-
command: `node ${path.join(HOOKS_DIR,
|
|
97
|
+
type: 'command',
|
|
98
|
+
command: `node ${path.join(HOOKS_DIR, 'session-end.js')}`,
|
|
97
99
|
timeout: 30,
|
|
98
100
|
},
|
|
99
101
|
],
|
|
100
102
|
});
|
|
101
103
|
|
|
102
|
-
upsertHook(
|
|
104
|
+
upsertHook('PreCompact', {
|
|
105
|
+
hooks: [
|
|
106
|
+
{
|
|
107
|
+
type: 'command',
|
|
108
|
+
command: `node ${path.join(HOOKS_DIR, 'pre-compact.js')}`,
|
|
109
|
+
timeout: 5,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
upsertHook('PostToolUseFailure', {
|
|
115
|
+
matcher: '',
|
|
103
116
|
hooks: [
|
|
104
117
|
{
|
|
105
|
-
type:
|
|
106
|
-
command: `node ${path.join(HOOKS_DIR,
|
|
118
|
+
type: 'command',
|
|
119
|
+
command: `node ${path.join(HOOKS_DIR, 'post-tool-use-failure.js')}`,
|
|
120
|
+
timeout: 5,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
upsertHook('SubagentStart', {
|
|
126
|
+
hooks: [
|
|
127
|
+
{
|
|
128
|
+
type: 'command',
|
|
129
|
+
command: `node ${path.join(HOOKS_DIR, 'subagent-start.js')}`,
|
|
130
|
+
timeout: 5,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
upsertHook('Stop', {
|
|
136
|
+
hooks: [
|
|
137
|
+
{
|
|
138
|
+
type: 'command',
|
|
139
|
+
command: `node ${path.join(HOOKS_DIR, 'stop.js')}`,
|
|
107
140
|
timeout: 5,
|
|
108
141
|
},
|
|
109
142
|
],
|
|
@@ -115,49 +148,46 @@ export function installHooks() {
|
|
|
115
148
|
} catch {}
|
|
116
149
|
|
|
117
150
|
const existing = settings.statusLine;
|
|
118
|
-
const existingCmd =
|
|
119
|
-
|
|
120
|
-
const alreadyOurs =
|
|
121
|
-
existingCmd === STATUSLINE_PATH || existingCmd === WRAPPER_PATH;
|
|
151
|
+
const existingCmd = typeof existing === 'string' ? existing : (existing?.command ?? null);
|
|
152
|
+
const alreadyOurs = existingCmd === STATUSLINE_PATH || existingCmd === WRAPPER_PATH;
|
|
122
153
|
|
|
123
154
|
if (!alreadyOurs && existingCmd) {
|
|
124
155
|
fs.writeFileSync(
|
|
125
156
|
WRAPPER_PATH,
|
|
126
157
|
[
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
'#!/usr/bin/env bash',
|
|
159
|
+
'SESSION_JSON=$(cat)',
|
|
129
160
|
`echo "$SESSION_JSON" | ${existingCmd} 2>/dev/null || true`,
|
|
130
161
|
`echo "$SESSION_JSON" | bash ${STATUSLINE_PATH}`,
|
|
131
|
-
].join(
|
|
162
|
+
].join('\n') + '\n'
|
|
132
163
|
);
|
|
133
164
|
fs.chmodSync(WRAPPER_PATH, 0o755);
|
|
134
165
|
settings.statusLine = {
|
|
135
|
-
type:
|
|
166
|
+
type: 'command',
|
|
136
167
|
command: WRAPPER_PATH,
|
|
137
168
|
padding: 1,
|
|
138
169
|
};
|
|
139
|
-
console.log(
|
|
140
|
-
" ✓ statusLine → wrapped your existing statusline + tokengolf HUD",
|
|
141
|
-
);
|
|
170
|
+
console.log(' ✓ statusLine → wrapped your existing statusline + tokengolf HUD');
|
|
142
171
|
} else if (!alreadyOurs) {
|
|
143
172
|
settings.statusLine = {
|
|
144
|
-
type:
|
|
173
|
+
type: 'command',
|
|
145
174
|
command: STATUSLINE_PATH,
|
|
146
175
|
padding: 1,
|
|
147
176
|
};
|
|
148
|
-
console.log(
|
|
177
|
+
console.log(' ✓ statusLine → live HUD in every Claude session');
|
|
149
178
|
} else {
|
|
150
|
-
console.log(
|
|
179
|
+
console.log(' ✓ statusLine → already installed');
|
|
151
180
|
}
|
|
152
181
|
|
|
153
182
|
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
|
154
183
|
|
|
155
|
-
console.log(
|
|
156
|
-
console.log(
|
|
157
|
-
console.log(
|
|
158
|
-
console.log(
|
|
159
|
-
console.log(
|
|
160
|
-
|
|
161
|
-
);
|
|
162
|
-
console.log(
|
|
184
|
+
console.log(' ✓ SessionStart → injects run context into Claude');
|
|
185
|
+
console.log(' ✓ PostToolUse → tracks tool calls + 80% budget warning');
|
|
186
|
+
console.log(' ✓ UserPromptSubmit → counts prompts + 50% nudge');
|
|
187
|
+
console.log(' ✓ SessionEnd → auto-displays scorecard on /exit');
|
|
188
|
+
console.log(' ✓ PreCompact → tracks compaction events for gear achievements');
|
|
189
|
+
console.log(' ✓ PostToolUseFailure → tracks failed tool calls');
|
|
190
|
+
console.log(' ✓ SubagentStart → tracks subagent spawns');
|
|
191
|
+
console.log(' ✓ Stop → tracks turn count');
|
|
192
|
+
console.log('\n ✅ Done! Start a run: tokengolf start\n');
|
|
163
193
|
}
|