cchubber 0.5.8 → 0.6.1
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 +10 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +245 -0
- package/src/analyzers/drift-run.js +53 -0
- package/src/analyzers/drift.js +481 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +119 -11
- package/src/data/frontier-models.js +12 -0
- package/src/data/price-overrides.js +16 -0
- package/src/data/price-snapshot.json +49 -0
- package/src/readers/jsonl-reader.js +11 -14
- package/src/readers/plan.js +56 -0
- package/src/readers/stated-plan.js +187 -0
- package/src/readers/transcripts.js +259 -0
- package/src/renderers/drift-report.js +315 -0
- package/src/renderers/html-report.js +1307 -1280
- package/src/renderers/spent-well.js +171 -0
- package/src/renderers/terminal-summary.js +10 -0
- package/src/renderers/vs-report.js +439 -0
- package/src/renderers/vs-text.js +149 -0
- package/src/telemetry.js +13 -1
package/src/cli/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, writeFileSync } from 'fs';
|
|
|
5
5
|
import { homedir, platform } from 'os';
|
|
6
6
|
import { exec } from 'child_process';
|
|
7
7
|
import { readFileSync } from 'fs';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
9
9
|
import { dirname } from 'path';
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -18,7 +18,8 @@ import { readSessionMeta } from '../readers/session-meta.js';
|
|
|
18
18
|
import { readCacheBreaks } from '../readers/cache-breaks.js';
|
|
19
19
|
import { readClaudeMdStack } from '../readers/claude-md.js';
|
|
20
20
|
import { readOAuthUsage } from '../readers/oauth-usage.js';
|
|
21
|
-
import { analyzeUsage, fetchPricing } from '../analyzers/cost-calculator.js';
|
|
21
|
+
import { analyzeUsage, fetchPricing, getLiteLLMRaw } from '../analyzers/cost-calculator.js';
|
|
22
|
+
import { reprice } from '../analyzers/reprice.js';
|
|
22
23
|
import { analyzeCacheHealth } from '../analyzers/cache-health.js';
|
|
23
24
|
import { detectAnomalies } from '../analyzers/anomaly-detector.js';
|
|
24
25
|
import { generateRecommendations } from '../analyzers/recommendations.js';
|
|
@@ -27,8 +28,11 @@ import { analyzeSessionIntelligence } from '../analyzers/session-intelligence.js
|
|
|
27
28
|
import { analyzeModelRouting } from '../analyzers/model-routing.js';
|
|
28
29
|
import { analyzeValueTrend } from '../analyzers/value-tracker.js';
|
|
29
30
|
import { renderHTML } from '../renderers/html-report.js';
|
|
30
|
-
import { renderTerminal } from '../renderers/terminal-summary.js';
|
|
31
|
-
import {
|
|
31
|
+
import { renderTerminal, vsOneLiner } from '../renderers/terminal-summary.js';
|
|
32
|
+
import { readPlan, monthsBilled } from '../readers/plan.js';
|
|
33
|
+
import { runDrift, driftForReport } from '../analyzers/drift-run.js';
|
|
34
|
+
import { shouldSendTelemetry, sendTelemetry, beaconConfig } from '../telemetry.js';
|
|
35
|
+
import { spentWellLine } from '../renderers/spent-well.js';
|
|
32
36
|
import { saveRun, getDelta, getHistory } from '../history.js';
|
|
33
37
|
|
|
34
38
|
const args = process.argv.slice(2);
|
|
@@ -37,6 +41,10 @@ const flags = {
|
|
|
37
41
|
json: args.includes('--json'),
|
|
38
42
|
noTelemetry: args.includes('--no-telemetry'),
|
|
39
43
|
noOpen: args.includes('--no-open'),
|
|
44
|
+
drift: args.includes('--drift'),
|
|
45
|
+
vs: args.includes('--vs'),
|
|
46
|
+
redact: args.includes('--redact'),
|
|
47
|
+
plan: (() => { const i = args.indexOf('--plan'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })(),
|
|
40
48
|
output: (() => {
|
|
41
49
|
const idx = args.indexOf('--output') !== -1 ? args.indexOf('--output') : args.indexOf('-o');
|
|
42
50
|
return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
|
|
@@ -62,7 +70,9 @@ if (flags.help) {
|
|
|
62
70
|
--days, -d <n> Analyze last N days (default: 30)
|
|
63
71
|
--output, -o <path> Output HTML report to custom path
|
|
64
72
|
--no-open Don't auto-open the report in browser
|
|
65
|
-
--json Output raw analysis as JSON
|
|
73
|
+
--json Output raw analysis as JSON (includes reprice: your usage on other models)
|
|
74
|
+
--plan <usd> Monthly plan price, if it can't be detected
|
|
75
|
+
--redact Show drift detours as categories instead of your own words
|
|
66
76
|
-h, --help Show this help
|
|
67
77
|
|
|
68
78
|
Examples:
|
|
@@ -70,6 +80,23 @@ if (flags.help) {
|
|
|
70
80
|
cchubber --days 7 Last 7 days only
|
|
71
81
|
cchubber -o report.html Custom output path
|
|
72
82
|
cchubber --json Machine-readable output
|
|
83
|
+
cchubber --redact Same report, drift detours as categories only
|
|
84
|
+
|
|
85
|
+
Nothing here needs a flag. The report that cchubber opens has a section for
|
|
86
|
+
how much of your last week went to what you said you'd do (read from a
|
|
87
|
+
PLAN.md, plan.md or TODO.md, computed locally), and one for
|
|
88
|
+
your usage on other models.
|
|
89
|
+
|
|
90
|
+
Your usage on other models needs no flag. The report that cchubber opens has
|
|
91
|
+
a section for it: your own tokens repriced on each frontier model's list
|
|
92
|
+
price, a race built from your real days, and buttons to download a card, copy
|
|
93
|
+
the text or post it on X. Prices come live from LiteLLM, plus a manual entry
|
|
94
|
+
for a listed model LiteLLM does not carry yet (used only until LiteLLM has
|
|
95
|
+
it). Offline it uses bundled prices dated 30 Sep 2026. When a plan you wrote
|
|
96
|
+
down is found (plan.md or similar) the section also shows how much of your
|
|
97
|
+
last week went to it, computed locally.
|
|
98
|
+
Same tokens on each model's list price, cache included. Another model would
|
|
99
|
+
use a different number of tokens, so this compares prices, not outcomes.
|
|
73
100
|
|
|
74
101
|
Shipped with Mover OS at speed.
|
|
75
102
|
https://moveros.dev
|
|
@@ -86,6 +113,14 @@ async function main() {
|
|
|
86
113
|
process.exit(1);
|
|
87
114
|
}
|
|
88
115
|
|
|
116
|
+
// Hidden, for scripts: `--vs --json` prints only the repricing (no report work). Plain `--vs` is an alias of the normal
|
|
117
|
+
// run that opens the report scrolled to its "Your usage on other models" section. Nothing needs the flag.
|
|
118
|
+
if (flags.vs && flags.json) return vsJsonMode(claudeDir);
|
|
119
|
+
|
|
120
|
+
// Hidden, for scripts: `--drift --json` prints only the drift analysis. Plain `--drift` is an alias of the normal run
|
|
121
|
+
// that opens the report scrolled to its drift section. Nothing needs the flag.
|
|
122
|
+
if (flags.drift && flags.json) return driftJsonMode(claudeDir);
|
|
123
|
+
|
|
89
124
|
console.log(`
|
|
90
125
|
/\\ _ /\\
|
|
91
126
|
/ \\(_)/ \\ CC Hubber v${VERSION}
|
|
@@ -157,6 +192,14 @@ async function main() {
|
|
|
157
192
|
if (communityStats) console.log(` ✓ Community data: ${communityStats.totalReports} users from ${Object.keys(communityStats.countries || {}).length} countries`);
|
|
158
193
|
else console.log(' ○ Community data unavailable (offline)');
|
|
159
194
|
|
|
195
|
+
// Your usage on other models. Never allowed to break the normal run.
|
|
196
|
+
let vsData = null;
|
|
197
|
+
try {
|
|
198
|
+
const live = getLiteLLMRaw();
|
|
199
|
+
vsData = reprice(costAnalysis, { raw: live?.data, fetchedAt: live?.fetchedAt });
|
|
200
|
+
console.log(vsData.offline ? ' ○ Other-model prices offline: bundled prices as of 30 Sep 2026' : ' ✓ Other-model prices: LiteLLM');
|
|
201
|
+
} catch { vsData = null; }
|
|
202
|
+
|
|
160
203
|
const report = {
|
|
161
204
|
generatedAt: new Date().toISOString(),
|
|
162
205
|
periodDays: flags.days,
|
|
@@ -172,6 +215,7 @@ async function main() {
|
|
|
172
215
|
recommendations,
|
|
173
216
|
valueTrend,
|
|
174
217
|
communityStats,
|
|
218
|
+
reprice: vsData,
|
|
175
219
|
history: getHistory(),
|
|
176
220
|
};
|
|
177
221
|
|
|
@@ -198,6 +242,12 @@ async function main() {
|
|
|
198
242
|
|
|
199
243
|
renderTerminal(report);
|
|
200
244
|
|
|
245
|
+
// One line on what the same tokens would cost elsewhere; the rest is in the report.
|
|
246
|
+
try { const line = vsOneLiner(vsData); if (line) console.log(`\n ${line}`); } catch {}
|
|
247
|
+
|
|
248
|
+
// The report's "Did you spend them well?" strip, pointed at from the terminal.
|
|
249
|
+
try { const line = spentWellLine(report); if (line) console.log(`\n ${line}`); } catch {}
|
|
250
|
+
|
|
201
251
|
// Anonymous telemetry (opt out: --no-telemetry or CC_HUBBER_TELEMETRY=0)
|
|
202
252
|
if (shouldSendTelemetry(flags)) {
|
|
203
253
|
console.log(' ○ Sharing anonymous stats...');
|
|
@@ -206,26 +256,84 @@ async function main() {
|
|
|
206
256
|
}
|
|
207
257
|
|
|
208
258
|
const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
|
|
209
|
-
|
|
259
|
+
// Drift is part of the plain report: the last week against what you wrote down, computed locally (about 2 seconds).
|
|
260
|
+
const drift = driftForReport(claudeDir, { days: flags.days === 30 ? 7 : flags.days });
|
|
261
|
+
console.log(driftLine(drift));
|
|
262
|
+
const vsCtx = vsData ? vsContext(vsData, drift) : null;
|
|
263
|
+
const html = renderHTML(report, { vs: vsCtx, drift: { ...drift, redact: flags.redact }, telemetry: beaconConfig(flags) });
|
|
210
264
|
writeFileSync(outputPath, html, 'utf-8');
|
|
211
265
|
console.log(`\n ✓ Report saved to: ${outputPath}`);
|
|
212
266
|
|
|
213
267
|
if (!flags.noOpen) {
|
|
214
|
-
openInBrowser(outputPath);
|
|
268
|
+
openInBrowser(outputPath, flags.drift ? 'drift' : flags.vs ? 'vs' : '');
|
|
215
269
|
console.log(' ✓ Opened in browser\n');
|
|
216
270
|
}
|
|
217
271
|
}
|
|
218
272
|
|
|
273
|
+
// The terminal line for drift, honest about why there is no number when there is none.
|
|
274
|
+
function driftLine(d) {
|
|
275
|
+
if (d.state === 'ok') return ` ✓ Drift: ${Math.round(d.drift.share * 100)}% of your last ${d.days} days went to what you said you'd do. It's in your report.`;
|
|
276
|
+
if (d.state === 'no-plan') return ' ○ Drift: no written plan found. Add a PLAN.md or TODO.md with a checklist to see it in your report.';
|
|
277
|
+
if (d.state === 'no-work') return ` ○ Drift: no Claude Code work in the last ${d.days} days, so nothing to measure.`;
|
|
278
|
+
return ' ○ Drift: could not be read this time. No number shown.';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// What the "other models" section needs beyond the repricing: the plan paid for (if detected), billing months over the
|
|
282
|
+
// logged window, and the drift headline when a written plan was found.
|
|
283
|
+
function vsContext(rp, drift) {
|
|
284
|
+
const plan = readPlan(flags.plan);
|
|
285
|
+
const dates = rp.dailyCum.dates;
|
|
286
|
+
const months = dates.length ? monthsBilled(dates[0], dates[dates.length - 1], plan?.since) : 1;
|
|
287
|
+
const driftPct = drift.state === 'ok' ? Math.round(drift.drift.share * 100) : null;
|
|
288
|
+
return { plan, months, driftPct };
|
|
289
|
+
}
|
|
290
|
+
|
|
219
291
|
function getClaudeDir() {
|
|
220
292
|
const home = homedir();
|
|
221
293
|
return join(home, '.claude');
|
|
222
294
|
}
|
|
223
295
|
|
|
224
|
-
function
|
|
296
|
+
async function vsJsonMode(claudeDir) {
|
|
297
|
+
// Progress goes to stderr so stdout is clean JSON
|
|
298
|
+
const log = (m) => process.stderr.write(m + '\n');
|
|
299
|
+
log(' Reading local Claude Code data...');
|
|
300
|
+
|
|
301
|
+
const jsonlEntries = readAllJSONL(claudeDir);
|
|
302
|
+
const statsCache = readStatsCache(claudeDir);
|
|
303
|
+
if (jsonlEntries.length === 0 && !statsCache) {
|
|
304
|
+
console.error(' ✗ No usage data found. Use Claude Code first, then run CC Hubber.\n');
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
const sessionMeta = readSessionMeta(claudeDir);
|
|
308
|
+
const dailyFromJSONL = aggregateDaily(jsonlEntries);
|
|
309
|
+
const modelFromJSONL = aggregateByModel(jsonlEntries);
|
|
310
|
+
|
|
311
|
+
await fetchPricing();
|
|
312
|
+
const live = getLiteLLMRaw();
|
|
313
|
+
const costAnalysis = analyzeUsage(statsCache, sessionMeta, 99999, dailyFromJSONL, modelFromJSONL);
|
|
314
|
+
const rp = reprice(costAnalysis, { raw: live?.data, fetchedAt: live?.fetchedAt });
|
|
315
|
+
log(rp.offline ? ' ○ Offline: prices as of 30 Sep 2026 (bundled)' : ' ✓ Live prices from LiteLLM');
|
|
316
|
+
process.stdout.write(JSON.stringify(rp, null, 2) + '\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function driftJsonMode(claudeDir) {
|
|
320
|
+
const days = flags.days === 30 ? 7 : flags.days; // 7-day week by default; --days overrides
|
|
321
|
+
process.stderr.write(` Reading the last ${days} days of local sessions...\n`);
|
|
322
|
+
const drift = runDrift(claudeDir, { days });
|
|
323
|
+
if (!drift.available) {
|
|
324
|
+
console.error(' ✗ No agent work found in the window. Use Claude Code (or Codex) this week, then try again.\n');
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
process.stdout.write(JSON.stringify(drift, null, 2) + '\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function openInBrowser(filePath, hash = '') {
|
|
225
331
|
const p = platform();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
332
|
+
// A file URL lets the browser scroll to a #section; a bare path cannot carry one.
|
|
333
|
+
const target = hash ? `${pathToFileURL(filePath).href}#${hash}` : filePath;
|
|
334
|
+
const cmd = p === 'win32' ? `start "" "${target}"`
|
|
335
|
+
: p === 'darwin' ? `open "${target}"`
|
|
336
|
+
: `xdg-open "${target}"`;
|
|
229
337
|
exec(cmd, (err) => { if (err) console.log(' ○ Could not auto-open browser. Open the file manually.'); });
|
|
230
338
|
}
|
|
231
339
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The explicit comparison list for --vs. `id` is the LiteLLM key (model_prices_and_context_window.json).
|
|
2
|
+
// Edit this list to add or drop a model; nothing else needs to change.
|
|
3
|
+
export const FRONTIER_MODELS = [
|
|
4
|
+
{ id: 'claude-fable-5-1', label: 'Claude Fable 5.1', maker: 'Anthropic' },
|
|
5
|
+
{ id: 'claude-opus-5-5', label: 'Claude Opus 5.5', maker: 'Anthropic' },
|
|
6
|
+
{ id: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5', maker: 'Anthropic' },
|
|
7
|
+
{ id: 'gpt-6-astra', label: 'GPT-6 Astra', maker: 'OpenAI' },
|
|
8
|
+
{ id: 'gpt-6-sol', label: 'GPT-6 Sol', maker: 'OpenAI' },
|
|
9
|
+
{ id: 'gemini-4-argon', label: 'Gemini 4 Argon', maker: 'Google' },
|
|
10
|
+
{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', maker: 'DeepSeek' },
|
|
11
|
+
{ id: 'moonshot/kimi-k3', label: 'Kimi K3', maker: 'Moonshot' },
|
|
12
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Manual prices for listed models that LiteLLM does not carry yet. Used ONLY when LiteLLM has no entry for the id,
|
|
2
|
+
// so live data wins the day LiteLLM adds the model. All prices are USD per million tokens.
|
|
3
|
+
//
|
|
4
|
+
// A missing cacheWrite is deliberate: Google lists no cache-write premium, so reprice.js bills writes at the input
|
|
5
|
+
// rate and records that in `filled` (an assumption, not a published price).
|
|
6
|
+
export const PRICE_OVERRIDES = {
|
|
7
|
+
'gemini-4-argon': {
|
|
8
|
+
intro: { input: 2, output: 10, cacheRead: 0.10 }, // cached input is 95% off the input price
|
|
9
|
+
after: { input: 4, output: 20, cacheRead: 0.20 }, // standard price once the introductory period ends
|
|
10
|
+
sourceUrl: 'https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-4-argon/',
|
|
11
|
+
sourceName: 'blog.google',
|
|
12
|
+
checked: '2026-09-30',
|
|
13
|
+
note: 'introductory price',
|
|
14
|
+
assumption: 'Google lists no cache-write premium, so cache writes are priced at the input rate.',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"date": "2026-09-30",
|
|
3
|
+
"source": "LiteLLM model_prices_and_context_window.json",
|
|
4
|
+
"note": "USD per million tokens as LiteLLM lists them; null means the field is absent (reprice.js fills it from the input price).",
|
|
5
|
+
"models": {
|
|
6
|
+
"claude-fable-5-1": {
|
|
7
|
+
"input": 10,
|
|
8
|
+
"output": 50,
|
|
9
|
+
"cacheRead": 0.25,
|
|
10
|
+
"cacheWrite": 12.5
|
|
11
|
+
},
|
|
12
|
+
"claude-opus-5-5": {
|
|
13
|
+
"input": 4,
|
|
14
|
+
"output": 20,
|
|
15
|
+
"cacheRead": 0.2,
|
|
16
|
+
"cacheWrite": 5
|
|
17
|
+
},
|
|
18
|
+
"claude-sonnet-5-5": {
|
|
19
|
+
"input": 2,
|
|
20
|
+
"output": 10,
|
|
21
|
+
"cacheRead": 0.2,
|
|
22
|
+
"cacheWrite": 2.5
|
|
23
|
+
},
|
|
24
|
+
"gpt-6-astra": {
|
|
25
|
+
"input": 10,
|
|
26
|
+
"output": 50,
|
|
27
|
+
"cacheRead": 1,
|
|
28
|
+
"cacheWrite": 12.5
|
|
29
|
+
},
|
|
30
|
+
"gpt-6-sol": {
|
|
31
|
+
"input": 2,
|
|
32
|
+
"output": 10,
|
|
33
|
+
"cacheRead": 0.2,
|
|
34
|
+
"cacheWrite": 2.5
|
|
35
|
+
},
|
|
36
|
+
"deepseek-v4-pro": {
|
|
37
|
+
"input": 1.32,
|
|
38
|
+
"output": 3.96,
|
|
39
|
+
"cacheRead": 0.044,
|
|
40
|
+
"cacheWrite": 0
|
|
41
|
+
},
|
|
42
|
+
"moonshot/kimi-k3": {
|
|
43
|
+
"input": 3,
|
|
44
|
+
"output": 15,
|
|
45
|
+
"cacheRead": 0.3,
|
|
46
|
+
"cacheWrite": null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -8,24 +8,21 @@ import { homedir } from 'os';
|
|
|
8
8
|
* Claude Code stores full conversation transcripts with token usage per message.
|
|
9
9
|
*/
|
|
10
10
|
export function readAllJSONL(claudeDir) {
|
|
11
|
-
const projectsDir = join(claudeDir, 'projects');
|
|
12
|
-
const xdgDir = join(homedir(), '.config', 'claude', 'projects'); // XDG fallback for Linux
|
|
13
|
-
|
|
14
11
|
const entries = [];
|
|
15
|
-
|
|
16
|
-
// Read from primary location
|
|
17
|
-
if (existsSync(projectsDir)) {
|
|
18
|
-
readProjectsDir(projectsDir, entries);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// XDG fallback (Linux with newer Claude Code)
|
|
22
|
-
if (existsSync(xdgDir) && xdgDir !== projectsDir) {
|
|
23
|
-
readProjectsDir(xdgDir, entries);
|
|
24
|
-
}
|
|
25
|
-
|
|
12
|
+
for (const dir of projectRoots(claudeDir)) readProjectsDir(dir, entries);
|
|
26
13
|
return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
27
14
|
}
|
|
28
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Where Claude Code keeps its transcripts: ~/.claude/projects, plus the XDG path newer Linux builds use.
|
|
18
|
+
* Shared with the drift reader so both read the same sessions.
|
|
19
|
+
*/
|
|
20
|
+
export function projectRoots(claudeDir) {
|
|
21
|
+
const projectsDir = join(claudeDir, 'projects');
|
|
22
|
+
const xdgDir = join(homedir(), '.config', 'claude', 'projects');
|
|
23
|
+
return [projectsDir, xdgDir].filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
24
|
+
}
|
|
25
|
+
|
|
29
26
|
function readProjectsDir(dir, entries) {
|
|
30
27
|
try {
|
|
31
28
|
const projectHashes = readdirSync(dir).filter(f => {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
// Monthly list prices from claude.com/pricing and the Max plan help page, checked 29 Sep 2026 (US web prices, before tax).
|
|
6
|
+
const PLAN_PRICES = { pro: 20, max_5x: 100, max_20x: 200 };
|
|
7
|
+
const PLAN_NAMES = { pro: 'Claude Pro', max_5x: 'Claude Max 5x', max_20x: 'Claude Max 20x' };
|
|
8
|
+
|
|
9
|
+
// Reads only the plan tier and start date from ~/.claude.json. Tokens and keys in that file are never touched.
|
|
10
|
+
export function readPlan(override) {
|
|
11
|
+
if (override) {
|
|
12
|
+
const usd = Number(override);
|
|
13
|
+
if (Number.isFinite(usd) && usd > 0) return { key: 'custom', name: 'your plan', monthlyUSD: usd, since: null };
|
|
14
|
+
}
|
|
15
|
+
const file = join(homedir(), '.claude.json');
|
|
16
|
+
if (!existsSync(file)) return null;
|
|
17
|
+
try {
|
|
18
|
+
const acct = JSON.parse(readFileSync(file, 'utf-8')).oauthAccount || {};
|
|
19
|
+
const tier = String(acct.organizationRateLimitTier || acct.userRateLimitTier || '').toLowerCase();
|
|
20
|
+
const key = tier.includes('max_20x') ? 'max_20x' : tier.includes('max_5x') ? 'max_5x' : tier.includes('pro') ? 'pro' : null;
|
|
21
|
+
if (!key) return null;
|
|
22
|
+
return { key, name: PLAN_NAMES[key], monthlyUSD: PLAN_PRICES[key], since: acct.subscriptionCreatedAt || null };
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// `d` plus `k` months, clamped to the last day of a shorter month: a plan billed on the 31st renews 28 Feb, 31 Mar, 30 Apr.
|
|
29
|
+
// (setUTCMonth alone rolls 31 Jan + 1 month over to 3 Mar, which silently drops February.)
|
|
30
|
+
function addMonths(d, k) {
|
|
31
|
+
const y = d.getUTCFullYear();
|
|
32
|
+
const m = d.getUTCMonth() + k;
|
|
33
|
+
const lastDay = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
|
|
34
|
+
return new Date(Date.UTC(y, m, Math.min(d.getUTCDate(), lastDay)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Billing months inside the window the logs cover: every charge whose month overlaps it. Counting only the logged window
|
|
38
|
+
// keeps "paid" and "consumed" about the same days, since Claude Code prunes old transcripts by default.
|
|
39
|
+
// Charges land on the subscription's own anniversary (`since`, when ~/.claude.json has it), not on the first logged day;
|
|
40
|
+
// without it the first logged day stands in as the billing day.
|
|
41
|
+
export function monthsBilled(firstDate, lastDate, since) {
|
|
42
|
+
const a = new Date(firstDate + 'T00:00:00Z');
|
|
43
|
+
const b = new Date(lastDate + 'T00:00:00Z');
|
|
44
|
+
if (isNaN(a) || isNaN(b) || b < a) return 1;
|
|
45
|
+
let anchor = a;
|
|
46
|
+
if (since) {
|
|
47
|
+
const s = new Date(String(since).slice(0, 10) + 'T00:00:00Z');
|
|
48
|
+
if (!isNaN(s) && s <= b) anchor = s;
|
|
49
|
+
}
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (let k = 0; ; k++) {
|
|
52
|
+
if (addMonths(anchor, k) > b) break;
|
|
53
|
+
if (addMonths(anchor, k + 1) > a) n++;
|
|
54
|
+
}
|
|
55
|
+
return Math.max(1, n);
|
|
56
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
2
|
+
import { join, dirname, resolve, relative, isAbsolute, sep } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "What you said you'd do", when you wrote it down. Two kinds of written plan are read, both local:
|
|
7
|
+
* - Mover OS: the day's Daily Note (Focus and Tasks), and the plan.md of the vault project a session ran in
|
|
8
|
+
* - anywhere else: a PLAN.md, plan.md or TODO.md in the session's project folder
|
|
9
|
+
* Only checklist items and focus lines are read. Nothing else in these files is used, and nothing leaves the machine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PLAN_NAMES = ['PLAN.md', 'plan.md', 'TODO.md', 'todo.md'];
|
|
13
|
+
// A plan with more open items than this is a long-running tracker, not a short to-do list. It is still used, as the
|
|
14
|
+
// project's vocabulary, but the report says "on the project" rather than "on this week's list".
|
|
15
|
+
export const BIG_PLAN_ITEMS = 60;
|
|
16
|
+
const CHECKBOX = /^\s*[-*+]\s+\[([ xX~\/>-])\]\s+(.+)$/;
|
|
17
|
+
|
|
18
|
+
// Mover's vault, from ~/.mover/config.json. Only vaultPath is read.
|
|
19
|
+
export function moverVault(configPath = join(homedir(), '.mover', 'config.json')) {
|
|
20
|
+
if (!existsSync(configPath)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const vault = JSON.parse(readFileSync(configPath, 'utf-8')).vaultPath;
|
|
23
|
+
return typeof vault === 'string' && existsSync(vault) ? vault : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Checklist items and focus lines, cleaned of Obsidian syntax and Mover's bookkeeping tags
|
|
30
|
+
export function cleanItem(text) {
|
|
31
|
+
return String(text)
|
|
32
|
+
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
|
|
33
|
+
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
|
34
|
+
.replace(/\s\^[\w-]+\s*$/, '')
|
|
35
|
+
.replace(/\[(Added|Due|Moved|Carried|From|UNVERIFIED|HYPOTHESIS ONLY)[^\]]*\]/gi, '')
|
|
36
|
+
.replace(/[*_`]/g, '')
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A template placeholder like "[The ONE thing that makes today a win]" is not a plan
|
|
42
|
+
const isPlaceholder = (s) => !s || /^\[.*\]$/.test(s) || /^\(.*\)$/.test(s);
|
|
43
|
+
|
|
44
|
+
// Section headers that are structure or bookkeeping, not a statement of work
|
|
45
|
+
const BORING_HEADER = /^(#+\s*)?(\d+\.|phase\b|execution log|the roadmap|the north star|the architecture|changelog|notes|project control|deferred|resolved|table of|contents|appendix|status|done|completed|in progress|todo|backlog|log\b)/i;
|
|
46
|
+
|
|
47
|
+
export function parsePlanText(text) {
|
|
48
|
+
const open = []; // still-to-do items: the plain reading of "what you said you'd do"
|
|
49
|
+
const all = []; // every item, open or done, plus real section headers: the project's stated work, for matching
|
|
50
|
+
for (const line of String(text).split('\n')) {
|
|
51
|
+
const m = line.match(CHECKBOX);
|
|
52
|
+
if (m) {
|
|
53
|
+
const item = cleanItem(m[2]);
|
|
54
|
+
if (isPlaceholder(item) || m[1] === '-') continue; // placeholder or dropped
|
|
55
|
+
all.push(item);
|
|
56
|
+
// [x] done, [~] done but unchecked in Mover's convention; everything else is still open
|
|
57
|
+
if (!/[xX~]/.test(m[1])) open.push(item);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Section headers carry the intent of long trackers (Mover writes the verbatim brief into each "## R157 ..."),
|
|
61
|
+
// which the terse checkboxes below them do not. Skip the structural and bookkeeping ones.
|
|
62
|
+
const h = line.match(/^(#{2,4})\s+(.+?)\s*#*$/);
|
|
63
|
+
if (h && !BORING_HEADER.test(line)) {
|
|
64
|
+
const item = cleanItem(h[2].replace(/[🏁🏗️⚡🧹🧠📚🔄🎓✅⚙️📋🔧]/gu, ''));
|
|
65
|
+
if (item.length > 6 && !isPlaceholder(item)) all.push(item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { open, all };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A Daily Note's Focus lines (Single Test, The Hard Thing) and its task list. The Sacrifice is what you said you
|
|
72
|
+
// would NOT do, so it is left out.
|
|
73
|
+
export function parseDailyNote(text) {
|
|
74
|
+
const items = [];
|
|
75
|
+
let section = null;
|
|
76
|
+
for (const line of String(text).split('\n')) {
|
|
77
|
+
const h = line.match(/^##\s+(.+?)\s*$/);
|
|
78
|
+
if (h) { section = /^focus/i.test(h[1]) ? 'focus' : /^tasks/i.test(h[1]) ? 'tasks' : null; continue; }
|
|
79
|
+
if (/^#\s/.test(line)) { section = null; continue; }
|
|
80
|
+
if (section === 'focus') {
|
|
81
|
+
const f = line.match(/^\s*[-*]\s+\*\*(Single Test|The Hard Thing|Focus|Main Quest)[^*]*\*\*:?\s*(.+)$/i);
|
|
82
|
+
if (f) {
|
|
83
|
+
const item = cleanItem(f[2]);
|
|
84
|
+
if (!isPlaceholder(item)) items.push(item);
|
|
85
|
+
}
|
|
86
|
+
} else if (section === 'tasks') {
|
|
87
|
+
const m = line.match(CHECKBOX);
|
|
88
|
+
if (m && m[1] !== '-') {
|
|
89
|
+
const item = cleanItem(m[2]);
|
|
90
|
+
if (!isPlaceholder(item)) items.push(item);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function dailyNotePath(vault, dayKey) {
|
|
98
|
+
return join(vault, '02_Areas', 'Engine', 'Dailies', dayKey.slice(0, 7), `Daily - ${dayKey}.md`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inside = (child, parent) => {
|
|
102
|
+
const rel = relative(parent, child);
|
|
103
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The plan file for a session's folder: in a Mover vault, the project's plan.md (or dev/plan.md); anywhere else,
|
|
107
|
+
// the nearest PLAN.md / plan.md / TODO.md between the folder and its git root
|
|
108
|
+
export function findPlanFile(cwd, vault) {
|
|
109
|
+
if (!cwd) return null;
|
|
110
|
+
const dir = resolve(cwd);
|
|
111
|
+
if (vault && inside(dir, join(vault, '01_Projects'))) {
|
|
112
|
+
const project = relative(join(vault, '01_Projects'), dir).split(sep)[0];
|
|
113
|
+
if (!project) return null;
|
|
114
|
+
const root = join(vault, '01_Projects', project);
|
|
115
|
+
for (const f of ['plan.md', join('dev', 'plan.md')]) if (existsSync(join(root, f))) return join(root, f);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const home = homedir();
|
|
119
|
+
let d = dir;
|
|
120
|
+
for (let i = 0; i < 6; i++) {
|
|
121
|
+
for (const name of PLAN_NAMES) {
|
|
122
|
+
const f = join(d, name);
|
|
123
|
+
try { if (statSync(f).isFile()) return f; } catch { /* not here */ }
|
|
124
|
+
}
|
|
125
|
+
if (existsSync(join(d, '.git')) || d === home) break;
|
|
126
|
+
const up = dirname(d);
|
|
127
|
+
if (up === d) break;
|
|
128
|
+
d = up;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Plan lookup for the analyzer: planFor(cwd, dayKey) returns { sources: [...], items } or null.
|
|
135
|
+
* `notes` records plans that were found and set aside, so the report can say why.
|
|
136
|
+
*/
|
|
137
|
+
export function readStatedPlans({ vault = moverVault() } = {}) {
|
|
138
|
+
const planCache = new Map();
|
|
139
|
+
const dailyCache = new Map();
|
|
140
|
+
const notes = new Map();
|
|
141
|
+
|
|
142
|
+
const plan = (cwd) => {
|
|
143
|
+
const file = findPlanFile(cwd, vault);
|
|
144
|
+
if (!file) return null;
|
|
145
|
+
if (!planCache.has(file)) {
|
|
146
|
+
let parsed = null;
|
|
147
|
+
try { parsed = parsePlanText(readFileSync(file, 'utf-8')); } catch { /* unreadable */ }
|
|
148
|
+
const name = file.split(sep).pop();
|
|
149
|
+
if (parsed && parsed.all.length) {
|
|
150
|
+
const big = parsed.open.length > BIG_PLAN_ITEMS;
|
|
151
|
+
// A short to-do list is matched item by item. A long tracker is matched as a whole: any of the project's
|
|
152
|
+
// work counts as on the plan, so "on-plan" reads as "on this project".
|
|
153
|
+
planCache.set(file, { name, items: parsed.all, scope: big ? 'project' : 'list', open: parsed.open.length });
|
|
154
|
+
if (big) notes.set(file, { name, open: parsed.open.length, scope: 'project' });
|
|
155
|
+
} else {
|
|
156
|
+
planCache.set(file, null);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return planCache.get(file);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const daily = (dayKey) => {
|
|
163
|
+
if (!vault) return null;
|
|
164
|
+
if (!dailyCache.has(dayKey)) {
|
|
165
|
+
const file = dailyNotePath(vault, dayKey);
|
|
166
|
+
let items = [];
|
|
167
|
+
try { items = parseDailyNote(readFileSync(file, 'utf-8')); } catch { /* no note that day */ }
|
|
168
|
+
dailyCache.set(dayKey, items.length ? { name: 'Daily Note', items, scope: 'list' } : null);
|
|
169
|
+
}
|
|
170
|
+
return dailyCache.get(dayKey);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
mover: Boolean(vault),
|
|
175
|
+
// A day's Daily-Note focus is the sharpest statement of intent, so it leads; the project's plan backs it.
|
|
176
|
+
planFor(cwd, dayKey) {
|
|
177
|
+
const found = [daily(dayKey), plan(cwd)].filter(Boolean);
|
|
178
|
+
if (!found.length) return null;
|
|
179
|
+
return {
|
|
180
|
+
sources: found.map(f => f.name),
|
|
181
|
+
scope: found.some(f => f.scope === 'list') ? 'list' : 'project',
|
|
182
|
+
items: found.flatMap(f => f.items),
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
notes: () => [...notes.values()],
|
|
186
|
+
};
|
|
187
|
+
}
|