cchubber 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +102 -0
- package/src/analyzers/drift-run.js +34 -0
- package/src/analyzers/drift.js +454 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +159 -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 +255 -0
- package/src/renderers/html-report.js +1301 -1280
- package/src/renderers/s1-report.js +166 -0
- 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
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
function esc(s) {
|
|
9
|
+
if (s === 0) return '0';
|
|
10
|
+
if (!s) return '';
|
|
11
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MIN = 60000;
|
|
15
|
+
const WEEKDAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
16
|
+
const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
17
|
+
|
|
18
|
+
const hours = (ms) => {
|
|
19
|
+
const h = ms / 3600000;
|
|
20
|
+
if (h >= 10) return `${Math.round(h)}h`;
|
|
21
|
+
if (h >= 1) return `${h.toFixed(1).replace(/\.0$/, '')}h`;
|
|
22
|
+
return `${Math.max(1, Math.round(ms / MIN))}m`;
|
|
23
|
+
};
|
|
24
|
+
const dateShort = (iso) => { const d = new Date(iso + 'T00:00:00'); return `${MONTH[d.getMonth()]} ${d.getDate()}`; };
|
|
25
|
+
const weekday = (iso) => WEEKDAY[new Date(iso + 'T00:00:00').getDay()];
|
|
26
|
+
// "Sep 22–29" when one month, "Sep 28 – Oct 4" across two
|
|
27
|
+
function rangeLabel(a, b) {
|
|
28
|
+
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()}–${db.getDate()}` : `${dateShort(a)} – ${dateShort(b)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A closing line with a bit of bite, in his register: lowercase, no wind-up, takes the reader's side against the
|
|
33
|
+
// drift. Built from how the week actually went (the worst day, the kind of detour), never a stock quip.
|
|
34
|
+
function kicker(share, drift) {
|
|
35
|
+
const pct = Math.round(share * 100);
|
|
36
|
+
const off = 100 - pct;
|
|
37
|
+
const worst = drift.worstDay && drift.worstDay.detourMs > 0 ? weekday(drift.worstDay.day).toLowerCase() : null;
|
|
38
|
+
const cat = drift.detours[0]?.category;
|
|
39
|
+
const chase = cat && cat !== 'something else' ? cat : 'a tangent';
|
|
40
|
+
if (pct >= 85) return `${pct}% on plan. who are you and what did you do with the tabs.`;
|
|
41
|
+
if (pct >= 60) return worst ? `${pct}% on plan. then ${worst} showed up and you chased ${chase} till 1am.` : `${pct}% on plan. the other ${off}% was 1am you, freelancing.`;
|
|
42
|
+
if (pct >= 40) return worst ? `half the plan, half whatever ${worst} was. ${chase}, mostly.` : `half the plan, half opinions. the plan didn't win the tie.`;
|
|
43
|
+
if (pct >= 20) return `${pct}% on plan. the plan was more of a vibe this week.`;
|
|
44
|
+
return `${pct}%. the plan filed a missing person report.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The drift card: phone-first, built to screenshot. Fully offline, no web fonts, no network.
|
|
49
|
+
* `drift` is the analysis from analyzeDrift; `opts.redact` shows detour categories instead of the user's words.
|
|
50
|
+
*/
|
|
51
|
+
export function renderDrift(drift, { redact = false } = {}) {
|
|
52
|
+
const pct = Math.round((drift.share || 0) * 100);
|
|
53
|
+
const { onMs, detourMs } = drift.totals;
|
|
54
|
+
const range = rangeLabel(firstDay(drift), lastDay(drift));
|
|
55
|
+
|
|
56
|
+
// Names by default; with --redact, categories, merged so one kind of detour is one row
|
|
57
|
+
const detours = (redact ? mergeByCategory(drift.detours) : drift.detours)
|
|
58
|
+
.slice(0, 5)
|
|
59
|
+
.map(d => ({ label: redact ? d.category : (d.name || d.category), ms: d.ms, times: d.times }));
|
|
60
|
+
const worst = drift.worstDay && drift.worstDay.detourMs > 0 ? drift.worstDay : null;
|
|
61
|
+
const streak = drift.streak && drift.streak.ms >= 20 * MIN ? drift.streak : null;
|
|
62
|
+
|
|
63
|
+
const agents = [drift.inputs?.claude && 'Claude Code', drift.inputs?.codex && 'Codex'].filter(Boolean).join(' + ') || 'Claude Code';
|
|
64
|
+
const sourceLine = sourceNote(drift);
|
|
65
|
+
|
|
66
|
+
const detourRows = detours.map((d, i) => `
|
|
67
|
+
<li class="row" style="--i:${i}">
|
|
68
|
+
<span class="dot"></span>
|
|
69
|
+
<span class="rlabel">${esc(d.label)}${d.times > 1 ? `<span class="rtimes">×${d.times}</span>` : ''}</span>
|
|
70
|
+
<span class="rtime">${hours(d.ms)}</span>
|
|
71
|
+
</li>`).join('');
|
|
72
|
+
|
|
73
|
+
const CARD = `
|
|
74
|
+
<article class="card" id="card" role="img" aria-label="Drift report: ${pct}% of agent time on plan">
|
|
75
|
+
<header class="top">
|
|
76
|
+
<span class="brand">DRIFT REPORT</span>
|
|
77
|
+
<span class="range">${esc(agents)}<br>${esc(range)}</span>
|
|
78
|
+
</header>
|
|
79
|
+
|
|
80
|
+
<section class="hero">
|
|
81
|
+
<div class="big"><span class="num" id="num" data-to="${pct}">${pct}</span><span class="pctsign">%</span></div>
|
|
82
|
+
<p class="cap">of your agent time went to<br><strong>what you said you’d do</strong></p>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<div class="split" aria-hidden="true">
|
|
86
|
+
<div class="bar"><span class="fill" id="fill" style="width:${pct}%"></span></div>
|
|
87
|
+
<div class="legend">
|
|
88
|
+
<span><i class="sw on"></i> On plan · ${hours(onMs)}</span>
|
|
89
|
+
<span><i class="sw off"></i> Detours · ${hours(detourMs)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
${detours.length ? `
|
|
94
|
+
<section class="block">
|
|
95
|
+
<h2>Where the rest went</h2>
|
|
96
|
+
<ul class="rows">${detourRows}</ul>
|
|
97
|
+
</section>` : ''}
|
|
98
|
+
|
|
99
|
+
<section class="facts">
|
|
100
|
+
${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><span class="fs">${redact ? esc(streak.category) : esc(streak.name || streak.category)}</span></div>` : ''}
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<p class="kick">${esc(kicker(drift.share || 0, drift))}</p>
|
|
105
|
+
|
|
106
|
+
<footer class="foot">
|
|
107
|
+
<span class="mover">Mover OS catches the drift while it happens.</span>
|
|
108
|
+
<span class="cmd">npx cchubber --drift</span>
|
|
109
|
+
</footer>
|
|
110
|
+
</article>`;
|
|
111
|
+
|
|
112
|
+
return `<!doctype html>
|
|
113
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
114
|
+
<title>Drift Report · CC Hubber</title>
|
|
115
|
+
<meta name="description" content="How much of your Claude Code week went to what you said you'd do. Runs locally, nothing leaves your machine.">
|
|
116
|
+
<style>
|
|
117
|
+
:root{
|
|
118
|
+
--bg:#0e0f13; --card:#15161c; --card2:#191b22; --ink:#ecebf1; --dim:#9a99a8; --faint:#6a6a78;
|
|
119
|
+
--line:rgba(255,255,255,.07); --on:#7fe0b0; --off:#ffb27a; --accent:#c0c1ff;
|
|
120
|
+
--shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 24px 60px -20px rgba(0,0,0,.7);
|
|
121
|
+
}
|
|
122
|
+
*{box-sizing:border-box}
|
|
123
|
+
html,body{margin:0}
|
|
124
|
+
body{
|
|
125
|
+
background:var(--bg); color:var(--ink);
|
|
126
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,system-ui,sans-serif;
|
|
127
|
+
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
|
128
|
+
padding:0; min-height:100dvh;
|
|
129
|
+
}
|
|
130
|
+
.page{max-width:390px; margin:0 auto; padding:18px 0 26px}
|
|
131
|
+
.mono{font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace}
|
|
132
|
+
.card{
|
|
133
|
+
margin:0 14px; background:
|
|
134
|
+
radial-gradient(120% 80% at 50% -10%, rgba(192,193,255,.10), transparent 60%),
|
|
135
|
+
linear-gradient(180deg,var(--card),var(--card2));
|
|
136
|
+
border:1px solid var(--line); border-radius:26px; padding:26px 22px 18px;
|
|
137
|
+
box-shadow:var(--shadow); position:relative; overflow:hidden;
|
|
138
|
+
}
|
|
139
|
+
.card::after{ /* faint grain, fixed layer, never animated */
|
|
140
|
+
content:""; position:absolute; inset:0; pointer-events:none; opacity:.03; mix-blend-mode:overlay;
|
|
141
|
+
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
142
|
+
}
|
|
143
|
+
.top{display:flex; justify-content:space-between; align-items:baseline; gap:10px}
|
|
144
|
+
.brand{font-size:11px; font-weight:800; letter-spacing:.22em; color:var(--accent)}
|
|
145
|
+
.range{font-size:11px; color:var(--faint); text-align:right; letter-spacing:.02em}
|
|
146
|
+
.range,.cmd,.num,.rtime,.fv{font-family:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace}
|
|
147
|
+
|
|
148
|
+
.hero{padding:22px 0 4px; text-align:center}
|
|
149
|
+
.big{display:flex; align-items:flex-start; justify-content:center; line-height:.9}
|
|
150
|
+
.num{font-size:112px; font-weight:800; letter-spacing:-.04em;
|
|
151
|
+
background:linear-gradient(180deg,#fff,#c9c8e6); -webkit-background-clip:text; background-clip:text; color:transparent;
|
|
152
|
+
font-variant-numeric:tabular-nums}
|
|
153
|
+
.pctsign{font-size:40px; font-weight:800; color:var(--accent); margin-top:14px; margin-left:2px}
|
|
154
|
+
.cap{margin:6px 0 0; font-size:16px; color:var(--dim); line-height:1.35}
|
|
155
|
+
.cap strong{color:var(--ink); font-weight:700}
|
|
156
|
+
|
|
157
|
+
.split{margin:20px 2px 6px}
|
|
158
|
+
.bar{height:12px; border-radius:8px; background:rgba(255,178,122,.22); overflow:hidden}
|
|
159
|
+
.fill{display:block; height:100%; border-radius:8px 0 0 8px;
|
|
160
|
+
background:linear-gradient(90deg,#7fe0b0,#9be8c2); box-shadow:0 0 16px rgba(127,224,176,.35);
|
|
161
|
+
transform-origin:left center}
|
|
162
|
+
.legend{display:flex; justify-content:space-between; margin-top:9px; font-size:12px; color:var(--dim)}
|
|
163
|
+
.legend i{display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:5px; vertical-align:middle}
|
|
164
|
+
.sw.on{background:#7fe0b0} .sw.off{background:#ffb27a}
|
|
165
|
+
|
|
166
|
+
.block{margin-top:22px}
|
|
167
|
+
h2{margin:0 0 10px; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--faint)}
|
|
168
|
+
.rows{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px}
|
|
169
|
+
.row{display:flex; align-items:center; gap:10px; padding:9px 4px; border-bottom:1px solid var(--line)}
|
|
170
|
+
.row:last-child{border-bottom:0}
|
|
171
|
+
.dot{width:6px; height:6px; border-radius:50%; background:var(--off); flex:none; box-shadow:0 0 8px rgba(255,178,122,.5)}
|
|
172
|
+
.rlabel{flex:1; font-size:14px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
|
|
173
|
+
.rtimes{color:var(--faint); font-size:11px; margin-left:6px}
|
|
174
|
+
.rtime{font-size:13px; color:var(--dim); flex:none; font-variant-numeric:tabular-nums}
|
|
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}}
|
|
200
|
+
}
|
|
201
|
+
</style></head>
|
|
202
|
+
<body>
|
|
203
|
+
<div class="page">
|
|
204
|
+
${CARD}
|
|
205
|
+
<p class="note">${esc(sourceLine)} <b>Everything ran on this machine. Nothing was uploaded.</b> The method is rough: it reads your words and the files touched, so it will miscall the odd one.</p>
|
|
206
|
+
</div>
|
|
207
|
+
<script>
|
|
208
|
+
(function(){
|
|
209
|
+
var el=document.getElementById('num'); if(!el) return;
|
|
210
|
+
var to=+el.getAttribute('data-to')||0;
|
|
211
|
+
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
212
|
+
// #shot renders the finished card in one frame, for a clean screenshot; otherwise motion plays once
|
|
213
|
+
var shot=/shot/.test(location.hash);
|
|
214
|
+
if(!reduce&&!shot) document.body.classList.add('anim');
|
|
215
|
+
if(reduce||shot||to<=0){el.textContent=to; return;}
|
|
216
|
+
var start=null, dur=1100;
|
|
217
|
+
function tick(ts){
|
|
218
|
+
if(start===null) start=ts;
|
|
219
|
+
var p=Math.min(1,(ts-start)/dur);
|
|
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>`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// For --redact: one row per category, times and minutes summed, biggest first
|
|
232
|
+
function mergeByCategory(detours) {
|
|
233
|
+
const by = new Map();
|
|
234
|
+
for (const d of detours) {
|
|
235
|
+
const c = by.get(d.category) || { category: d.category, ms: 0, times: 0 };
|
|
236
|
+
c.ms += d.ms; c.times += d.times;
|
|
237
|
+
by.set(d.category, c);
|
|
238
|
+
}
|
|
239
|
+
return [...by.values()].sort((a, b) => b.ms - a.ms);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function firstDay(drift) { return drift.days?.[0]?.day || new Date(drift.since).toISOString().slice(0, 10); }
|
|
243
|
+
function lastDay(drift) { const d = drift.days; return (d && d[d.length - 1]?.day) || new Date(drift.until).toISOString().slice(0, 10); }
|
|
244
|
+
|
|
245
|
+
// Plain text; the template escapes it. Says which sources set "what you said you'd do".
|
|
246
|
+
function sourceNote(drift) {
|
|
247
|
+
const s = drift.source || {};
|
|
248
|
+
const names = s.planNames || [];
|
|
249
|
+
if (names.length) {
|
|
250
|
+
const where = names.join(' and ');
|
|
251
|
+
const scope = s.scope === 'project' ? ', so on-plan means on the project' : '';
|
|
252
|
+
return `Measured against ${where}${scope}, then your first ask of each session where there was no plan.`;
|
|
253
|
+
}
|
|
254
|
+
return 'Measured against the first thing you asked in each session.';
|
|
255
|
+
}
|