cchubber 0.6.0 → 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 +3 -0
- package/package.json +1 -1
- package/src/analyzers/drift-privacy.js +143 -0
- package/src/analyzers/drift-run.js +20 -1
- package/src/analyzers/drift.js +44 -17
- package/src/cli/index.js +31 -71
- package/src/renderers/drift-report.js +205 -145
- package/src/renderers/html-report.js +7 -1
- package/src/renderers/s1-report.js +0 -166
package/README.md
CHANGED
|
@@ -32,6 +32,9 @@ A single HTML report that tells you three things: what you spent, why you spent
|
|
|
32
32
|
- CLAUDE.md section-by-section analysis with per-message cost impact
|
|
33
33
|
- Cache break estimation even when diff files don't exist on your CC version
|
|
34
34
|
|
|
35
|
+
**Your week against your plan:**
|
|
36
|
+
Directly under the "Did you spend them well?" strip, the report shows how much of your last 7 days of Claude Code (and Codex) work went to what you said you'd do: one number, what it was measured against, where the rest went, and the day you drifted most. "What you said you'd do" is read from a `PLAN.md`, `plan.md` or `TODO.md` checklist in the project you work in (or a Mover OS Daily Note). With no written plan, or no work in the window, the section says what to add instead of showing a number. It runs on your machine and nothing is uploaded; detour names are your own words with paths, keys and links removed. No flag needed, it is part of `npx cchubber`.
|
|
37
|
+
|
|
35
38
|
**Your usage on other models:**
|
|
36
39
|
Right under the grade card, the report reprices your own tokens on every current frontier model's list price (Claude Fable 5.1, Opus 5.5, Sonnet 5.5, GPT-6 Astra and Sol, Gemini 4 Argon, DeepSeek V4 Pro, Kimi K3) and replays your real days as a six-second race. It also shows how many tokens Claude read for every token it wrote, what you paid (when your plan is detected), and, when you have a written plan, how much of your last week went to it (computed locally). Buttons download a 1200x675 card, copy the text, or open a pre-filled post on X. You click; the page never posts. No flag needed, it is part of `npx cchubber`. `--json` includes it as `reprice`.
|
|
37
40
|
|
package/package.json
CHANGED
|
@@ -100,3 +100,146 @@ export function categorize(text) {
|
|
|
100
100
|
for (const [name, re] of CATEGORIES) if (re.test(s)) return name;
|
|
101
101
|
return 'something else';
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
105
|
+
// Readable labels for the drift section. shortName() above keeps the raw start of a prompt; labelName() turns it into
|
|
106
|
+
// something a person recognises, or says what the detour touched, or names its kind. scrub() runs first, always.
|
|
107
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
// Openers that only lead into the point. Multi-word ones first so "question is" goes as one.
|
|
110
|
+
const OPENERS = [
|
|
111
|
+
'at the', 'on the', 'in the', 'question is', 'the question is', 'can you', 'could you', 'would you', 'will you', 'i want to', 'i want',
|
|
112
|
+
'i need to', 'i need', 'i would like to', 'make sure', 'lets', "let's", 'let us', 'ok so', 'okay so', 'so', 'also', 'ok', 'okay', 'and', 'but',
|
|
113
|
+
'i think that', 'i think', 'i feel like', 'i feel', 'i guess', 'i mean', 'i was thinking', 'i dont know', "i don't know", 'maybe', 'btw', 'yo', 'hey', 'please', 'pls', 'now', 'then', 'actually', 'basically', 'well', 'the', 'a', 'an', 'i',
|
|
114
|
+
];
|
|
115
|
+
// Words that carry no topic on their own, for counting how much a label really says
|
|
116
|
+
const THIN = new Set(`a an the and or but nor so if of to in on at by for with from as is am are was were be been being it its it's this that these those
|
|
117
|
+
here there i me my we our you your he she they them his her their do does did done doing have has had not no yes yeah
|
|
118
|
+
can could would should will shall may might must just also too very really quite much many more most some any all etc
|
|
119
|
+
what when where which who whom whose why how than then thus about into onto over under up down out off again still even
|
|
120
|
+
thing things stuff bit lot lots like kind sort one ones`.split(/\s+/).filter(Boolean));
|
|
121
|
+
// A label that ends on one of these was cut mid-thought
|
|
122
|
+
const DANGLING = new Set(['or', 'and', 'but', 'the', 'a', 'an', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'was', 'is', 'are', 'that', 'this',
|
|
123
|
+
'so', 'if', 'as', 'by', 'from', 'etc', 'how', 'what', 'my', 'your', 'our', 'their', 'it', 'its']);
|
|
124
|
+
|
|
125
|
+
const LABEL_MAX = 32;
|
|
126
|
+
|
|
127
|
+
/** The prompt with everything that reads as markup or decoration removed: tags, [Image #1], pasted-content ids, bullets. */
|
|
128
|
+
function tidy(text) {
|
|
129
|
+
return String(text || '')
|
|
130
|
+
.replace(/\[(?:Image|Pasted|Attached|File)[^\]]*\]/gi, ' ')
|
|
131
|
+
// <pastedcontent id=2b8c ...>, also when the closing > never came
|
|
132
|
+
.replace(/<\/?[a-z][\w:-]*(?:\s+[\w:-]+\s*=\s*[^\s>]+)*\s*>?/gi, ' ')
|
|
133
|
+
.replace(/<[^>\n]*>/g, ' ')
|
|
134
|
+
.replace(/[●•·▪◦○◉■□▶►→←↑↓✓✗✔✘★☆]/g, ' ')
|
|
135
|
+
.replace(/(?:^|\s)[-*>#|]+(?=\s|$)/g, ' ')
|
|
136
|
+
.replace(/\s+/g, ' ')
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const plain = (w) => w.toLowerCase().replace(/[^a-z0-9']/g, '');
|
|
141
|
+
const meaningful = (w) => { const p = plain(w); return p.length >= 2 && /[a-z]/.test(p) && !THIN.has(p) && !/^\d/.test(p); };
|
|
142
|
+
|
|
143
|
+
// "1/5 skill gates" -> "skill gates": numbering, fractions and lone digits at the front say nothing
|
|
144
|
+
const stripLeadNumbers = (s) => s.replace(/^(?:[\d#./:-]+\s+)+/, '');
|
|
145
|
+
|
|
146
|
+
function stripOpeners(s) {
|
|
147
|
+
let out = s;
|
|
148
|
+
for (let i = 0; i < 6; i++) {
|
|
149
|
+
const low = out.toLowerCase();
|
|
150
|
+
const hit = OPENERS.find(o => low === o || low.startsWith(o + ' '));
|
|
151
|
+
if (!hit) break;
|
|
152
|
+
out = out.slice(hit.length).trim();
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Cut to the cap at a word boundary, never ending on a dangling word
|
|
158
|
+
function capWords(words) {
|
|
159
|
+
let out = '';
|
|
160
|
+
for (const w of words) {
|
|
161
|
+
const next = out ? `${out} ${w}` : w;
|
|
162
|
+
if (next.length > LABEL_MAX) break;
|
|
163
|
+
out = next;
|
|
164
|
+
}
|
|
165
|
+
// A cut inside brackets leaves "skill gates (design and writing": drop the open bracket and what follows
|
|
166
|
+
out = out.replace(/\s*[(\[][^)\]]*$/, '');
|
|
167
|
+
const parts = out.split(' ');
|
|
168
|
+
while (parts.length > 1 && DANGLING.has(plain(parts[parts.length - 1]))) parts.pop();
|
|
169
|
+
return parts.join(' ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The readable label a prompt earns on its own, or '' when it does not say enough to be one. */
|
|
173
|
+
export function promptLabel(text) {
|
|
174
|
+
const cleaned = tidy(scrub(tidy(text)).replace(/\[(?:key|link|file|email|number)\]/g, ' '));
|
|
175
|
+
const body = stripLeadNumbers(stripOpeners(stripLeadNumbers(cleaned)))
|
|
176
|
+
.replace(/[‘’]/g, "'").replace(/["“”`*_@]/g, '').replace(/(?:\.{2,}|…)+\s*$/, '').replace(/\s+/g, ' ').trim();
|
|
177
|
+
// The first sentence that says something
|
|
178
|
+
const parts = body.split(/(?<=[.?!])\s+|\n+/).map(p => p.trim()).filter(Boolean);
|
|
179
|
+
let phrase = '';
|
|
180
|
+
for (const p of parts) {
|
|
181
|
+
phrase = phrase ? `${phrase} ${p}` : p;
|
|
182
|
+
if (phrase.split(' ').filter(meaningful).length >= 3) break;
|
|
183
|
+
}
|
|
184
|
+
const words = phrase.replace(/[.?!,;:]+$/, '').split(' ').filter(Boolean);
|
|
185
|
+
if (words.filter(meaningful).length < 3) return '';
|
|
186
|
+
// Looks like a fragment: it was cut off, or it ends on a joining word
|
|
187
|
+
if (DANGLING.has(plain(words[words.length - 1])) && words.length <= 7) return '';
|
|
188
|
+
const label = capWords(words.slice(0, 8)).replace(/[,;:\-]+$/, '').toLowerCase();
|
|
189
|
+
return label.split(' ').filter(meaningful).length >= 3 ? label : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const FOLDER_SKIP = new Set(['users', 'user', 'home', 'documents', 'desktop', 'downloads', 'library', 'private', 'tmp', 'var', 'opt', 'node_modules', 'dist', 'build', '.git', '.claude']);
|
|
193
|
+
const EXT_NOUN = { md: 'notes', mdx: 'notes', txt: 'notes', html: 'pages', htm: 'pages', css: 'styles', scss: 'styles', js: 'code', mjs: 'code', ts: 'code', tsx: 'code', jsx: 'code', py: 'code', sh: 'scripts', json: 'data', yml: 'config', yaml: 'config', toml: 'config', png: 'images', jpg: 'images', jpeg: 'images', svg: 'images', pdf: 'documents' };
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What a run of work touched: the folder its files mostly sit in ("site/compare pages"), else the project folder it ran in.
|
|
197
|
+
* `files` are absolute paths, `cwd` the folder the session ran in. Returns '' when there is nothing safe to say.
|
|
198
|
+
*/
|
|
199
|
+
export function touchedLabel(files = [], cwd = '', home = '') {
|
|
200
|
+
const tally = new Map();
|
|
201
|
+
const exts = new Map();
|
|
202
|
+
const cwdClean = String(cwd || '').replace(/[\\/]+$/, '');
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
let p = String(f);
|
|
205
|
+
if (cwdClean && p.startsWith(cwdClean + '/')) p = p.slice(cwdClean.length + 1);
|
|
206
|
+
else p = p.split(/[\\/]/).slice(-3).join('/');
|
|
207
|
+
const segs = p.split(/[\\/]/).filter(Boolean);
|
|
208
|
+
const file = segs.pop() || '';
|
|
209
|
+
const homeBase = String(home || '').split(/[\\/]/).filter(Boolean).pop();
|
|
210
|
+
const dirs = segs.filter(s => !FOLDER_SKIP.has(s.toLowerCase()) && !s.startsWith('.') && s !== homeBase).slice(0, 2);
|
|
211
|
+
const ext = (file.split('.').pop() || '').toLowerCase();
|
|
212
|
+
if (dirs.length) {
|
|
213
|
+
const k = dirs.join('/');
|
|
214
|
+
tally.set(k, (tally.get(k) || 0) + 1);
|
|
215
|
+
if (EXT_NOUN[ext]) exts.set(k + '|' + EXT_NOUN[ext], (exts.get(k + '|' + EXT_NOUN[ext]) || 0) + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const pretty = (s) => s.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
|
|
219
|
+
const best = [...tally].sort((a, b) => b[1] - a[1])[0];
|
|
220
|
+
if (best) {
|
|
221
|
+
const noun = [...exts].filter(([k]) => k.startsWith(best[0] + '|')).sort((a, b) => b[1] - a[1])[0];
|
|
222
|
+
const dir = pretty(best[0]);
|
|
223
|
+
const word = noun ? noun[0].split('|')[1] : '';
|
|
224
|
+
return word && !dir.endsWith(word) ? `${dir} ${word}` : dir;
|
|
225
|
+
}
|
|
226
|
+
// No folder to name: the project the work ran in, unless that is the home folder or plumbing
|
|
227
|
+
const base = cwdClean.split(/[\\/]/).filter(Boolean).pop() || '';
|
|
228
|
+
const homeBase = String(home || '').split(/[\\/]/).filter(Boolean).pop();
|
|
229
|
+
if (base && base !== homeBase && !FOLDER_SKIP.has(base.toLowerCase())) return pretty(base);
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The name shown for a detour. Its own words when they make a readable label; else what it touched; else its kind.
|
|
235
|
+
* Everything that reaches the page has been through scrub() and is at most ~32 characters.
|
|
236
|
+
* `touched`: { files, cwd, home } for the work the detour covered.
|
|
237
|
+
*/
|
|
238
|
+
export function labelName(text, touched = {}, category = categorize(text)) {
|
|
239
|
+
const own = promptLabel(text);
|
|
240
|
+
if (own) return own;
|
|
241
|
+
const t = touchedLabel(touched.files, touched.cwd, touched.home);
|
|
242
|
+
const safe = t ? scrub(t).replace(/\[(?:key|link|file|email|number)\]/g, ' ').replace(/\s+/g, ' ').trim() : '';
|
|
243
|
+
if (safe && meaningful(safe.split(' ')[0]) && safe.length <= LABEL_MAX + 12) return capWords(safe.split(' '));
|
|
244
|
+
return category;
|
|
245
|
+
}
|
|
@@ -23,7 +23,7 @@ export function runDrift(claudeDir, { days = 7, until = Date.now(), codexDir = d
|
|
|
23
23
|
|
|
24
24
|
return {
|
|
25
25
|
...drift,
|
|
26
|
-
days,
|
|
26
|
+
windowDays: days, // the length of the window; drift.days is the day-by-day list and must not be overwritten
|
|
27
27
|
inputs: {
|
|
28
28
|
claude: sessions.filter(s => s.agent === 'claude').length,
|
|
29
29
|
codex: sessions.filter(s => s.agent === 'codex').length,
|
|
@@ -32,3 +32,22 @@ export function runDrift(claudeDir, { days = 7, until = Date.now(), codexDir = d
|
|
|
32
32
|
},
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drift as the plain report needs it: never throws, and says which of four things happened so the page can be honest.
|
|
38
|
+
* ok a written plan was found and there was agent work: `drift` holds the analysis
|
|
39
|
+
* no-work nothing from Claude Code or Codex in the window
|
|
40
|
+
* no-plan work, but no written plan to measure it against (the first-ask fallback is not shown as a headline)
|
|
41
|
+
* error the pipeline failed on this machine
|
|
42
|
+
*/
|
|
43
|
+
export function driftForReport(claudeDir, opts = {}) {
|
|
44
|
+
const days = opts.days || 7;
|
|
45
|
+
try {
|
|
46
|
+
const drift = runDrift(claudeDir, { days });
|
|
47
|
+
if (!drift.available) return { state: 'no-work', days };
|
|
48
|
+
if (!drift.source?.planNames?.length) return { state: 'no-plan', days, drift };
|
|
49
|
+
return { state: 'ok', days, drift };
|
|
50
|
+
} catch {
|
|
51
|
+
return { state: 'error', days };
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/analyzers/drift.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { shortName, categorize } from './drift-privacy.js';
|
|
1
|
+
import { shortName, categorize, labelName } from './drift-privacy.js';
|
|
2
|
+
import { homedir } from 'os';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Drift: how much of your agent time went to what you said you'd do.
|
|
@@ -319,6 +320,11 @@ export function analyzeDrift(sessions, { since, until = Date.now(), planFor = ()
|
|
|
319
320
|
return summarize({ turns, human, detours, since, until, idf, sources, planNames, scopes: [...scopes], planNotes });
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
// What a run of turns touched, for naming a detour whose own words do not make a readable label
|
|
324
|
+
function touchedBy(turns) {
|
|
325
|
+
return { files: turns.flatMap(t => [...(t.topicFiles || t.files || [])]), cwd: turns[0]?.cwd || '', home: homedir() };
|
|
326
|
+
}
|
|
327
|
+
|
|
322
328
|
function summarize({ turns, human, detours, since, until, idf, sources, planNames, scopes, planNotes }) {
|
|
323
329
|
const counted = turns.filter(t => t.time && t.time.ms > 0 && (t.label === 'on' || t.label === 'detour'));
|
|
324
330
|
const onMs = counted.filter(t => t.label === 'on').reduce((a, t) => a + t.time.ms, 0);
|
|
@@ -372,16 +378,8 @@ function summarize({ turns, human, detours, since, until, idf, sources, planName
|
|
|
372
378
|
}
|
|
373
379
|
for (const e of episodes) e.best = e.turns.reduce((a, b) => (b.richness > a.richness ? b : a), e.turns[0]);
|
|
374
380
|
|
|
375
|
-
// The longest single episode is the day's deepest rabbit hole
|
|
381
|
+
// The longest single episode is the day's deepest rabbit hole (named below, from the detour row it belongs to)
|
|
376
382
|
const longest = episodes.slice().sort((a, b) => b.ms - a.ms)[0] || null;
|
|
377
|
-
const streak = longest ? {
|
|
378
|
-
ms: longest.ms,
|
|
379
|
-
wallMs: longest.end - longest.start,
|
|
380
|
-
start: longest.start,
|
|
381
|
-
detours: longest.turns.filter(t => t.thread && t.thread.head === t).length,
|
|
382
|
-
name: shortName(longest.best.text),
|
|
383
|
-
category: categorize(longest.turns.map(t => t.text).join(' ')),
|
|
384
|
-
} : null;
|
|
385
383
|
|
|
386
384
|
// Episodes on the same subject across the week are one named detour, counted each time it pulled you away
|
|
387
385
|
const clusters = [];
|
|
@@ -397,13 +395,42 @@ function summarize({ turns, human, detours, since, until, idf, sources, planName
|
|
|
397
395
|
clusters.push({ best: e.best, bag: own, ms: e.ms, episodes: [e] });
|
|
398
396
|
}
|
|
399
397
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
398
|
+
// Two detours that end up with the same label (several fall back to "code") are one row
|
|
399
|
+
const mergeSame = (list) => {
|
|
400
|
+
const by = new Map();
|
|
401
|
+
for (const d of list) {
|
|
402
|
+
const k = `${d.name}|${d.category}`;
|
|
403
|
+
const c = by.get(k);
|
|
404
|
+
if (c) { c.ms += d.ms; c.times += d.times; c.first = Math.min(c.first, d.first); } else by.set(k, { ...d });
|
|
405
|
+
}
|
|
406
|
+
return [...by.values()];
|
|
407
|
+
};
|
|
408
|
+
// One name and one category per detour, decided once here. The list and the rabbit-hole card both read them, so they cannot disagree.
|
|
409
|
+
const rowOf = (c) => {
|
|
410
|
+
const category = categorize(c.episodes.map(e => e.best.text).join(' '));
|
|
411
|
+
return {
|
|
412
|
+
name: labelName(c.best.text, touchedBy(c.episodes.flatMap(e => e.turns)), category),
|
|
413
|
+
category,
|
|
414
|
+
ms: c.ms,
|
|
415
|
+
times: c.episodes.length,
|
|
416
|
+
first: Math.min(...c.episodes.map(e => e.start)),
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
const named = mergeSame(clusters.map(rowOf).filter(d => d.name)).sort((a, b) => b.ms - a.ms);
|
|
420
|
+
|
|
421
|
+
// The rabbit hole is the longest episode, labelled as the detour it belongs to. An episode that joined no cluster (no
|
|
422
|
+
// readable words) falls back to labelling itself.
|
|
423
|
+
const home = longest ? clusters.find(c => c.episodes.includes(longest)) : null;
|
|
424
|
+
const homeRow = home ? rowOf(home) : null;
|
|
425
|
+
const ownCategory = longest ? categorize(longest.turns.map(t => t.text).join(' ')) : null;
|
|
426
|
+
const streak = longest ? {
|
|
427
|
+
ms: longest.ms,
|
|
428
|
+
wallMs: longest.end - longest.start,
|
|
429
|
+
start: longest.start,
|
|
430
|
+
detours: longest.turns.filter(t => t.thread && t.thread.head === t).length,
|
|
431
|
+
name: homeRow ? homeRow.name : labelName(longest.best.text, touchedBy(longest.turns), ownCategory),
|
|
432
|
+
category: homeRow ? homeRow.category : ownCategory,
|
|
433
|
+
} : null;
|
|
407
434
|
|
|
408
435
|
const active = new Set(counted.map(t => t.session));
|
|
409
436
|
const inSpan = human.filter(t => t.t >= since && t.t <= until);
|
package/src/cli/index.js
CHANGED
|
@@ -29,10 +29,8 @@ import { analyzeModelRouting } from '../analyzers/model-routing.js';
|
|
|
29
29
|
import { analyzeValueTrend } from '../analyzers/value-tracker.js';
|
|
30
30
|
import { renderHTML } from '../renderers/html-report.js';
|
|
31
31
|
import { renderTerminal, vsOneLiner } from '../renderers/terminal-summary.js';
|
|
32
|
-
import { renderS1 } from '../renderers/s1-report.js';
|
|
33
32
|
import { readPlan, monthsBilled } from '../readers/plan.js';
|
|
34
|
-
import { runDrift } from '../analyzers/drift-run.js';
|
|
35
|
-
import { renderDrift } from '../renderers/drift-report.js';
|
|
33
|
+
import { runDrift, driftForReport } from '../analyzers/drift-run.js';
|
|
36
34
|
import { shouldSendTelemetry, sendTelemetry, beaconConfig } from '../telemetry.js';
|
|
37
35
|
import { spentWellLine } from '../renderers/spent-well.js';
|
|
38
36
|
import { saveRun, getDelta, getHistory } from '../history.js';
|
|
@@ -43,12 +41,9 @@ const flags = {
|
|
|
43
41
|
json: args.includes('--json'),
|
|
44
42
|
noTelemetry: args.includes('--no-telemetry'),
|
|
45
43
|
noOpen: args.includes('--no-open'),
|
|
46
|
-
s1: args.includes('--s1'),
|
|
47
44
|
drift: args.includes('--drift'),
|
|
48
45
|
vs: args.includes('--vs'),
|
|
49
46
|
redact: args.includes('--redact'),
|
|
50
|
-
showProject: args.includes('--show-project'),
|
|
51
|
-
name: (() => { const i = args.indexOf('--name'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })(),
|
|
52
47
|
plan: (() => { const i = args.indexOf('--plan'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })(),
|
|
53
48
|
output: (() => {
|
|
54
49
|
const idx = args.indexOf('--output') !== -1 ? args.indexOf('--output') : args.indexOf('-o');
|
|
@@ -76,12 +71,8 @@ if (flags.help) {
|
|
|
76
71
|
--output, -o <path> Output HTML report to custom path
|
|
77
72
|
--no-open Don't auto-open the report in browser
|
|
78
73
|
--json Output raw analysis as JSON (includes reprice: your usage on other models)
|
|
79
|
-
--s1 Your usage as an IPO prospectus (parody)
|
|
80
|
-
--name <name> Company name on the S-1 (default: You)
|
|
81
74
|
--plan <usd> Monthly plan price, if it can't be detected
|
|
82
|
-
--
|
|
83
|
-
--drift Your week vs what you said you'd do (a shareable card)
|
|
84
|
-
--redact On --drift, replace detour names with categories
|
|
75
|
+
--redact Show drift detours as categories instead of your own words
|
|
85
76
|
-h, --help Show this help
|
|
86
77
|
|
|
87
78
|
Examples:
|
|
@@ -89,8 +80,12 @@ if (flags.help) {
|
|
|
89
80
|
cchubber --days 7 Last 7 days only
|
|
90
81
|
cchubber -o report.html Custom output path
|
|
91
82
|
cchubber --json Machine-readable output
|
|
92
|
-
cchubber --
|
|
93
|
-
|
|
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.
|
|
94
89
|
|
|
95
90
|
Your usage on other models needs no flag. The report that cchubber opens has
|
|
96
91
|
a section for it: your own tokens repriced on each frontier model's list
|
|
@@ -122,8 +117,9 @@ async function main() {
|
|
|
122
117
|
// run that opens the report scrolled to its "Your usage on other models" section. Nothing needs the flag.
|
|
123
118
|
if (flags.vs && flags.json) return vsJsonMode(claudeDir);
|
|
124
119
|
|
|
125
|
-
//
|
|
126
|
-
|
|
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);
|
|
127
123
|
|
|
128
124
|
console.log(`
|
|
129
125
|
/\\ _ /\\
|
|
@@ -259,42 +255,36 @@ async function main() {
|
|
|
259
255
|
console.log(' ✓ Stats shared (opt out: --no-telemetry)');
|
|
260
256
|
}
|
|
261
257
|
|
|
262
|
-
if (flags.s1) {
|
|
263
|
-
const plan = readPlan(flags.plan);
|
|
264
|
-
const s1Path = flags.output || join(process.cwd(), 'cchubber-s1.html');
|
|
265
|
-
writeFileSync(s1Path, renderS1(report, { plan, name: flags.name, showProject: flags.showProject }), 'utf-8');
|
|
266
|
-
console.log(`\n ✓ Your S-1 saved to: ${s1Path}`);
|
|
267
|
-
if (!plan) console.log(' ○ Plan not detected. Add --plan 100 (or 20, 200) to show what you paid.');
|
|
268
|
-
if (!flags.noOpen) { openInBrowser(s1Path); console.log(' ✓ Opened in browser\n'); }
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
258
|
const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
|
|
273
|
-
|
|
274
|
-
const
|
|
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) });
|
|
275
264
|
writeFileSync(outputPath, html, 'utf-8');
|
|
276
265
|
console.log(`\n ✓ Report saved to: ${outputPath}`);
|
|
277
266
|
|
|
278
267
|
if (!flags.noOpen) {
|
|
279
|
-
openInBrowser(outputPath, flags.vs ? 'vs' : '');
|
|
268
|
+
openInBrowser(outputPath, flags.drift ? 'drift' : flags.vs ? 'vs' : '');
|
|
280
269
|
console.log(' ✓ Opened in browser\n');
|
|
281
270
|
}
|
|
282
271
|
}
|
|
283
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
|
+
|
|
284
281
|
// What the "other models" section needs beyond the repricing: the plan paid for (if detected), billing months over the
|
|
285
|
-
// logged window, and the drift headline when a
|
|
286
|
-
function vsContext(
|
|
282
|
+
// logged window, and the drift headline when a written plan was found.
|
|
283
|
+
function vsContext(rp, drift) {
|
|
287
284
|
const plan = readPlan(flags.plan);
|
|
288
285
|
const dates = rp.dailyCum.dates;
|
|
289
286
|
const months = dates.length ? monthsBilled(dates[0], dates[dates.length - 1], plan?.since) : 1;
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
const t0 = Date.now();
|
|
293
|
-
const days = flags.days === 30 ? 7 : flags.days;
|
|
294
|
-
const drift = runDrift(claudeDir, { days });
|
|
295
|
-
if (drift.available && drift.source?.planNames?.length) driftPct = Math.round(drift.share * 100);
|
|
296
|
-
console.log(driftPct != null ? ` ✓ Drift: ${driftPct}% of your last ${days} days on what you said you'd do (${((Date.now() - t0) / 1000).toFixed(1)}s)` : ' ○ No stated plan found, so no drift line');
|
|
297
|
-
} catch { driftPct = null; }
|
|
287
|
+
const driftPct = drift.state === 'ok' ? Math.round(drift.drift.share * 100) : null;
|
|
298
288
|
return { plan, months, driftPct };
|
|
299
289
|
}
|
|
300
290
|
|
|
@@ -326,45 +316,15 @@ async function vsJsonMode(claudeDir) {
|
|
|
326
316
|
process.stdout.write(JSON.stringify(rp, null, 2) + '\n');
|
|
327
317
|
}
|
|
328
318
|
|
|
329
|
-
async function
|
|
319
|
+
async function driftJsonMode(claudeDir) {
|
|
330
320
|
const days = flags.days === 30 ? 7 : flags.days; // 7-day week by default; --days overrides
|
|
331
|
-
|
|
332
|
-
const log = flags.json ? (m) => process.stderr.write(m + '\n') : (m) => console.log(m);
|
|
333
|
-
|
|
334
|
-
log(`
|
|
335
|
-
/\\ _ /\\
|
|
336
|
-
/ \\(_)/ \\ CC Hubber v${VERSION} — Drift Report
|
|
337
|
-
\\ / ~ \\ / How much of your week went to what you said you'd do.
|
|
338
|
-
\\/ · \\/
|
|
339
|
-
`);
|
|
340
|
-
log(` Reading the last ${days} days of local sessions...\n`);
|
|
341
|
-
|
|
321
|
+
process.stderr.write(` Reading the last ${days} days of local sessions...\n`);
|
|
342
322
|
const drift = runDrift(claudeDir, { days });
|
|
343
|
-
|
|
344
323
|
if (!drift.available) {
|
|
345
324
|
console.error(' ✗ No agent work found in the window. Use Claude Code (or Codex) this week, then try again.\n');
|
|
346
325
|
process.exit(1);
|
|
347
326
|
}
|
|
348
|
-
|
|
349
|
-
const pct = Math.round(drift.share * 100);
|
|
350
|
-
log(` ✓ ${drift.totals.prompts} prompts across ${drift.totals.sessions} sessions (${drift.inputs.claude} Claude${drift.inputs.codex ? `, ${drift.inputs.codex} Codex` : ''})`);
|
|
351
|
-
log(` ✓ What you said you'd do: ${drift.source.planNames.length ? drift.source.planNames.join(' + ') : 'your first ask of each session'}`);
|
|
352
|
-
if (!drift.inputs.codexPresent) log(' ○ No Codex sessions found (skipped)');
|
|
353
|
-
log(`\n ${pct}% of your agent time went to what you said you'd do.`);
|
|
354
|
-
log(` ${Math.round(drift.totals.onMs / 3600000)}h on plan · ${Math.round(drift.totals.detourMs / 3600000)}h on detours\n`);
|
|
355
|
-
for (const d of drift.detours.slice(0, 5)) {
|
|
356
|
-
const label = flags.redact ? d.category : (d.name || d.category);
|
|
357
|
-
log(` · ${label} ${Math.max(1, Math.round(d.ms / 60000))}m`);
|
|
358
|
-
}
|
|
359
|
-
log('');
|
|
360
|
-
|
|
361
|
-
if (flags.json) { process.stdout.write(JSON.stringify(drift, null, 2) + '\n'); return; }
|
|
362
|
-
|
|
363
|
-
const outPath = flags.output || join(process.cwd(), 'cchubber-drift.html');
|
|
364
|
-
writeFileSync(outPath, renderDrift(drift, { redact: flags.redact }), 'utf-8');
|
|
365
|
-
log(` ✓ Card saved to: ${outPath}`);
|
|
366
|
-
log(' ○ It stayed on this machine. Nothing was uploaded.' + (flags.redact ? '' : ' Use --redact to hide detour names.'));
|
|
367
|
-
if (!flags.noOpen) { openInBrowser(outPath); log(' ✓ Opened in browser\n'); }
|
|
327
|
+
process.stdout.write(JSON.stringify(drift, null, 2) + '\n');
|
|
368
328
|
}
|
|
369
329
|
|
|
370
330
|
function openInBrowser(filePath, hash = '') {
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
2
|
-
import { join, dirname } from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
7
|
-
|
|
8
1
|
function esc(s) {
|
|
9
2
|
if (s === 0) return '0';
|
|
10
3
|
if (!s) return '';
|
|
@@ -23,10 +16,10 @@ const hours = (ms) => {
|
|
|
23
16
|
};
|
|
24
17
|
const dateShort = (iso) => { const d = new Date(iso + 'T00:00:00'); return `${MONTH[d.getMonth()]} ${d.getDate()}`; };
|
|
25
18
|
const weekday = (iso) => WEEKDAY[new Date(iso + 'T00:00:00').getDay()];
|
|
26
|
-
// "Sep 22
|
|
19
|
+
// "Sep 22 to 29" when one month, "Sep 28 to Oct 4" across two
|
|
27
20
|
function rangeLabel(a, b) {
|
|
28
21
|
const da = new Date(a + 'T00:00:00'), db = new Date(b + 'T00:00:00');
|
|
29
|
-
return da.getMonth() === db.getMonth() ? `${MONTH[da.getMonth()]} ${da.getDate()}
|
|
22
|
+
return da.getMonth() === db.getMonth() ? `${MONTH[da.getMonth()]} ${da.getDate()} to ${db.getDate()}` : `${dateShort(a)} to ${dateShort(b)}`;
|
|
30
23
|
}
|
|
31
24
|
|
|
32
25
|
// A closing line with a bit of bite, in his register: lowercase, no wind-up, takes the reader's side against the
|
|
@@ -44,33 +37,132 @@ function kicker(share, drift) {
|
|
|
44
37
|
return `${pct}%. the plan filed a missing person report.`;
|
|
45
38
|
}
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
41
|
+
// The drift card as a component: one section inside the plain report, under the "Did you spend them well?" strip.
|
|
42
|
+
// Every selector starts with #drift and nothing is fetched, so it cannot restyle the report around it. The card itself
|
|
43
|
+
// (number, bar, detours, facts, closing line) is the same markup the standalone --drift page used to have.
|
|
44
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
45
|
+
const CSS = `
|
|
46
|
+
#drift{
|
|
47
|
+
--card:#15161c; --card2:#191b22; --ink:#ecebf1; --dim:#9a99a8; --faint:#6a6a78;
|
|
48
|
+
--line:rgba(255,255,255,.07); --on:#7fe0b0; --off:#ffb27a; --accent:#c0c1ff;
|
|
49
|
+
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
|
50
|
+
width:100%; max-width:1152px; margin-left:auto; margin-right:auto; color:var(--ink);
|
|
51
|
+
}
|
|
52
|
+
#drift .dr-head{display:flex; justify-content:space-between; align-items:baseline; gap:12px; flex-wrap:wrap; margin:0 0 16px}
|
|
53
|
+
#drift .dr-title{margin:0; font-size:20px; line-height:1.2; font-weight:700; color:var(--ink)}
|
|
54
|
+
#drift .dr-range{font-family:var(--mono); font-size:11px; color:var(--faint)}
|
|
55
|
+
#drift .dr-grid{display:grid; grid-template-columns:minmax(0,390px) minmax(0,1fr); gap:28px; align-items:start}
|
|
56
|
+
#drift .dr-grid > *{min-width:0}
|
|
57
|
+
#drift .dr-side{display:flex; flex-direction:column; gap:22px; padding-top:6px}
|
|
58
|
+
#drift .dr-side h4{margin:0 0 8px; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--faint)}
|
|
59
|
+
#drift .dr-side p{margin:0; font-size:16px; line-height:1.5; color:var(--dim)}
|
|
60
|
+
#drift .dr-side p b{color:var(--ink); font-weight:700}
|
|
61
|
+
#drift .dr-tools{display:flex; align-items:center; gap:12px 14px; flex-wrap:wrap; margin:0 0 16px}
|
|
62
|
+
#drift .dr-btn{appearance:none; font:inherit; font-size:13px; font-weight:700; cursor:pointer; color:var(--ink); background:rgba(255,255,255,.05);
|
|
63
|
+
border:1px solid rgba(70,69,84,.45); border-radius:8px; padding:8px 14px; min-height:40px}
|
|
64
|
+
#drift .dr-btn:hover{background:#292a2b}
|
|
65
|
+
#drift .dr-btn:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
|
66
|
+
#drift .dr-btn[aria-pressed="true"]{background:rgba(192,193,255,.14); border-color:rgba(192,193,255,.4)}
|
|
67
|
+
#drift .dr-hint{font-size:12px; color:var(--faint); flex:1 1 220px}
|
|
68
|
+
#drift:not(.dr-hidden) .dr-c{display:none}
|
|
69
|
+
#drift.dr-hidden .dr-w{display:none}
|
|
70
|
+
#drift .dr-days{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px}
|
|
71
|
+
#drift .dr-day{display:grid; grid-template-columns:84px minmax(40px,1fr) 196px; align-items:center; gap:12px; padding:7px 0; border-bottom:1px solid var(--line); font-size:13px}
|
|
72
|
+
#drift .dr-day:last-child{border-bottom:0}
|
|
73
|
+
#drift .dd{color:var(--ink); font-weight:600}
|
|
74
|
+
#drift .db{height:8px; border-radius:6px; background:rgba(255,255,255,.05); display:flex; overflow:hidden}
|
|
75
|
+
#drift .db i{display:block; height:100%}
|
|
76
|
+
#drift .db .on{background:var(--on)} #drift .db .off{background:var(--off)}
|
|
77
|
+
#drift .dh{font-family:var(--mono); font-size:12px; color:var(--dim); text-align:right; white-space:nowrap}
|
|
78
|
+
#drift .dr-side .dr-small{font-size:13px; line-height:1.6; color:var(--faint)}
|
|
79
|
+
#drift .dr-empty{background:var(--card); border:1px solid rgba(70,69,84,.15); border-radius:12px; padding:24px 28px; max-width:760px}
|
|
80
|
+
#drift .dr-empty p{margin:0 0 12px; font-size:16px; line-height:1.55; color:var(--dim)}
|
|
81
|
+
#drift .dr-empty p:last-child{margin-bottom:0}
|
|
82
|
+
#drift .dr-empty p b{color:var(--ink)}
|
|
83
|
+
#drift .dr-code{display:inline-block; font-family:var(--mono); font-size:13px; color:var(--accent); background:rgba(192,193,255,.08); border:1px solid rgba(192,193,255,.16); padding:3px 8px; border-radius:6px}
|
|
84
|
+
|
|
85
|
+
#drift .card{
|
|
86
|
+
margin:0; background:
|
|
87
|
+
radial-gradient(120% 80% at 50% -10%, rgba(192,193,255,.10), transparent 60%),
|
|
88
|
+
linear-gradient(180deg,var(--card),var(--card2));
|
|
89
|
+
border:1px solid var(--line); border-radius:26px; padding:26px 22px 18px;
|
|
90
|
+
box-shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 24px 60px -20px rgba(0,0,0,.7); position:relative; overflow:hidden;
|
|
91
|
+
}
|
|
92
|
+
#drift .top{display:flex; justify-content:space-between; align-items:baseline; gap:10px}
|
|
93
|
+
#drift .brand{font-size:11px; font-weight:800; letter-spacing:.22em; color:var(--accent)}
|
|
94
|
+
#drift .range{font-size:11px; color:var(--faint); text-align:right; letter-spacing:.02em}
|
|
95
|
+
#drift .range,#drift .cmd,#drift .num,#drift .rtime,#drift .fv{font-family:var(--mono)}
|
|
96
|
+
#drift .hero{padding:22px 0 4px; text-align:center}
|
|
97
|
+
#drift .big{display:flex; align-items:flex-start; justify-content:center; line-height:.9}
|
|
98
|
+
#drift .num{font-size:112px; font-weight:800; letter-spacing:-.04em;
|
|
99
|
+
background:linear-gradient(180deg,#fff,#c9c8e6); -webkit-background-clip:text; background-clip:text; color:transparent; font-variant-numeric:tabular-nums}
|
|
100
|
+
#drift .pctsign{font-size:40px; font-weight:800; color:var(--accent); margin-top:14px; margin-left:2px}
|
|
101
|
+
#drift .cap{margin:6px 0 0; font-size:16px; color:var(--dim); line-height:1.35}
|
|
102
|
+
#drift .cap strong{color:var(--ink); font-weight:700}
|
|
103
|
+
#drift .split{margin:20px 2px 6px}
|
|
104
|
+
#drift .bar{height:12px; border-radius:8px; background:rgba(255,178,122,.22); overflow:hidden}
|
|
105
|
+
#drift .fill{display:block; height:100%; border-radius:8px 0 0 8px; background:linear-gradient(90deg,#7fe0b0,#9be8c2); box-shadow:0 0 16px rgba(127,224,176,.35)}
|
|
106
|
+
#drift .legend{display:flex; justify-content:space-between; margin-top:9px; font-size:12px; color:var(--dim)}
|
|
107
|
+
#drift .legend i{display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:5px; vertical-align:middle}
|
|
108
|
+
#drift .sw.on{background:#7fe0b0} #drift .sw.off{background:#ffb27a}
|
|
109
|
+
#drift .block{margin-top:22px}
|
|
110
|
+
#drift .block h2{margin:0 0 10px; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--faint)}
|
|
111
|
+
#drift .rows{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px}
|
|
112
|
+
#drift .row{display:flex; align-items:center; gap:10px; padding:9px 4px; border-bottom:1px solid var(--line)}
|
|
113
|
+
#drift .row:last-child{border-bottom:0}
|
|
114
|
+
#drift .dot{width:6px; height:6px; border-radius:50%; background:var(--off); flex:none; box-shadow:0 0 8px rgba(255,178,122,.5)}
|
|
115
|
+
#drift .rlabel{flex:1; min-width:0; font-size:14px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
116
|
+
#drift .rtimes{color:var(--faint); font-size:11px; margin-left:6px}
|
|
117
|
+
#drift .rtime{font-size:13px; color:var(--dim); flex:none; font-variant-numeric:tabular-nums}
|
|
118
|
+
#drift .facts{display:flex; gap:10px; margin-top:20px}
|
|
119
|
+
#drift .fact{flex:1; background:rgba(255,255,255,.03); border:1px solid var(--line); border-radius:14px; padding:12px 13px; min-width:0}
|
|
120
|
+
#drift .fk{display:block; font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint)}
|
|
121
|
+
#drift .fv{display:block; font-size:20px; font-weight:800; margin:3px 0 1px}
|
|
122
|
+
#drift .fs{display:block; min-width:0; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
123
|
+
#drift .kick{margin:22px 2px 0; font-size:15px; line-height:1.4; color:#e7d9c2}
|
|
124
|
+
#drift .foot{display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line)}
|
|
125
|
+
#drift .mover{font-size:11px; color:var(--faint); max-width:56%; line-height:1.3}
|
|
126
|
+
#drift .cmd{font-size:12px; color:var(--accent); background:rgba(192,193,255,.08); border:1px solid rgba(192,193,255,.16); padding:5px 9px; border-radius:8px; white-space:nowrap}
|
|
127
|
+
|
|
128
|
+
@media (max-width:900px){
|
|
129
|
+
#drift .dr-grid{grid-template-columns:minmax(0,1fr)}
|
|
130
|
+
#drift .card{width:100%; max-width:420px; margin:0 auto}
|
|
131
|
+
#drift .dr-side{padding-top:0}
|
|
132
|
+
}
|
|
133
|
+
@media (max-width:480px){
|
|
134
|
+
#drift .dr-day{grid-template-columns:84px minmax(40px,1fr); row-gap:4px}
|
|
135
|
+
#drift .dh{grid-column:1 / -1; text-align:left}
|
|
136
|
+
}
|
|
137
|
+
@media (max-width:420px){
|
|
138
|
+
#drift .num{font-size:96px}
|
|
139
|
+
#drift .dr-empty{padding:18px 16px}
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
function cardHtml(drift, { redact }) {
|
|
52
144
|
const pct = Math.round((drift.share || 0) * 100);
|
|
53
145
|
const { onMs, detourMs } = drift.totals;
|
|
54
146
|
const range = rangeLabel(firstDay(drift), lastDay(drift));
|
|
55
147
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
148
|
+
// Both views are embedded so the page can swap them without a reload: detours by name, and merged by category (one kind of
|
|
149
|
+
// detour is one row). With --redact the names are not written into the file at all, only the categories.
|
|
150
|
+
const byName = drift.detours.slice(0, 5).map(d => ({ label: d.name || d.category, ms: d.ms, times: d.times }));
|
|
151
|
+
const byKind = mergeByCategory(drift.detours).slice(0, 5).map(d => ({ label: d.category, ms: d.ms, times: d.times }));
|
|
60
152
|
const worst = drift.worstDay && drift.worstDay.detourMs > 0 ? drift.worstDay : null;
|
|
61
153
|
const streak = drift.streak && drift.streak.ms >= 20 * MIN ? drift.streak : null;
|
|
154
|
+
const agents = [drift.agents?.claude && 'Claude Code', drift.agents?.codex && 'Codex'].filter(Boolean).join(' + ') || 'Claude Code';
|
|
62
155
|
|
|
63
|
-
const
|
|
64
|
-
const sourceLine = sourceNote(drift);
|
|
65
|
-
|
|
66
|
-
const detourRows = detours.map((d, i) => `
|
|
156
|
+
const rows = (list, cls) => `<ul class="rows ${cls}">${list.map((d, i) => `
|
|
67
157
|
<li class="row" style="--i:${i}">
|
|
68
158
|
<span class="dot"></span>
|
|
69
159
|
<span class="rlabel">${esc(d.label)}${d.times > 1 ? `<span class="rtimes">×${d.times}</span>` : ''}</span>
|
|
70
160
|
<span class="rtime">${hours(d.ms)}</span>
|
|
71
|
-
</li>`).join('')
|
|
161
|
+
</li>`).join('')}</ul>`;
|
|
162
|
+
const detourRows = (redact ? '' : rows(byName, 'dr-w')) + rows(byKind, 'dr-c');
|
|
163
|
+
const streakName = (redact ? '' : `<span class="fs dr-w">${esc(streak?.name || streak?.category)}</span>`) + `<span class="fs dr-c">${esc(streak?.category)}</span>`;
|
|
72
164
|
|
|
73
|
-
|
|
165
|
+
return `
|
|
74
166
|
<article class="card" id="card" role="img" aria-label="Drift report: ${pct}% of agent time on plan">
|
|
75
167
|
<header class="top">
|
|
76
168
|
<span class="brand">DRIFT REPORT</span>
|
|
@@ -78,7 +170,7 @@ export function renderDrift(drift, { redact = false } = {}) {
|
|
|
78
170
|
</header>
|
|
79
171
|
|
|
80
172
|
<section class="hero">
|
|
81
|
-
<div class="big"><span class="num" id="num"
|
|
173
|
+
<div class="big"><span class="num" id="num">${pct}</span><span class="pctsign">%</span></div>
|
|
82
174
|
<p class="cap">of your agent time went to<br><strong>what you said you’d do</strong></p>
|
|
83
175
|
</section>
|
|
84
176
|
|
|
@@ -90,142 +182,110 @@ export function renderDrift(drift, { redact = false } = {}) {
|
|
|
90
182
|
</div>
|
|
91
183
|
</div>
|
|
92
184
|
|
|
93
|
-
${
|
|
185
|
+
${byKind.length ? `
|
|
94
186
|
<section class="block">
|
|
95
187
|
<h2>Where the rest went</h2>
|
|
96
|
-
|
|
188
|
+
${detourRows}
|
|
97
189
|
</section>` : ''}
|
|
98
190
|
|
|
99
191
|
<section class="facts">
|
|
100
192
|
${worst ? `<div class="fact"><span class="fk">Drifted most</span><span class="fv">${esc(weekday(worst.day))}</span><span class="fs">${Math.round(worst.share * 100)}% off plan</span></div>` : ''}
|
|
101
|
-
${streak ? `<div class="fact"><span class="fk">Longest rabbit hole</span><span class="fv">${hours(streak.ms)}</span
|
|
193
|
+
${streak ? `<div class="fact"><span class="fk">Longest rabbit hole</span><span class="fv">${hours(streak.ms)}</span>${streakName}</div>` : ''}
|
|
102
194
|
</section>
|
|
103
195
|
|
|
104
196
|
<p class="kick">${esc(kicker(drift.share || 0, drift))}</p>
|
|
105
197
|
|
|
106
198
|
<footer class="foot">
|
|
107
199
|
<span class="mover">Mover OS catches the drift while it happens.</span>
|
|
108
|
-
<span class="cmd">npx cchubber
|
|
200
|
+
<span class="cmd">npx cchubber</span>
|
|
109
201
|
</footer>
|
|
110
202
|
</article>`;
|
|
203
|
+
}
|
|
111
204
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
.
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
.
|
|
149
|
-
.
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
.facts{display:flex; gap:10px; margin-top:20px}
|
|
177
|
-
.fact{flex:1; background:rgba(255,255,255,.03); border:1px solid var(--line); border-radius:14px; padding:12px 13px; min-width:0}
|
|
178
|
-
.fk{display:block; font-size:10px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint)}
|
|
179
|
-
.fv{display:block; font-size:20px; font-weight:800; margin:3px 0 1px}
|
|
180
|
-
.fs{display:block; font-size:12px; color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
181
|
-
|
|
182
|
-
.kick{margin:22px 2px 0; font-size:15px; line-height:1.4; color:#e7d9c2}
|
|
183
|
-
|
|
184
|
-
.foot{display:flex; justify-content:space-between; align-items:center; gap:10px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line)}
|
|
185
|
-
.mover{font-size:11px; color:var(--faint); max-width:56%; line-height:1.3}
|
|
186
|
-
.cmd{font-size:12px; color:var(--accent); background:rgba(192,193,255,.08); border:1px solid rgba(192,193,255,.16); padding:5px 9px; border-radius:8px; white-space:nowrap}
|
|
187
|
-
|
|
188
|
-
.note{margin:16px 16px 0; font-size:11px; line-height:1.5; color:var(--faint); text-align:center}
|
|
189
|
-
.note b{color:var(--dim); font-weight:600}
|
|
190
|
-
|
|
191
|
-
/* Motion carries the reveal: the number counts up, the bar fills, the detours slide in. All off under reduced-motion. */
|
|
192
|
-
@media (prefers-reduced-motion: no-preference){
|
|
193
|
-
.anim .fill{animation:grow .9s cubic-bezier(.22,1,.32,1) .15s both}
|
|
194
|
-
.anim .row{opacity:0; animation:slide .5s cubic-bezier(.22,1,.32,1) both; animation-delay:calc(.5s + var(--i)*.09s)}
|
|
195
|
-
.anim .hero .cap,.anim .kick,.anim .facts,.anim .foot{opacity:0; animation:rise .6s ease .25s both}
|
|
196
|
-
.anim .kick{animation-delay:.9s} .anim .facts{animation-delay:.7s} .anim .foot{animation-delay:1.1s}
|
|
197
|
-
@keyframes grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}
|
|
198
|
-
@keyframes slide{from{opacity:0; transform:translateX(10px)}to{opacity:1; transform:none}}
|
|
199
|
-
@keyframes rise{from{opacity:0; transform:translateY(8px)}to{opacity:1; transform:none}}
|
|
205
|
+
// What to add so it works next run, in plain words. Never a number.
|
|
206
|
+
const EMPTY = {
|
|
207
|
+
'no-plan': (d) => `
|
|
208
|
+
<p><b>No written plan found, so there is nothing to measure your last ${d} days against.</b></p>
|
|
209
|
+
<p>Write what you mean to do as a checklist in a <span class="dr-code">PLAN.md</span>, <span class="dr-code">plan.md</span> or <span class="dr-code">TODO.md</span> in the project you work in, for example <span class="dr-code">- [ ] ship the login page</span>. Run cchubber again and your week will be checked against it.</p>
|
|
210
|
+
<p>If you use Mover OS, the Focus and Tasks in your Daily Note count too.</p>`,
|
|
211
|
+
'no-work': (d) => `
|
|
212
|
+
<p><b>No Claude Code or Codex work in the last ${d} days, so there is nothing to measure.</b></p>
|
|
213
|
+
<p>Use it this week, with a checklist in a <span class="dr-code">PLAN.md</span> or <span class="dr-code">TODO.md</span> in the project, and run cchubber again.</p>`,
|
|
214
|
+
'error': (d) => `
|
|
215
|
+
<p><b>Drift could not be read on this machine this time, so no number is shown.</b></p>
|
|
216
|
+
<p>Run cchubber again. Your last ${d} days are read from your own Claude Code folder and nothing is uploaded.</p>`,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// The days drift already measured, oldest first, with hours on plan and on detours. Days with no agent work are left out.
|
|
220
|
+
function dayList(drift) {
|
|
221
|
+
const days = (drift.days || []).filter(d => d.onMs + d.detourMs > 0);
|
|
222
|
+
if (!days.length) return '';
|
|
223
|
+
const busiest = Math.max(...days.map(d => d.onMs + d.detourMs));
|
|
224
|
+
const items = days.map(d => `
|
|
225
|
+
<li class="dr-day"><span class="dd">${esc(weekday(d.day).slice(0, 3))} ${esc(dateShort(d.day))}</span>
|
|
226
|
+
<span class="db" aria-hidden="true"><i class="on" style="width:${(d.onMs / busiest * 100).toFixed(1)}%"></i><i class="off" style="width:${(d.detourMs / busiest * 100).toFixed(1)}%"></i></span>
|
|
227
|
+
<span class="dh">${hours(d.onMs)} on plan · ${hours(d.detourMs)} off</span></li>`).join('');
|
|
228
|
+
return `<div>
|
|
229
|
+
<h4>Day by day</h4>
|
|
230
|
+
<ul class="dr-days">${items}</ul>
|
|
231
|
+
</div>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The drift section of the plain report. `state` comes from driftForReport: { state, days, drift }.
|
|
236
|
+
* `opts.redact` shows detour categories instead of the user's words (the old --drift --redact).
|
|
237
|
+
* Returns style + markup for one section (id="drift"), offline, nothing fetched.
|
|
238
|
+
*/
|
|
239
|
+
export function renderDriftSection(state, { redact = false } = {}) {
|
|
240
|
+
if (!state) return '';
|
|
241
|
+
const days = state.days || 7;
|
|
242
|
+
const ok = state.state === 'ok' && state.drift;
|
|
243
|
+
const range = ok ? rangeLabel(firstDay(state.drift), lastDay(state.drift)) : `last ${days} days`;
|
|
244
|
+
|
|
245
|
+
let body;
|
|
246
|
+
if (ok) {
|
|
247
|
+
const d = state.drift;
|
|
248
|
+
const t = d.totals;
|
|
249
|
+
// The split of the same sessions the total counts (sessions that had agent time), not of every transcript that was read
|
|
250
|
+
const split = [d.agents?.claude && `${d.agents.claude} Claude Code`, d.agents?.codex && `${d.agents.codex} Codex`].filter(Boolean).join(', ');
|
|
251
|
+
body = `
|
|
252
|
+
<div class="dr-grid">
|
|
253
|
+
${cardHtml(d, { redact })}
|
|
254
|
+
<div class="dr-side">
|
|
255
|
+
<div>
|
|
256
|
+
<h4>What you said you’d do</h4>
|
|
257
|
+
<p>${esc(sourceNote(d))}</p>
|
|
258
|
+
</div>
|
|
259
|
+
<div>
|
|
260
|
+
<h4>What was measured</h4>
|
|
261
|
+
<p><b>${esc(t.prompts)}</b> prompts across <b>${esc(t.sessions)}</b> session${t.sessions === 1 ? '' : 's'}${split ? ` (${esc(split)})` : ''} in the last ${days} days. Work that matched your plan counts as on plan; everything else is a detour.</p>
|
|
262
|
+
</div>
|
|
263
|
+
${dayList(d)}
|
|
264
|
+
<p class="dr-small">Everything ran on this machine. Nothing was uploaded. The method is rough: it reads your words and the files touched, so it will miscall the odd one. ${redact ? 'Detour names are hidden in this file.' : 'Detour names are short labels from your own words (paths, keys and links removed) or from the folder the work touched.'}</p>
|
|
265
|
+
</div>
|
|
266
|
+
</div>`;
|
|
267
|
+
} else {
|
|
268
|
+
body = `<div class="dr-empty">${(EMPTY[state.state] || EMPTY.error)(days)}</div>`;
|
|
200
269
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
var
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
var e=1-Math.pow(1-p,3);
|
|
221
|
-
el.textContent=Math.round(e*to);
|
|
222
|
-
if(p<1) requestAnimationFrame(tick); else el.textContent=to;
|
|
223
|
-
}
|
|
224
|
-
el.textContent='0';
|
|
225
|
-
requestAnimationFrame(tick);
|
|
226
|
-
})();
|
|
227
|
-
</script>
|
|
228
|
-
</body></html>`;
|
|
270
|
+
|
|
271
|
+
return `
|
|
272
|
+
<!-- DRIFT: your last week against what you said you'd do -->
|
|
273
|
+
<style>${CSS}</style>
|
|
274
|
+
<section id="drift" class="${redact ? 'dr-hidden' : ''}" aria-label="Your week against your plan">
|
|
275
|
+
<div class="dr-head"><h3 class="dr-title">Your week against your plan</h3><span class="dr-range">${esc(range)}</span></div>
|
|
276
|
+
${ok && !redact ? `<p class="dr-tools"><button type="button" class="dr-btn" id="dr-hide" aria-pressed="false">Hide my words</button><span class="dr-hint" id="dr-hint">For sharing a screenshot: swaps every detour name for its kind.</span></p>` : ''}
|
|
277
|
+
${body}
|
|
278
|
+
</section>
|
|
279
|
+
<script id="dr-js">(function(){
|
|
280
|
+
var box = document.getElementById('drift'), btn = document.getElementById('dr-hide');
|
|
281
|
+
if (btn) btn.addEventListener('click', function(){
|
|
282
|
+
var hidden = box.classList.toggle('dr-hidden');
|
|
283
|
+
btn.setAttribute('aria-pressed', hidden ? 'true' : 'false');
|
|
284
|
+
btn.textContent = hidden ? 'Show my words' : 'Hide my words';
|
|
285
|
+
});
|
|
286
|
+
if (location.hash === '#drift') window.addEventListener('load', function(){ setTimeout(function(){ box.scrollIntoView(); }, 60); });
|
|
287
|
+
})();</script>
|
|
288
|
+
`;
|
|
229
289
|
}
|
|
230
290
|
|
|
231
291
|
// For --redact: one row per category, times and minutes summed, biggest first
|
|
@@ -3,6 +3,7 @@ import { join, dirname } from 'path';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { renderVsBlock } from './vs-report.js';
|
|
5
5
|
import { renderSpentWell } from './spent-well.js';
|
|
6
|
+
import { renderDriftSection } from './drift-report.js';
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
@@ -41,6 +42,9 @@ export function renderHTML(report, opts = {}) {
|
|
|
41
42
|
// "You burnt N tokens. Did you spend them well?": the strip between the grade card and the race.
|
|
42
43
|
const swBlock = renderSpentWell(report, opts.telemetry || { on: false });
|
|
43
44
|
|
|
45
|
+
// Drift (your last week against what you wrote down), directly under that strip so the two read together.
|
|
46
|
+
const driftBlock = opts.drift ? renderDriftSection(opts.drift, { redact: !!opts.drift.redact }) : '';
|
|
47
|
+
|
|
44
48
|
const fmtCost = (n) => '$' + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
|
|
45
49
|
const fmtDuration = (m) => m >= 120 ? Math.round(m/60) + 'h' : m >= 60 ? (m/60).toFixed(1) + 'h' : m + 'm';
|
|
46
50
|
|
|
@@ -267,6 +271,7 @@ export function renderHTML(report, opts = {}) {
|
|
|
267
271
|
.cc-card .cc-stats > div{text-align:left;min-width:0}
|
|
268
272
|
.cc-card .cc-stats > div:first-child{flex:1 0 100%}
|
|
269
273
|
.cc-card .cc-stats p.font-mono{font-size:32px;overflow-wrap:anywhere}
|
|
274
|
+
.cc-card .cc-meta{white-space:nowrap;flex-shrink:0;margin-left:12px}
|
|
270
275
|
.cc-card .cc-foot{flex-direction:column;align-items:flex-start;gap:12px}
|
|
271
276
|
.cc-card .cc-foot > div{flex-wrap:wrap}
|
|
272
277
|
}
|
|
@@ -283,7 +288,7 @@ export function renderHTML(report, opts = {}) {
|
|
|
283
288
|
<span class="text-xl font-bold text-[#e3e2e3]">${gradeLabel}</span>
|
|
284
289
|
</div>
|
|
285
290
|
</div>
|
|
286
|
-
<div class="text-right">
|
|
291
|
+
<div class="text-right cc-meta">
|
|
287
292
|
<span class="text-[11px] font-mono uppercase tracking-[0.1em] text-[#908fa0] font-bold block">Claude Code</span>
|
|
288
293
|
<span class="text-[11px] font-mono uppercase tracking-[0.06em] text-[#596678] block" id="card-range">All time</span>
|
|
289
294
|
</div>
|
|
@@ -324,6 +329,7 @@ export function renderHTML(report, opts = {}) {
|
|
|
324
329
|
</section>
|
|
325
330
|
|
|
326
331
|
${swBlock}
|
|
332
|
+
${driftBlock}
|
|
327
333
|
${vsBlock}
|
|
328
334
|
|
|
329
335
|
${inflection && inflection.multiplier >= 1.5 ? `
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'fs';
|
|
2
|
-
import { join, dirname } from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
import { monthsBilled } from '../readers/plan.js';
|
|
5
|
-
|
|
6
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
8
|
-
|
|
9
|
-
function esc(s) {
|
|
10
|
-
if (!s) return '';
|
|
11
|
-
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
15
|
-
const WORDS = ['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve'];
|
|
16
|
-
const fmtUSD = (n) => '$' + (n >= 1000 || Number.isInteger(n) ? Math.round(n).toLocaleString('en-US') : n.toFixed(2));
|
|
17
|
-
const fmtDate = (iso) => { const d = new Date(iso + 'T00:00:00Z'); return isNaN(d) ? iso : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; };
|
|
18
|
-
const fmtBig = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' billion' : n >= 1e6 ? (n / 1e6).toFixed(1) + ' million' : Math.round(n).toLocaleString('en-US');
|
|
19
|
-
const word = (n) => n < WORDS.length ? WORDS[n] : String(n);
|
|
20
|
-
|
|
21
|
-
function familyShares(modelCosts) {
|
|
22
|
-
const fam = {};
|
|
23
|
-
let total = 0;
|
|
24
|
-
for (const [name, cost] of Object.entries(modelCosts || {})) {
|
|
25
|
-
if (!(cost > 0)) continue;
|
|
26
|
-
const f = /opus/i.test(name) ? 'Opus' : /sonnet/i.test(name) ? 'Sonnet' : /haiku/i.test(name) ? 'Haiku' : /fable/i.test(name) ? 'Fable' : null;
|
|
27
|
-
if (!f) continue;
|
|
28
|
-
fam[f] = (fam[f] || 0) + cost;
|
|
29
|
-
total += cost;
|
|
30
|
-
}
|
|
31
|
-
return Object.entries(fam).map(([f, c]) => ({ family: f, pct: total ? Math.round(c / total * 100) : 0 })).sort((a, b) => b.pct - a.pct);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Each risk factor is a true sentence about this user's own logs, and it only appears when its data clears the bar.
|
|
35
|
-
function riskFactors(report, plan) {
|
|
36
|
-
const { costAnalysis, cacheHealth, inflection, sessionIntel } = report;
|
|
37
|
-
const out = [];
|
|
38
|
-
const top = familyShares(costAnalysis.modelCosts)[0];
|
|
39
|
-
if (top && top.pct >= 50) out.push([`We depend on a single supplier.`, `All of our spend went to Anthropic, ${top.pct}% of it to ${top.family}.`]);
|
|
40
|
-
const peak = costAnalysis.peakDay;
|
|
41
|
-
if (peak && plan && peak.cost >= plan.monthlyUSD) {
|
|
42
|
-
const n = Math.floor(peak.cost / plan.monthlyUSD);
|
|
43
|
-
out.push([`One day cost more than ${word(n)} month${n === 1 ? '' : 's'} of our subscription.`, `On ${fmtDate(peak.date)}, we consumed ${fmtUSD(peak.cost)} of Claude across ${peak.messageCount.toLocaleString('en-US')} replies.`]);
|
|
44
|
-
} else if (peak) {
|
|
45
|
-
out.push([`Our costs are concentrated.`, `On ${fmtDate(peak.date)} alone, we consumed ${fmtUSD(peak.cost)} of Claude.`]);
|
|
46
|
-
}
|
|
47
|
-
const hours = sessionIntel?.hourDistribution;
|
|
48
|
-
if (Array.isArray(hours) && hours.length === 24) {
|
|
49
|
-
const total = hours.reduce((a, b) => a + b, 0);
|
|
50
|
-
const night = hours.slice(0, 5).reduce((a, b) => a + b, 0);
|
|
51
|
-
const peakHour = hours.indexOf(Math.max(...hours));
|
|
52
|
-
const label = peakHour === 0 ? 'midnight' : peakHour === 12 ? 'noon' : peakHour < 12 ? `${peakHour}am` : `${peakHour - 12}pm`;
|
|
53
|
-
if (total && night / total >= 0.2) out.push([`We do our best work at ${label}.`, `${Math.round(night / total * 100)}% of our Claude usage happened between midnight and 5am.`]);
|
|
54
|
-
}
|
|
55
|
-
if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
|
|
56
|
-
out.push([`Our cache efficiency fell ${inflection.multiplier}x on ${fmtDate(inflection.date)}, and we kept going.`, `The week before, we re-read ${inflection.beforeRatio.toLocaleString('en-US')} tokens for every token we got back. The week after, ${inflection.afterRatio.toLocaleString('en-US')}.`]);
|
|
57
|
-
}
|
|
58
|
-
const saved = cacheHealth?.savings?.fromCaching || 0;
|
|
59
|
-
if (saved > costAnalysis.totalCost) {
|
|
60
|
-
// cache-health prices every cached token at Opus rates, so this is an estimate and is shown rounded
|
|
61
|
-
out.push([`Caching is the only thing keeping this affordable.`, `Without it, the same work would have cost roughly ${fmtUSD(Math.round((costAnalysis.totalCost + saved) / 1000) * 1000)} at list price.`]);
|
|
62
|
-
}
|
|
63
|
-
const maxMin = sessionIntel?.maxDuration || 0;
|
|
64
|
-
if (maxMin >= 1440) out.push([`Our longest session spanned ${word(Math.round(maxMin / 1440))} days.`, `We did not close the terminal.`]);
|
|
65
|
-
return out.slice(0, 5);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function renderS1(report, { plan = null, name = null, showProject = false } = {}) {
|
|
69
|
-
const { costAnalysis, cacheHealth, generatedAt } = report;
|
|
70
|
-
const days = costAnalysis.dailyCosts || [];
|
|
71
|
-
const first = days[0]?.date, last = days[days.length - 1]?.date;
|
|
72
|
-
const consumed = costAnalysis.totalCost || 0;
|
|
73
|
-
const months = first && last ? monthsBilled(first, last) : 1;
|
|
74
|
-
const paid = plan ? plan.monthlyUSD * months : null;
|
|
75
|
-
const multiple = paid ? consumed / paid : null;
|
|
76
|
-
const company = (name ? String(name).trim() : 'You').toUpperCase() + ', INC.';
|
|
77
|
-
const dated = fmtDate((generatedAt || new Date().toISOString()).slice(0, 10));
|
|
78
|
-
const cacheRead = costAnalysis.totals?.cacheReadTokens ?? cacheHealth?.totals?.cacheRead ?? 0;
|
|
79
|
-
const peak = costAnalysis.peakDay;
|
|
80
|
-
const risks = riskFactors(report, plan);
|
|
81
|
-
// Use of proceeds names the top project by output, only when asked, since repo names can be private
|
|
82
|
-
let proceeds = null;
|
|
83
|
-
if (showProject && report.projectBreakdown?.length) {
|
|
84
|
-
const byOut = [...report.projectBreakdown].sort((x, y) => (y.outputTokens || 0) - (x.outputTokens || 0));
|
|
85
|
-
const all = byOut.reduce((t, p) => t + (p.outputTokens || 0), 0);
|
|
86
|
-
if (all && byOut[0].name) proceeds = { name: byOut[0].name, pct: Math.round((byOut[0].outputTokens || 0) / all * 100) };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const rows = [
|
|
90
|
-
paid !== null ? ['Paid to Anthropic', `${fmtUSD(paid)}`, `${esc(plan.name)}, ${months} month${months === 1 ? '' : 's'}`] : null,
|
|
91
|
-
['Claude consumed at API list prices', fmtUSD(consumed), `${costAnalysis.activeDays} active days`],
|
|
92
|
-
cacheRead ? ['Cached tokens read', fmtBig(cacheRead), ''] : null,
|
|
93
|
-
peak ? ['Largest single day', fmtUSD(peak.cost), fmtDate(peak.date)] : null,
|
|
94
|
-
].filter(Boolean);
|
|
95
|
-
|
|
96
|
-
return `<!doctype html>
|
|
97
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
98
|
-
<title>${esc(company)} Form S-1 (parody) | CC Hubber</title>
|
|
99
|
-
<style>
|
|
100
|
-
:root { --ink:#111; --paper:#fff; --desk:#e8e5de; --red:#c1121f; --rule:#111; }
|
|
101
|
-
* { box-sizing:border-box; }
|
|
102
|
-
body { margin:0; background:var(--desk); color:var(--ink); font-family:"Times New Roman", Times, "Liberation Serif", serif; }
|
|
103
|
-
.wrap { padding:40px 16px 64px; display:flex; justify-content:center; }
|
|
104
|
-
.paper { position:relative; width:820px; max-width:100%; background:var(--paper); padding:56px 64px 44px 92px; box-shadow:0 1px 2px rgba(0,0,0,.08), 0 12px 40px rgba(60,50,30,.14); }
|
|
105
|
-
.herring { position:absolute; left:26px; top:56px; bottom:44px; width:40px; writing-mode:vertical-rl; transform:rotate(180deg); color:var(--red); font-size:10.5px; line-height:1.35; text-align:center; font-style:italic; }
|
|
106
|
-
.dated { text-align:right; font-size:12px; font-style:italic; margin:0 0 26px; }
|
|
107
|
-
.c { text-align:center; }
|
|
108
|
-
.agency { font-size:13px; font-weight:700; letter-spacing:.08em; line-height:1.5; }
|
|
109
|
-
.form { font-size:26px; font-weight:700; letter-spacing:.14em; margin:14px 0 2px; }
|
|
110
|
-
.act { font-size:12px; font-weight:700; letter-spacing:.06em; }
|
|
111
|
-
hr { border:0; border-top:1.5px solid var(--rule); margin:18px 0; }
|
|
112
|
-
hr.thin { border-top-width:.75px; margin:12px 0; }
|
|
113
|
-
.prospectus { font-size:13px; font-weight:700; letter-spacing:.3em; margin-top:22px; }
|
|
114
|
-
.co { font-size:44px; font-weight:700; letter-spacing:.02em; margin:6px 0 2px; line-height:1.05; }
|
|
115
|
-
.exact { font-size:12px; font-style:italic; }
|
|
116
|
-
.hero { margin:30px 0 8px; }
|
|
117
|
-
.hero .n { font-size:78px; font-weight:700; letter-spacing:-.01em; line-height:1; }
|
|
118
|
-
.hero .k { font-size:15px; margin-top:8px; }
|
|
119
|
-
.hero .x { display:inline-block; margin-top:16px; font-size:22px; font-weight:700; border:1.5px solid var(--ink); padding:4px 14px; }
|
|
120
|
-
table { width:100%; border-collapse:collapse; font-size:15px; margin:6px 0 2px; }
|
|
121
|
-
td { padding:6px 0; vertical-align:baseline; }
|
|
122
|
-
td.l { width:auto; }
|
|
123
|
-
td.l span { background:var(--paper); padding-right:6px; }
|
|
124
|
-
td.l::after { content:""; }
|
|
125
|
-
td.v { text-align:right; font-weight:700; white-space:nowrap; padding-left:10px; width:1%; }
|
|
126
|
-
td.m { text-align:right; font-size:12.5px; font-style:italic; color:#333; white-space:nowrap; padding-left:14px; width:1%; }
|
|
127
|
-
.lead { position:relative; }
|
|
128
|
-
.lead::before { content:""; position:absolute; left:0; right:0; bottom:10px; border-bottom:1px dotted #777; z-index:0; }
|
|
129
|
-
.lead span { position:relative; z-index:1; }
|
|
130
|
-
h2 { font-size:15px; font-weight:700; letter-spacing:.18em; text-align:center; margin:26px 0 10px; }
|
|
131
|
-
.risk { font-size:15px; line-height:1.45; margin:0 0 10px; }
|
|
132
|
-
.risk b { font-style:italic; }
|
|
133
|
-
.foot { font-size:11.5px; line-height:1.45; color:#333; margin-top:22px; }
|
|
134
|
-
.cmd { font-family:ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:13px; background:#f3f1ec; padding:2px 7px; border-radius:3px; color:#111; }
|
|
135
|
-
.gen { display:flex; justify-content:space-between; align-items:baseline; margin-top:14px; font-size:12.5px; }
|
|
136
|
-
@media (max-width:640px) {
|
|
137
|
-
.paper { padding:36px 20px 30px 52px; }
|
|
138
|
-
.herring { left:12px; width:30px; font-size:9px; }
|
|
139
|
-
.co { font-size:30px; } .hero .n { font-size:52px; } .form { font-size:21px; }
|
|
140
|
-
table { font-size:13.5px; } td.m { display:none; }
|
|
141
|
-
}
|
|
142
|
-
</style></head>
|
|
143
|
-
<body><div class="wrap"><article class="paper" id="s1">
|
|
144
|
-
<div class="herring">The information in this prospectus is not complete and may be changed. We may not stop until the weekly limit resets. This prospectus is not an offer to sell anything and is not soliciting an offer to buy anything. It is a receipt.</div>
|
|
145
|
-
<p class="dated">Subject to Completion. Preliminary Prospectus dated ${esc(dated)}</p>
|
|
146
|
-
<div class="c agency">UNITED STATES<br>TOKENS AND EXCHANGE COMMISSION<br><span style="font-weight:400;font-style:italic;letter-spacing:0">~/.claude, Your Terminal</span></div>
|
|
147
|
-
<div class="c form">FORM S-1</div>
|
|
148
|
-
<div class="c act">REGISTRATION STATEMENT UNDER THE CLAUDE CODE ACT</div>
|
|
149
|
-
<hr>
|
|
150
|
-
<div class="c prospectus">PROSPECTUS</div>
|
|
151
|
-
<div class="c co">${esc(company)}</div>
|
|
152
|
-
<div class="c exact">(Exact name of heavy user as specified in its CLAUDE.md)</div>
|
|
153
|
-
<div class="c hero">
|
|
154
|
-
<div class="n">${fmtUSD(consumed)}</div>
|
|
155
|
-
<div class="k">of Claude consumed at API list prices${paid !== null ? `, against ${fmtUSD(paid)} paid` : ''}</div>
|
|
156
|
-
${multiple ? `<div class="x">${multiple >= 10 ? Math.round(multiple) : multiple.toFixed(1)}× what we paid</div>` : ''}
|
|
157
|
-
</div>
|
|
158
|
-
<hr class="thin">
|
|
159
|
-
<table>${rows.map(r => `<tr><td class="l lead"><span>${esc(r[0])}</span></td><td class="v">${esc(r[1])}</td><td class="m">${r[2]}</td></tr>`).join('')}</table>
|
|
160
|
-
${proceeds ? `<h2>USE OF PROCEEDS</h2><p class="risk" style="text-align:center">${proceeds.pct}% of our output went to one project: <b style="font-style:normal">${esc(proceeds.name)}</b>.</p>` : ''}
|
|
161
|
-
${risks.length ? `<h2>RISK FACTORS</h2>${risks.map(([h, b]) => `<p class="risk"><b>${esc(h)}</b> ${esc(b)}</p>`).join('')}` : ''}
|
|
162
|
-
<hr class="thin">
|
|
163
|
-
<p class="foot">Anthropic’s own prospectus, as reported by Reuters on September 28, 2026, says two customers made up nearly a quarter of its 2025 revenue. We are not one of them. Figures come from this machine’s Claude Code logs${first ? ` (${esc(fmtDate(first))} to ${esc(fmtDate(last))})` : ''}, priced at public API rates, which are not Anthropic’s own costs. This is a parody, not a securities filing.</p>
|
|
164
|
-
<div class="gen"><span>Get yours: <span class="cmd">npx cchubber --s1</span></span><span style="font-style:italic">CC Hubber v${esc(PKG_VERSION)}</span></div>
|
|
165
|
-
</article></div></body></html>`;
|
|
166
|
-
}
|