zopkit-bug-resolution-mcp 1.7.0 → 1.8.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 CHANGED
@@ -189,3 +189,81 @@ Four more, independent of the fetch/resolve flow above:
189
189
  isocode`) to the company-admin panel — real project names, no full
190
190
  filesystem paths or usernames, even though the local scan reads the full
191
191
  path off the transcript.
192
+
193
+ ---
194
+
195
+ ## Usage reporting (v1.8.0)
196
+
197
+ Three reporters ship alongside the MCP server. They are plain commands
198
+ Claude Code spawns — a status line and two hooks — not MCP tools, because
199
+ what they report has to be captured at moments no tool call happens at.
200
+
201
+ ```bash
202
+ npx zopkit-bug-resolution-mcp --init-usage-reporting --api-key bugres_…
203
+ # then restart Claude Code
204
+ ```
205
+
206
+ That writes `~/.claude/.zopkit-usage.json` (mode 0600 — it holds your API
207
+ key) and adds a `statusLine` plus two hooks to `~/.claude/settings.json`.
208
+ It backs the file up first, never overwrites an existing `statusLine`, and
209
+ is idempotent.
210
+
211
+ ### What each one does
212
+
213
+ | | fires | reports |
214
+ |---|---|---|
215
+ | `bin/zopkit-statusline.js` | every assistant message (300ms debounce), `/compact`, session start/resume, rate-limit reset | per-session context occupancy, the account's 5h/7d limit percentages, session cost |
216
+ | `bin/zopkit-task-marker.js` | `PostToolUse` on `fetch_bug_context` / `submit_bug_resolution` | the session's cumulative tokens at each end of a task window |
217
+ | `bin/zopkit-session-end.js` | `SessionEnd` | that the session is over |
218
+
219
+ The status line is the interesting one. Claude Code hands it a JSON payload
220
+ containing `rate_limits.seven_day.used_percentage` — the number `/usage`
221
+ shows, which nothing server-side can fetch on your behalf. A status-line
222
+ script isn't a third party; it runs inside your own session. So that figure
223
+ no longer has to be typed in by a human (`report_claude_usage`, now legacy).
224
+
225
+ Two rules the status-line script follows, both load-bearing:
226
+
227
+ 1. **It renders before it touches the network.** Claude Code cancels an
228
+ in-flight status-line script when the next update arrives, so a slow
229
+ fetch ahead of the first write would blank the bar.
230
+ 2. **It whitelists fields, never forwards the payload.** The payload
231
+ carries more than this feature needs. `session_name` — your session's
232
+ AI-generated title, i.e. a description of what you're working on — is
233
+ sent **only** with `--share-session-name`, because it lands on a panel
234
+ every staff member with the `claude-usage` section can read.
235
+
236
+ ### Checking your Claude Code is new enough
237
+
238
+ These fields arrived across several releases, so "is my client new enough"
239
+ has a different answer per field:
240
+
241
+ ```bash
242
+ # capture one payload
243
+ cd /tmp && claude --settings '{"statusLine":{"type":"command","command":"tee /tmp/sl.json"}}'
244
+ # …let it render once, exit, then:
245
+ npx zopkit-bug-resolution-mcp --check-statusline < /tmp/sl.json
246
+ ```
247
+
248
+ It prints which fields are present and what goes empty without each.
249
+
250
+ ### Backfilling past bugs
251
+
252
+ The task markers are already in every transcript you've ever written, so
253
+ bugs you fixed before installing the hook can be attributed retroactively:
254
+
255
+ ```bash
256
+ npx zopkit-bug-resolution-mcp --backfill-tasks --dry-run
257
+ npx zopkit-bug-resolution-mcp --backfill-tasks
258
+ ```
259
+
260
+ Reconstruction is weaker than live markers and tags every window it writes
261
+ `backfill`, which the panel renders differently. Known imprecisions: a
262
+ window includes anything else you did inside it; an un-submitted window is
263
+ closed at the first 30-minute gap (without that, one ran 738 minutes and
264
+ swallowed its whole session); and only transcripts on this machine exist.
265
+
266
+ ### Turning it off
267
+
268
+ Delete `~/.claude/.zopkit-usage.json` — every reporter no-ops without it —
269
+ or remove the `statusLine` / `hooks` entries from `~/.claude/settings.json`.
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ // One-shot historical backfill of per-task token attribution.
3
+ //
4
+ // The PostToolUse marker hook only knows about tasks worked AFTER it is
5
+ // installed. But the markers it relies on — fetch_bug_context and
6
+ // submit_bug_resolution tool calls — are already sitting in every
7
+ // transcript ever written, so the same windows can be reconstructed
8
+ // retroactively.
9
+ //
10
+ // Reconstruction is strictly weaker than live markers, and says so: every
11
+ // window it writes is tagged attributionSource 'backfill', which the panel
12
+ // renders differently from a measured one. Three known imprecisions:
13
+ //
14
+ // - A window includes everything the developer did inside it. If they
15
+ // wandered off to an unrelated file, it is still charged to the bug.
16
+ // - An un-submitted window has no natural end, so it is closed at the
17
+ // first gap longer than IDLE_GAP_MS. Without that, one historical
18
+ // window ran 738 minutes and swallowed the rest of the session.
19
+ // - Only transcripts on THIS machine are visible. Work done elsewhere, or
20
+ // through the panel rather than the MCP tool, is simply absent — which
21
+ // is why coverage is reported at the end rather than left to assume.
22
+ //
23
+ // Usage:
24
+ // npx zopkit-bug-resolution-mcp --backfill-tasks [--days 90] [--dry-run]
25
+
26
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
27
+ import { createInterface } from 'node:readline';
28
+ import { join, basename } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+ import { loadConfig, post } from '../lib/zopkit-config.js';
31
+
32
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
33
+ const START_TOOL = 'mcp__bug-resolution__fetch_bug_context';
34
+ const END_TOOL = 'mcp__bug-resolution__submit_bug_resolution';
35
+ const SYNTHETIC_MODEL = '<synthetic>';
36
+
37
+ // An un-submitted window ends at the first gap longer than this. Picked to
38
+ // sit above a long single turn (a big refactor, a slow test run) and below
39
+ // a meal.
40
+ const IDLE_GAP_MS = 30 * 60 * 1000;
41
+
42
+ function emptyTotals() {
43
+ return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
44
+ }
45
+
46
+ function addUsage(byModel, model, usage) {
47
+ const t = (byModel[model] ??= emptyTotals());
48
+ t.inputTokens += usage.input_tokens || 0;
49
+ t.outputTokens += usage.output_tokens || 0;
50
+ t.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
51
+ t.cacheReadTokens += usage.cache_read_input_tokens || 0;
52
+ }
53
+
54
+ async function readEntries(filePath) {
55
+ const out = [];
56
+ let stream;
57
+ try {
58
+ stream = createReadStream(filePath, { encoding: 'utf8' });
59
+ } catch {
60
+ return out;
61
+ }
62
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
63
+ try {
64
+ for await (const line of rl) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ out.push(JSON.parse(line));
68
+ } catch {
69
+ // partial final line
70
+ }
71
+ }
72
+ } finally {
73
+ rl.close();
74
+ stream.destroy();
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function toolUses(entry) {
80
+ const c = entry?.message?.content;
81
+ return Array.isArray(c) ? c.filter((b) => b?.type === 'tool_use') : [];
82
+ }
83
+
84
+ /** Reconstruct every task window in one session transcript. */
85
+ async function windowsForSession(filePath, sinceMs) {
86
+ const entries = await readEntries(filePath);
87
+ if (entries.length === 0) return [];
88
+
89
+ const sessionId = basename(filePath, '.jsonl');
90
+ const windows = [];
91
+ let current = null;
92
+ let seen = new Set();
93
+ let lastTs = null;
94
+
95
+ const close = () => {
96
+ if (current && current.turns > 0) windows.push(current);
97
+ current = null;
98
+ seen = new Set();
99
+ };
100
+
101
+ const sorted = entries
102
+ .filter((e) => e.timestamp)
103
+ .sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
104
+
105
+ for (const entry of sorted) {
106
+ const ts = entry.timestamp;
107
+
108
+ if (entry.type === 'system' && entry.subtype === 'turn_duration' && current) {
109
+ current.activeMs = (current.activeMs ?? 0) + (entry.durationMs || 0);
110
+ continue;
111
+ }
112
+ if (entry.type !== 'assistant' || !entry.message) continue;
113
+
114
+ // An un-submitted window stops at the first long gap, rather than
115
+ // running to the end of the session.
116
+ if (current && !current.closed && lastTs && Date.parse(ts) - Date.parse(lastTs) > IDLE_GAP_MS) {
117
+ close();
118
+ }
119
+ lastTs = ts;
120
+
121
+ // Marker detection runs on every line, duplicates included — setting a
122
+ // boundary twice is harmless, unlike double-counting tokens.
123
+ for (const block of toolUses(entry)) {
124
+ if (block.name === START_TOOL && block.input?.bugReportId) {
125
+ close();
126
+ current = {
127
+ bugReportId: block.input.bugReportId,
128
+ sessionId,
129
+ startedAt: ts,
130
+ endedAt: ts,
131
+ byModel: {},
132
+ turns: 0,
133
+ subagentTurns: 0,
134
+ activeMs: null,
135
+ closed: false,
136
+ };
137
+ } else if (block.name === END_TOOL && current && block.input?.bugReportId === current.bugReportId) {
138
+ current.closed = true;
139
+ }
140
+ }
141
+
142
+ if (!current) continue;
143
+
144
+ const messageId = entry.message.id;
145
+ if (messageId) {
146
+ if (seen.has(messageId)) continue;
147
+ seen.add(messageId);
148
+ }
149
+ const usage = entry.message.usage;
150
+ if (!usage) continue;
151
+ const model = entry.message.model || 'unknown';
152
+ if (model === SYNTHETIC_MODEL) continue;
153
+
154
+ addUsage(current.byModel, model, usage);
155
+ current.turns += 1;
156
+ current.endedAt = ts;
157
+
158
+ if (current.closed) close();
159
+ }
160
+ close();
161
+
162
+ // Fold in subagent spend that falls inside a window. Subagent transcripts
163
+ // carry the parent sessionId, so a timestamp range is enough to attribute
164
+ // them — and omitting them undercounts a window that fanned out.
165
+ const subDir = join(filePath.replace(/\.jsonl$/, ''), 'subagents');
166
+ let subFiles = [];
167
+ try {
168
+ subFiles = readdirSync(subDir, { withFileTypes: true })
169
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
170
+ .map((e) => join(subDir, e.name));
171
+ } catch {
172
+ // none
173
+ }
174
+ for (const sub of subFiles) {
175
+ const subEntries = await readEntries(sub);
176
+ for (const w of windows) {
177
+ const subSeen = new Set();
178
+ for (const entry of subEntries) {
179
+ if (entry.type !== 'assistant' || !entry.message || !entry.timestamp) continue;
180
+ if (entry.timestamp < w.startedAt || entry.timestamp > w.endedAt) continue;
181
+ const id = entry.message.id;
182
+ if (id) {
183
+ if (subSeen.has(id)) continue;
184
+ subSeen.add(id);
185
+ }
186
+ const usage = entry.message.usage;
187
+ if (!usage) continue;
188
+ const model = entry.message.model || 'unknown';
189
+ if (model === SYNTHETIC_MODEL) continue;
190
+ addUsage(w.byModel, model, usage);
191
+ w.subagentTurns += 1;
192
+ }
193
+ }
194
+ }
195
+
196
+ return windows.filter((w) => Date.parse(w.endedAt) >= sinceMs);
197
+ }
198
+
199
+ function listSessionTranscripts() {
200
+ const files = [];
201
+ let dirs;
202
+ try {
203
+ dirs = readdirSync(PROJECTS_DIR, { withFileTypes: true });
204
+ } catch {
205
+ return files;
206
+ }
207
+ for (const d of dirs) {
208
+ if (!d.isDirectory()) continue;
209
+ const dirPath = join(PROJECTS_DIR, d.name);
210
+ let entries;
211
+ try {
212
+ entries = readdirSync(dirPath, { withFileTypes: true });
213
+ } catch {
214
+ continue;
215
+ }
216
+ for (const e of entries) {
217
+ if (e.isFile() && e.name.endsWith('.jsonl')) files.push(join(dirPath, e.name));
218
+ }
219
+ }
220
+ return files;
221
+ }
222
+
223
+ function fmt(n) {
224
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
225
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
226
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
227
+ return String(n);
228
+ }
229
+
230
+ export async function runBackfill({ days = 90, dryRun = false } = {}) {
231
+ const cfg = loadConfig();
232
+ if (!cfg && !dryRun) {
233
+ console.error('No API key — run `npx zopkit-bug-resolution-mcp --init-usage-reporting` first.');
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+
238
+ const sinceMs = Date.now() - days * 24 * 60 * 60 * 1000;
239
+ const transcripts = listSessionTranscripts();
240
+ console.error(`Scanning ${transcripts.length} transcripts for task markers (last ${days} days)…`);
241
+
242
+ const all = [];
243
+ for (const file of transcripts) {
244
+ try {
245
+ // Cheap pre-filter: most transcripts never touched a bug tool.
246
+ if (statSync(file).size === 0) continue;
247
+ const found = await windowsForSession(file, sinceMs);
248
+ all.push(...found);
249
+ } catch {
250
+ // unreadable transcript — skip, don't abort the run
251
+ }
252
+ }
253
+
254
+ if (all.length === 0) {
255
+ console.error('No task windows found. Nothing to backfill.');
256
+ return;
257
+ }
258
+
259
+ const bugs = new Set(all.map((w) => w.bugReportId));
260
+ console.error(`\n${all.length} windows across ${bugs.size} bugs:\n`);
261
+ for (const w of all.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1)).slice(0, 20)) {
262
+ const newTokens = Object.values(w.byModel).reduce(
263
+ (s, t) => s + t.inputTokens + t.outputTokens + t.cacheCreationTokens,
264
+ 0
265
+ );
266
+ const cacheRead = Object.values(w.byModel).reduce((s, t) => s + t.cacheReadTokens, 0);
267
+ console.error(
268
+ ` ${w.bugReportId.slice(0, 8)} ${fmt(newTokens).padStart(8)} new ${fmt(cacheRead).padStart(8)} cr ` +
269
+ `${String(w.turns).padStart(3)} turns ${w.closed ? 'closed' : 'open '} ` +
270
+ `${w.activeMs === null ? ' —' : `${Math.round(w.activeMs / 60000)}m`}`
271
+ );
272
+ }
273
+ if (all.length > 20) console.error(` … and ${all.length - 20} more`);
274
+
275
+ if (dryRun) {
276
+ console.error('\n--dry-run: nothing posted.');
277
+ return;
278
+ }
279
+
280
+ let ok = 0;
281
+ let failed = 0;
282
+ for (const w of all) {
283
+ const res = await post('/api/admin/bug-reports/task-window', {
284
+ bugReportId: w.bugReportId,
285
+ sessionId: w.sessionId,
286
+ startedAt: w.startedAt,
287
+ // An un-submitted window has no real end; recording the last turn as
288
+ // one would claim the task finished there. It did not.
289
+ endedAt: w.closed ? w.endedAt : null,
290
+ activeMs: w.activeMs,
291
+ turns: w.turns,
292
+ subagentTurns: w.subagentTurns,
293
+ byModel: w.byModel,
294
+ attributionSource: 'backfill',
295
+ });
296
+ if (res?.success) ok += 1;
297
+ else failed += 1;
298
+ }
299
+
300
+ console.error(`\nPosted ${ok} windows${failed ? `, ${failed} failed` : ''}.`);
301
+ // Coverage, stated rather than assumed: a roll-up that silently omits
302
+ // most bugs reads as "those fixes were cheap".
303
+ console.error(
304
+ `Coverage: ${bugs.size} bugs have attribution from this machine. Bugs worked through the ` +
305
+ 'panel, on another machine, or without fetch_bug_context remain unattributed by design.'
306
+ );
307
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // SessionEnd hook — drop a finished session off the Claude Usage panel at
3
+ // once, instead of letting it age out of the live window.
4
+ //
5
+ // Best-effort by nature: a crashed terminal, a killed tmux pane or a closed
6
+ // laptop never fire SessionEnd. That is exactly why the server derives
7
+ // "live" from lastSeenAt rather than storing a flag — this hook makes the
8
+ // common case immediate, it is not what makes it correct.
9
+ //
10
+ // Wire-up (settings.json):
11
+ // "SessionEnd": [{ "hooks": [{ "type": "command", "command": "<this script>" }] }]
12
+
13
+ import { loadConfig, post, readStdinJson } from '../lib/zopkit-config.js';
14
+
15
+ async function main() {
16
+ const payload = await readStdinJson();
17
+ if (!payload?.session_id) return;
18
+ if (!loadConfig()) return;
19
+
20
+ await post(
21
+ '/api/admin/claude-usage/session-end',
22
+ { sessionId: payload.session_id, reason: payload.reason },
23
+ // SessionEnd hooks share a 1.5s budget by default, so this gets a
24
+ // tighter timeout than the rest: better to leave the row to age out
25
+ // than to hold up the editor's shutdown.
26
+ { timeoutMs: 1200 }
27
+ );
28
+ }
29
+
30
+ main().catch(() => {});
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // Status line + real-time usage heartbeat.
3
+ //
4
+ // Claude Code hands a statusLine command a JSON payload on stdin that
5
+ // already contains everything the Claude Usage panel was guessing at:
6
+ //
7
+ // context_window.{total_input_tokens,context_window_size,used_percentage}
8
+ // rate_limits.five_hour.used_percentage <- the /usage number
9
+ // rate_limits.seven_day.used_percentage <- ditto, the weekly one
10
+ // rate_limits.spend_limit.{used_usd,limit_usd}
11
+ // cost.total_cost_usd, model, session_id, workspace.repo, version, …
12
+ //
13
+ // That is the figure report_claude_usage was asking a human to read off
14
+ // `/usage` and type in. It is not retrievable by a third party — but a
15
+ // status-line script runs INSIDE the developer's own session, so it is not
16
+ // a third party. Nothing reported here is self-attested.
17
+ //
18
+ // It re-runs on every new assistant message, on /compact, on session
19
+ // start/resume, when a rate-limit window reaches its resets_at, and on an
20
+ // optional refreshInterval timer, debounced at 300ms. One clock per
21
+ // session, ticking independently — which is what makes concurrent sessions
22
+ // work, where the old single-row-per-developer design could not.
23
+ //
24
+ // Install: npx zopkit-bug-resolution-mcp --init-usage-reporting
25
+ //
26
+ // TWO RULES govern everything below:
27
+ //
28
+ // 1. RENDER BEFORE THE NETWORK. Claude Code cancels an in-flight status
29
+ // line script when the next update arrives. A slow fetch before the
30
+ // first write would blank the bar.
31
+ // 2. WHITELIST, NEVER FORWARD. The payload carries more than this feature
32
+ // needs (session titles, prompt-cache miss causes). Only the named
33
+ // fields below are sent, and the session title only on explicit opt-in.
34
+
35
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { homedir, tmpdir } from 'node:os';
38
+ import { loadConfig, post, readStdinJson } from '../lib/zopkit-config.js';
39
+
40
+ // Per-session throttle. The status line fires on every assistant message; a
41
+ // 40-turn session would otherwise be 40 POSTs. 15s keeps the panel's 10s
42
+ // poll meaningfully fresh without that.
43
+ const THROTTLE_MS = 15_000;
44
+
45
+ const STATE_DIR = join(tmpdir(), 'zopkit-usage');
46
+
47
+ function statePath(sessionId) {
48
+ return join(STATE_DIR, `${String(sessionId).replace(/[^\w.-]/g, '_')}.json`);
49
+ }
50
+
51
+ function readState(sessionId) {
52
+ try {
53
+ return JSON.parse(readFileSync(statePath(sessionId), 'utf8'));
54
+ } catch {
55
+ return { lastSentAt: 0, lastContextTokens: null };
56
+ }
57
+ }
58
+
59
+ function writeState(sessionId, state) {
60
+ try {
61
+ mkdirSync(STATE_DIR, { recursive: true });
62
+ writeFileSync(statePath(sessionId), JSON.stringify(state));
63
+ } catch {
64
+ // A read-only tmpdir just means no throttling — still correct, only
65
+ // chattier. Never a reason to fail the status line.
66
+ }
67
+ }
68
+
69
+ function pct(n) {
70
+ return typeof n === 'number' && Number.isFinite(n) ? Math.floor(n) : null;
71
+ }
72
+
73
+ /** The line the developer actually sees. Kept deliberately plain — anyone
74
+ * who wants branch/git decoration can wrap this script. */
75
+ function render(p) {
76
+ const parts = [];
77
+ if (p.model?.display_name) parts.push(p.model.display_name);
78
+ const ctx = pct(p.context_window?.used_percentage);
79
+ if (ctx !== null) parts.push(`${ctx}% ctx`);
80
+ const week = pct(p.rate_limits?.seven_day?.used_percentage);
81
+ if (week !== null) parts.push(`${week}% wk`);
82
+ const fiveHour = pct(p.rate_limits?.five_hour?.used_percentage);
83
+ // Only worth the space once it is the binding constraint — the 5-hour
84
+ // window is what actually blocks you mid-afternoon.
85
+ if (fiveHour !== null && fiveHour >= 50) parts.push(`${fiveHour}% 5h`);
86
+ if (p.workspace?.git_worktree) parts.push(p.workspace.git_worktree);
87
+ return parts.join(' · ');
88
+ }
89
+
90
+ /** Only the named fields, never the raw payload. */
91
+ function buildHeartbeat(p, cfg, fresh) {
92
+ return {
93
+ sessionId: p.session_id,
94
+ sessionName: cfg.shareSessionName ? (p.session_name ?? null) : null,
95
+ model: p.model?.id ?? null,
96
+ version: p.version ?? null,
97
+
98
+ contextTokens: p.context_window?.total_input_tokens ?? null,
99
+ contextLimit: p.context_window?.context_window_size ?? null,
100
+ contextPercent: p.context_window?.used_percentage ?? null,
101
+ exceeds200k: p.exceeds_200k_tokens ?? null,
102
+
103
+ cwd: p.workspace?.current_dir ?? p.cwd ?? null,
104
+ repo: p.workspace?.repo?.name ?? null,
105
+ gitBranch: p.workspace?.git_worktree ?? null,
106
+
107
+ costUsd: p.cost?.total_cost_usd ?? null,
108
+ linesAdded: p.cost?.total_lines_added ?? null,
109
+ linesRemoved: p.cost?.total_lines_removed ?? null,
110
+ effort: p.effort?.level ?? null,
111
+ fastMode: p.fast_mode ?? null,
112
+
113
+ rateLimits: p.rate_limits ?? null,
114
+
115
+ // Whether a NEW API response landed since the last heartbeat for this
116
+ // session, detected by the context token count moving. Only a fresh
117
+ // reading may advance the account-level percentages: an idle session's
118
+ // status line still re-renders on a timer carrying `rate_limits` from
119
+ // its last API response, possibly an hour stale, and would otherwise
120
+ // walk a live session's number backwards.
121
+ fresh,
122
+ };
123
+ }
124
+
125
+ async function main() {
126
+ const payload = await readStdinJson();
127
+ if (!payload) return;
128
+
129
+ // RULE 1: the bar is written before anything can block.
130
+ process.stdout.write(render(payload));
131
+
132
+ const cfg = loadConfig();
133
+ if (!cfg || !payload.session_id) return;
134
+
135
+ const state = readState(payload.session_id);
136
+ const now = Date.now();
137
+ const contextTokens = payload.context_window?.total_input_tokens ?? null;
138
+ const fresh = contextTokens !== null && contextTokens !== state.lastContextTokens;
139
+
140
+ // Throttle, but never let a fresh reading wait: a developer watching
141
+ // their weekly percentage climb should see it move on the panel, not 15
142
+ // seconds later.
143
+ if (!fresh && now - (state.lastSentAt || 0) < THROTTLE_MS) return;
144
+
145
+ writeState(payload.session_id, { lastSentAt: now, lastContextTokens: contextTokens });
146
+ await post('/api/admin/claude-usage/heartbeat', buildHeartbeat(payload, cfg, fresh));
147
+ }
148
+
149
+ // Any unexpected throw must still leave the bar rendered and the editor
150
+ // unbothered.
151
+ main().catch(() => {});
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // Per-task token attribution — the PostToolUse hook.
3
+ //
4
+ // The two bug-resolution MCP tools already bracket a task precisely:
5
+ //
6
+ // fetch_bug_context "calling this IS starting work on it"
7
+ // (open / failed_testing -> in_progress)
8
+ // submit_bug_resolution ends it (-> needs_testing)
9
+ //
10
+ // So a PostToolUse hook matched on those two names knows exactly when a
11
+ // task starts and stops, with the session id, the bug id (in tool_input)
12
+ // and the transcript path all handed to it on stdin. It reads the session's
13
+ // cumulative token totals at each boundary and posts them; the SERVER
14
+ // subtracts. The delta is what the bug actually cost, per model.
15
+ //
16
+ // Why the server does the subtraction rather than this hook: a locally
17
+ // cached baseline is lost on reboot (the end marker would then have nothing
18
+ // to diff against), and keeping it server-side makes an in-flight window
19
+ // reportable — "being worked right now, 120k tokens in" — which a local
20
+ // diff cannot express until the task closes.
21
+ //
22
+ // Install: npx zopkit-bug-resolution-mcp --init-usage-reporting
23
+ //
24
+ // Wire-up (settings.json):
25
+ // "PostToolUse": [{
26
+ // "matcher": "mcp__bug-resolution__(fetch_bug_context|submit_bug_resolution)",
27
+ // "hooks": [{ "type": "command", "command": "<this script>" }]
28
+ // }]
29
+
30
+ import { loadConfig, post, readStdinJson } from '../lib/zopkit-config.js';
31
+ import { scanSessionCumulative, EMPTY_CUMULATIVE } from '../lib/session-scan.js';
32
+
33
+ const START_TOOL = 'fetch_bug_context';
34
+ const END_TOOL = 'submit_bug_resolution';
35
+
36
+ /** Which end of a window this tool call represents, or null if the hook
37
+ * fired for something else (a broader matcher than the documented one). */
38
+ function phaseFor(toolName) {
39
+ if (!toolName) return null;
40
+ if (toolName.endsWith(START_TOOL)) return 'start';
41
+ if (toolName.endsWith(END_TOOL)) return 'end';
42
+ return null;
43
+ }
44
+
45
+ async function main() {
46
+ const payload = await readStdinJson();
47
+ if (!payload) return;
48
+
49
+ const cfg = loadConfig();
50
+ if (!cfg) return;
51
+
52
+ const phase = phaseFor(payload.tool_name);
53
+ if (!phase) return;
54
+
55
+ const bugReportId = payload.tool_input?.bugReportId;
56
+ // fetch_assigned_bugs and create_bug_report carry no bugReportId; if a
57
+ // broader matcher routed one here there is simply no task to bracket.
58
+ if (!bugReportId || !payload.session_id) return;
59
+
60
+ // PostToolUse fires AFTER the tool succeeded, so by now the transcript
61
+ // already contains this turn — the reading includes the work up to and
62
+ // including the marker itself, consistently at both ends, which is what
63
+ // makes the subtraction clean.
64
+ const cumulative = payload.transcript_path
65
+ ? await scanSessionCumulative(payload.transcript_path).catch(() => EMPTY_CUMULATIVE)
66
+ : EMPTY_CUMULATIVE;
67
+
68
+ await post('/api/admin/bug-reports/task-marker', {
69
+ bugReportId,
70
+ sessionId: payload.session_id,
71
+ phase,
72
+ // The client's clock, not the server's: a hook can be retried or
73
+ // queued, and a window's boundaries must match the transcript they
74
+ // were read from.
75
+ observedAt: new Date().toISOString(),
76
+ byModel: cumulative.byModel,
77
+ turns: cumulative.turns,
78
+ subagentTurns: cumulative.subagentTurns,
79
+ activeMs: cumulative.activeMs,
80
+ });
81
+ }
82
+
83
+ // A hook that throws surfaces an error to the developer about telemetry
84
+ // they did not ask to think about. Never that.
85
+ main().catch(() => {});
package/index.js CHANGED
@@ -44,6 +44,45 @@ import { createInterface } from 'node:readline';
44
44
  import { join, basename } from 'node:path';
45
45
  import { homedir } from 'node:os';
46
46
 
47
+ // ── CLI subcommands ───────────────────────────────────────────────────────
48
+ // Handled BEFORE the server is constructed (and before the API-key guard
49
+ // below), since --init-usage-reporting exists precisely to configure a
50
+ // machine that has no key yet. Without a subcommand this falls straight
51
+ // through to the stdio MCP server, which is the normal path.
52
+ const argv = process.argv.slice(2);
53
+ function flag(name) {
54
+ const i = argv.indexOf(`--${name}`);
55
+ return i === -1 ? undefined : (argv[i + 1]?.startsWith('--') ? true : argv[i + 1] ?? true);
56
+ }
57
+
58
+ if (argv.includes('--init-usage-reporting')) {
59
+ const { initUsageReporting } = await import('./lib/init-usage-reporting.js');
60
+ process.exit(
61
+ initUsageReporting({
62
+ apiKey: typeof flag('api-key') === 'string' ? flag('api-key') : undefined,
63
+ baseUrl: typeof flag('base-url') === 'string' ? flag('base-url') : undefined,
64
+ shareSessionName: argv.includes('--share-session-name'),
65
+ })
66
+ );
67
+ }
68
+
69
+ if (argv.includes('--check-statusline')) {
70
+ const { checkStatusline } = await import('./lib/init-usage-reporting.js');
71
+ const chunks = [];
72
+ for await (const chunk of process.stdin) chunks.push(chunk);
73
+ process.exit(await checkStatusline(Buffer.concat(chunks).toString('utf8')));
74
+ }
75
+
76
+ if (argv.includes('--backfill-tasks')) {
77
+ const { runBackfill } = await import('./bin/zopkit-backfill-tasks.js');
78
+ const days = Number(flag('days'));
79
+ await runBackfill({
80
+ days: Number.isFinite(days) && days > 0 ? days : 90,
81
+ dryRun: argv.includes('--dry-run'),
82
+ });
83
+ process.exit(process.exitCode ?? 0);
84
+ }
85
+
47
86
  const API_KEY = process.env.BUG_REPORT_API_KEY;
48
87
  const BASE_URL = (process.env.BUG_REPORT_API_BASE_URL || 'https://api.zopkit.com').replace(/\/$/, '');
49
88
 
@@ -52,7 +91,7 @@ if (!API_KEY) {
52
91
  'Generate one from the company-admin panel (Bug Reports → API Keys) and set it when registering this server.');
53
92
  }
54
93
 
55
- const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.7.0' });
94
+ const server = new McpServer({ name: 'zopkit-bug-resolution', version: '1.8.0' });
56
95
 
57
96
  const BUG_SEVERITIES = ['low', 'medium', 'high', 'critical'];
58
97
  // Keep in sync with BUG_STATUSES in backend/src/db/schema/platform/bug-reports.ts.
@@ -0,0 +1,197 @@
1
+ // `--init-usage-reporting` — wire the status line and the two hooks into
2
+ // Claude Code, and write the credentials they need.
3
+ //
4
+ // Done as an explicit opt-in command rather than silently on install: this
5
+ // edits the developer's own ~/.claude/settings.json and turns on reporting
6
+ // to a shared team panel. Both deserve a deliberate action.
7
+ //
8
+ // It is conservative about what it touches:
9
+ // - never overwrites an existing statusLine (prints what to do instead),
10
+ // - appends its hooks rather than replacing the arrays,
11
+ // - is idempotent: running it twice changes nothing the second time,
12
+ // - backs the file up before writing.
13
+
14
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
15
+ import { join, dirname } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { CONFIG_PATH } from './zopkit-config.js';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ const BIN = join(HERE, '..', 'bin');
22
+
23
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
24
+
25
+ const STATUSLINE_CMD = `node ${join(BIN, 'zopkit-statusline.js')}`;
26
+ const MARKER_CMD = `node ${join(BIN, 'zopkit-task-marker.js')}`;
27
+ const SESSION_END_CMD = `node ${join(BIN, 'zopkit-session-end.js')}`;
28
+
29
+ // The matcher the task marker relies on. Narrow on purpose — a broader one
30
+ // would spawn a node process on every tool call in the session.
31
+ const MARKER_MATCHER = 'mcp__bug-resolution__(fetch_bug_context|submit_bug_resolution)';
32
+
33
+ function readJson(path, fallback) {
34
+ try {
35
+ return JSON.parse(readFileSync(path, 'utf8'));
36
+ } catch {
37
+ return fallback;
38
+ }
39
+ }
40
+
41
+ /** Already present? Compare on the command string, so re-running after an
42
+ * upgrade (same path) is a no-op rather than a duplicate. */
43
+ function hasHook(entries, command) {
44
+ return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
45
+ }
46
+
47
+ export function initUsageReporting({ apiKey, baseUrl, shareSessionName = false } = {}) {
48
+ const log = (...a) => console.error(...a);
49
+
50
+ // ── 1. credentials ──────────────────────────────────────────────────
51
+ const key = apiKey || process.env.BUG_REPORT_API_KEY;
52
+ if (!key) {
53
+ log('No API key.');
54
+ log(' Generate one in the company-admin panel (Bug Reports → API Keys), then:');
55
+ log(' npx zopkit-bug-resolution-mcp --init-usage-reporting --api-key bugres_…');
56
+ log(' …or export BUG_REPORT_API_KEY and re-run.');
57
+ return 1;
58
+ }
59
+
60
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
61
+ const existingCfg = readJson(CONFIG_PATH, {});
62
+ writeFileSync(
63
+ CONFIG_PATH,
64
+ JSON.stringify(
65
+ {
66
+ ...existingCfg,
67
+ apiKey: key,
68
+ baseUrl: baseUrl || existingCfg.baseUrl || 'https://api.zopkit.com',
69
+ // Opt-in: the session title describes what you are working on, and
70
+ // it lands on a panel the whole staff can read.
71
+ shareSessionName: shareSessionName || existingCfg.shareSessionName || false,
72
+ },
73
+ null,
74
+ 2
75
+ ) + '\n',
76
+ // The key is a credential; keep it off other accounts on the machine.
77
+ { mode: 0o600 }
78
+ );
79
+ log(`✓ credentials → ${CONFIG_PATH}`);
80
+
81
+ for (const f of ['zopkit-statusline.js', 'zopkit-task-marker.js', 'zopkit-session-end.js']) {
82
+ try {
83
+ chmodSync(join(BIN, f), 0o755);
84
+ } catch {
85
+ // bundled read-only (npx cache) — `node <path>` still works, and that
86
+ // is the form written into settings.json anyway.
87
+ }
88
+ }
89
+
90
+ // ── 2. settings.json ────────────────────────────────────────────────
91
+ const settings = readJson(SETTINGS_PATH, {});
92
+ if (existsSync(SETTINGS_PATH)) {
93
+ copyFileSync(SETTINGS_PATH, `${SETTINGS_PATH}.zopkit-backup`);
94
+ log(`✓ backed up → ${SETTINGS_PATH}.zopkit-backup`);
95
+ }
96
+
97
+ let changed = false;
98
+
99
+ if (!settings.statusLine) {
100
+ settings.statusLine = { type: 'command', command: STATUSLINE_CMD };
101
+ changed = true;
102
+ log('✓ statusLine configured');
103
+ } else if (settings.statusLine.command === STATUSLINE_CMD) {
104
+ log('· statusLine already configured');
105
+ } else {
106
+ // Never clobber: a custom status line is work the developer did, and
107
+ // theirs is probably nicer than ours.
108
+ log('! statusLine already set to something else — left alone.');
109
+ log(' To keep yours AND report usage, call ours from inside it (it reads stdin and');
110
+ log(' writes one line), or pipe the payload through:');
111
+ log(` ${STATUSLINE_CMD}`);
112
+ }
113
+
114
+ settings.hooks ??= {};
115
+
116
+ settings.hooks.PostToolUse ??= [];
117
+ if (!hasHook(settings.hooks.PostToolUse, MARKER_CMD)) {
118
+ settings.hooks.PostToolUse.push({
119
+ matcher: MARKER_MATCHER,
120
+ hooks: [{ type: 'command', command: MARKER_CMD }],
121
+ });
122
+ changed = true;
123
+ log('✓ PostToolUse task-marker hook added');
124
+ } else {
125
+ log('· task-marker hook already present');
126
+ }
127
+
128
+ settings.hooks.SessionEnd ??= [];
129
+ if (!hasHook(settings.hooks.SessionEnd, SESSION_END_CMD)) {
130
+ settings.hooks.SessionEnd.push({ hooks: [{ type: 'command', command: SESSION_END_CMD }] });
131
+ changed = true;
132
+ log('✓ SessionEnd hook added');
133
+ } else {
134
+ log('· SessionEnd hook already present');
135
+ }
136
+
137
+ if (changed) {
138
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
139
+ log(`✓ wrote ${SETTINGS_PATH}`);
140
+ }
141
+
142
+ log('');
143
+ log('Restart Claude Code for the status line and hooks to take effect.');
144
+ log('');
145
+ log('Then, to attribute bugs you already fixed on this machine:');
146
+ log(' npx zopkit-bug-resolution-mcp --backfill-tasks --dry-run');
147
+ log('');
148
+ log('Note: the account-level percentages come from rate_limits in the status-line');
149
+ log('payload. If your Claude Code predates that field the panel still shows context');
150
+ log('and per-session cost; it just leaves the 5h/7d bars empty. Check with:');
151
+ log(' npx zopkit-bug-resolution-mcp --check-statusline');
152
+ return 0;
153
+ }
154
+
155
+ /**
156
+ * `--check-statusline` — print exactly which of the fields this feature
157
+ * depends on the local Claude Code actually emits.
158
+ *
159
+ * Worth having as a command rather than a docs note: the fields arrived in
160
+ * different releases, so "is my client new enough" has a different answer
161
+ * per field, and guessing wrong looks identical to a broken install.
162
+ */
163
+ export async function checkStatusline(payloadText) {
164
+ let p;
165
+ try {
166
+ p = JSON.parse(payloadText);
167
+ } catch {
168
+ console.error('Pipe a status-line payload in. Easiest way to capture one:');
169
+ console.error('');
170
+ console.error(' cd /tmp && claude --settings \'{"statusLine":{"type":"command",' +
171
+ '"command":"tee /tmp/zopkit-sl.json"}}\'');
172
+ console.error(' # let it render once, exit, then:');
173
+ console.error(' npx zopkit-bug-resolution-mcp --check-statusline < /tmp/zopkit-sl.json');
174
+ return 1;
175
+ }
176
+
177
+ const checks = [
178
+ ['session_id', p.session_id, 'required — identifies the session'],
179
+ ['context_window.used_percentage', p.context_window?.used_percentage, 'the per-session context bar'],
180
+ ['context_window.context_window_size', p.context_window?.context_window_size, 'the bar\'s denominator'],
181
+ ['cost.total_cost_usd', p.cost?.total_cost_usd, 'per-session cost'],
182
+ ['rate_limits.five_hour.used_percentage', p.rate_limits?.five_hour?.used_percentage, 'the 5-hour bar'],
183
+ ['rate_limits.seven_day.used_percentage', p.rate_limits?.seven_day?.used_percentage, 'the weekly % — replaces self-reporting'],
184
+ ['rate_limits.spend_limit.used_usd', p.rate_limits?.spend_limit?.used_usd, 'gateway spend (needs CC >= 2.1.284)'],
185
+ ];
186
+
187
+ console.error(`Claude Code ${p.version ?? '(version not reported)'}\n`);
188
+ let missing = 0;
189
+ for (const [field, value, why] of checks) {
190
+ const present = value !== undefined && value !== null;
191
+ if (!present) missing += 1;
192
+ console.error(` ${present ? '✓' : '✗'} ${field.padEnd(40)} ${present ? '' : `— absent; ${why} will be empty`}`);
193
+ }
194
+ console.error('');
195
+ console.error(missing === 0 ? 'Everything this feature uses is available.' : `${missing} field(s) absent — upgrade Claude Code to fill them in.`);
196
+ return 0;
197
+ }
@@ -0,0 +1,150 @@
1
+ // Cumulative token accounting for ONE Claude Code session.
2
+ //
3
+ // index.js's computeWeeklyTokenBreakdown answers "how much did this
4
+ // developer spend this week", scanning every transcript in a time window.
5
+ // This answers a different question — "how much has THIS session spent so
6
+ // far, in total" — which is what task attribution is built on: the marker
7
+ // hook reads this at each boundary and the server subtracts, so the delta
8
+ // between fetch_bug_context and submit_bug_resolution is the bug's real cost.
9
+ //
10
+ // Deliberately NOT time-windowed. A window boundary inside a session would
11
+ // make the two readings incomparable, and the subtraction is what makes the
12
+ // time filter unnecessary anyway.
13
+
14
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
15
+ import { createInterface } from 'node:readline';
16
+ import { join, dirname, basename } from 'node:path';
17
+
18
+ /** Models Claude Code logs for its own synthetic messages (rate-limit
19
+ * notices, interrupts). They carry a zero usage block and no real spend. */
20
+ const SYNTHETIC_MODEL = '<synthetic>';
21
+
22
+ function emptyTotals() {
23
+ return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
24
+ }
25
+
26
+ function addUsage(byModel, model, usage) {
27
+ const t = (byModel[model] ??= emptyTotals());
28
+ t.inputTokens += usage.input_tokens || 0;
29
+ t.outputTokens += usage.output_tokens || 0;
30
+ t.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
31
+ t.cacheReadTokens += usage.cache_read_input_tokens || 0;
32
+ }
33
+
34
+ /**
35
+ * Subagent transcripts for a session live in a nested directory a flat
36
+ * listing never finds: …/<projectDir>/<sessionId>/subagents/*.jsonl. Their
37
+ * spend is real and belongs to whatever task was running at the time, so a
38
+ * task window that ignored them would undercount — sometimes by a third.
39
+ */
40
+ function subagentFiles(transcriptPath) {
41
+ const dir = join(dirname(transcriptPath), basename(transcriptPath, '.jsonl'), 'subagents');
42
+ try {
43
+ return readdirSync(dir, { withFileTypes: true })
44
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
45
+ .map((e) => join(dir, e.name));
46
+ } catch {
47
+ return []; // no subagents for this session — the common case
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Stream one transcript, folding its assistant turns into `acc`.
53
+ *
54
+ * Streams line-by-line rather than readFileSync+split: a long session's
55
+ * transcript runs to hundreds of MB, and this is called from a hook on the
56
+ * critical path of a tool call.
57
+ */
58
+ async function foldTranscript(filePath, acc, { isSubagent }) {
59
+ let stream;
60
+ try {
61
+ stream = createReadStream(filePath, { encoding: 'utf8' });
62
+ } catch {
63
+ return;
64
+ }
65
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
66
+ try {
67
+ for await (const line of rl) {
68
+ if (!line.trim()) continue;
69
+ let entry;
70
+ try {
71
+ entry = JSON.parse(line);
72
+ } catch {
73
+ continue; // a partially-flushed last line, not a reason to abandon the file
74
+ }
75
+
76
+ // Claude Code's own measure of time actually spent on a turn. Summed
77
+ // instead of using wall-clock, which measures when the developer went
78
+ // to lunch: one historical window read as 738 minutes of "work" that
79
+ // was really 36. Main thread only — a subagent's duration overlaps
80
+ // its parent's and would double-count.
81
+ if (!isSubagent && entry.type === 'system' && entry.subtype === 'turn_duration') {
82
+ acc.activeMs = (acc.activeMs ?? 0) + (entry.durationMs || 0);
83
+ continue;
84
+ }
85
+
86
+ if (entry.type !== 'assistant' || !entry.message) continue;
87
+
88
+ // Streaming writes the same final usage numbers 2-3x per real
89
+ // response (once per content-block flush), so totals would be
90
+ // inflated 2-3x without this.
91
+ const messageId = entry.message.id;
92
+ if (messageId) {
93
+ if (acc._seen.has(messageId)) continue;
94
+ acc._seen.add(messageId);
95
+ }
96
+
97
+ const usage = entry.message.usage;
98
+ if (!usage) continue;
99
+ const model = entry.message.model || 'unknown';
100
+ if (model === SYNTHETIC_MODEL) continue;
101
+
102
+ addUsage(acc.byModel, model, usage);
103
+ if (isSubagent) acc.subagentTurns += 1;
104
+ else acc.turns += 1;
105
+ }
106
+ } finally {
107
+ rl.close();
108
+ stream.destroy();
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Everything this session has spent so far.
114
+ *
115
+ * Subagent spend is folded INTO byModel (so the totals are complete) and
116
+ * also counted separately as subagentTurns, matching the convention the
117
+ * weekly breakdown already uses.
118
+ *
119
+ * activeMs stays null when the transcript has no turn_duration records at
120
+ * all — older Claude Code versions don't write them, and "unknown" must not
121
+ * collapse into "zero minutes of work".
122
+ */
123
+ export async function scanSessionCumulative(transcriptPath) {
124
+ const acc = {
125
+ byModel: {},
126
+ turns: 0,
127
+ subagentTurns: 0,
128
+ activeMs: null,
129
+ _seen: new Set(),
130
+ };
131
+ try {
132
+ statSync(transcriptPath);
133
+ } catch {
134
+ return { byModel: {}, turns: 0, subagentTurns: 0, activeMs: null };
135
+ }
136
+ await foldTranscript(transcriptPath, acc, { isSubagent: false });
137
+ for (const sub of subagentFiles(transcriptPath)) {
138
+ await foldTranscript(sub, acc, { isSubagent: true });
139
+ }
140
+ return {
141
+ byModel: acc.byModel,
142
+ turns: acc.turns,
143
+ subagentTurns: acc.subagentTurns,
144
+ activeMs: acc.activeMs,
145
+ };
146
+ }
147
+
148
+ /** Round-trip-safe empty reading, for a session whose transcript cannot be
149
+ * read at all (the marker still records a window; it just measures zero). */
150
+ export const EMPTY_CUMULATIVE = { byModel: {}, turns: 0, subagentTurns: 0, activeMs: null };
@@ -0,0 +1,93 @@
1
+ // Shared config for the hook/status-line binaries (bin/*.js).
2
+ //
3
+ // The MCP server itself gets BUG_REPORT_API_KEY from the `env` block of the
4
+ // mcpServers entry in ~/.claude.json. Hooks and the status line do NOT —
5
+ // they are plain commands Claude Code spawns, inheriting only the ordinary
6
+ // shell environment, which does not have that key.
7
+ //
8
+ // So they read a small config file instead, and fall back to the env var for
9
+ // anyone who does export it globally. The file lives outside the repo on
10
+ // purpose: an API key in settings.json is one `git add` away from being
11
+ // committed.
12
+ //
13
+ // ~/.claude/.zopkit-usage.json
14
+ // { "apiKey": "bugres_…", "baseUrl": "https://api.zopkit.com" }
15
+ //
16
+ // Create it with: npx zopkit-bug-resolution-mcp --init-usage-reporting
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+
22
+ export const CONFIG_PATH = join(homedir(), '.claude', '.zopkit-usage.json');
23
+
24
+ let cached;
25
+
26
+ export function loadConfig() {
27
+ if (cached !== undefined) return cached;
28
+ let file = {};
29
+ try {
30
+ file = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
31
+ } catch {
32
+ // Missing or unreadable is the normal case before setup — not an error
33
+ // worth printing, since these binaries run on every status-line render
34
+ // and must never write noise into a developer's terminal.
35
+ }
36
+ const apiKey = file.apiKey || process.env.BUG_REPORT_API_KEY || null;
37
+ const baseUrl = (file.baseUrl || process.env.BUG_REPORT_API_BASE_URL || 'https://api.zopkit.com').replace(/\/$/, '');
38
+ // Opt-in, default off. The session title describes what a developer is
39
+ // working on, which is a step beyond the cwd this feature already
40
+ // collects, and it lands on a panel every staff member with the
41
+ // 'claude-usage' section can read.
42
+ const shareSessionName = file.shareSessionName === true;
43
+ cached = apiKey ? { apiKey, baseUrl, shareSessionName } : null;
44
+ return cached;
45
+ }
46
+
47
+ /**
48
+ * POST, with a hard timeout and every failure swallowed.
49
+ *
50
+ * Nothing these binaries report is worth degrading the editor over: a status
51
+ * line that hangs on a network call blanks the bar, and a hook that throws
52
+ * surfaces an error to the developer about telemetry they did not ask to
53
+ * think about. The next heartbeat is seconds away, so there is nothing to
54
+ * retry either.
55
+ */
56
+ export async function post(path, body, { timeoutMs = 5000 } = {}) {
57
+ const cfg = loadConfig();
58
+ if (!cfg) return null;
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
61
+ try {
62
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': cfg.apiKey },
65
+ body: JSON.stringify(body),
66
+ signal: controller.signal,
67
+ });
68
+ if (!res.ok) return null;
69
+ return await res.json().catch(() => null);
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ /** Read all of stdin as text. Claude Code hands hooks and the status line
78
+ * their JSON payload this way. */
79
+ export async function readStdin() {
80
+ const chunks = [];
81
+ for await (const chunk of process.stdin) chunks.push(chunk);
82
+ return Buffer.concat(chunks).toString('utf8');
83
+ }
84
+
85
+ /** Parse the stdin payload, returning null rather than throwing — a
86
+ * malformed payload must not take the status line down with it. */
87
+ export async function readStdinJson() {
88
+ try {
89
+ return JSON.parse(await readStdin());
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "zopkit-bug-resolution-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
- "description": "MCP server for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, report your Claude Code usage status, and report a real token/model/context-window breakdown (including subagent spend) from your local transcripts. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) — see README.md.",
5
+ "description": "MCP server + status-line/hook reporters for Zopkit wrapper bug reports: fetch full bug context (with screenshots), submit how a bug was fixed, list bugs assigned to you, file new bug reports, and report REAL Claude Code usage \u2014 live per-session context and account rate limits via the status line, plus per-bug token attribution via PostToolUse hooks. Requires a BUG_REPORT_API_KEY (generated in the company-admin panel) \u2014 see README.md.",
6
6
  "license": "UNLICENSED",
7
7
  "engines": {
8
8
  "node": ">=18"
@@ -13,7 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "index.js",
16
- "README.md"
16
+ "README.md",
17
+ "bin/",
18
+ "lib/"
17
19
  ],
18
20
  "scripts": {
19
21
  "start": "node index.js"