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.
@@ -0,0 +1,259 @@
1
+ import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+ import { homedir } from 'os';
4
+ import { projectRoots } from './jsonl-reader.js';
5
+
6
+ /**
7
+ * Transcripts for the drift report: what the user asked, and when the agent was working.
8
+ * Reads Claude Code sessions (~/.claude/projects) and Codex rollouts (~/.codex/sessions).
9
+ *
10
+ * Each session comes back as time-ordered events:
11
+ * { t, kind: 'prompt', text, cwd } something the user typed
12
+ * { t, kind: 'auto' } work started by a script or hook, not by the user
13
+ * { t, kind: 'step', words, files } the agent working: its visible reply text, and the files its tools named
14
+ */
15
+
16
+ const DAY = 86400000;
17
+
18
+ // Files are read in chunks, never whole: one long-running session can pass 800 MB, and a JS string stops at ~512 MB.
19
+ export function forEachLine(filePath, fn) {
20
+ let fd;
21
+ try { fd = openSync(filePath, 'r'); } catch { return; }
22
+ const buf = Buffer.allocUnsafe(1 << 22);
23
+ let carry = null;
24
+ try {
25
+ let n;
26
+ while ((n = readSync(fd, buf, 0, buf.length, null)) > 0) {
27
+ let start = 0;
28
+ for (;;) {
29
+ const nl = buf.indexOf(10, start);
30
+ if (nl === -1 || nl >= n) break;
31
+ const piece = buf.subarray(start, nl);
32
+ const line = carry ? Buffer.concat([carry, piece]) : piece;
33
+ carry = null;
34
+ fn(line);
35
+ start = nl + 1;
36
+ }
37
+ if (start < n) {
38
+ const rest = Buffer.from(buf.subarray(start, n)); // copied, because buf is reused by the next read
39
+ carry = carry ? Buffer.concat([carry, rest]) : rest;
40
+ }
41
+ }
42
+ if (carry && carry.length) fn(carry);
43
+ } catch { /* a file that fails mid-read keeps what was read */ }
44
+ finally { closeSync(fd); }
45
+ }
46
+
47
+ export function defaultCodexDir() {
48
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
49
+ }
50
+
51
+ /**
52
+ * Sessions with activity between `since` and `until` (ms). Events from up to `lookbackMs` earlier are kept, so a
53
+ * session resumed inside the window still knows what it was opened for.
54
+ */
55
+ export function readTranscripts({ claudeDir, codexDir = defaultCodexDir(), since, until = Date.now(), lookbackMs = 2 * DAY } = {}) {
56
+ const from = since - lookbackMs;
57
+ const sessions = [];
58
+ const seen = new Set(); // resumed sessions copy earlier records into the new file; each record counts once
59
+
60
+ for (const root of claudeDir ? projectRoots(claudeDir) : []) {
61
+ for (const file of recentFiles(root, since, 1)) {
62
+ const s = readClaudeSession(file, from, until, seen);
63
+ if (s.events.length) sessions.push(s);
64
+ }
65
+ }
66
+
67
+ const codexSessions = join(codexDir || '', 'sessions');
68
+ if (codexDir && existsSync(codexSessions)) {
69
+ // Codex files sit under YYYY/MM/DD of the day the thread STARTED, and a thread can stay open for weeks,
70
+ // so every day folder is checked by modification time rather than by name
71
+ for (const file of recentFiles(codexSessions, since, 3)) {
72
+ const s = readCodexSession(file, from, until);
73
+ if (s.events.length) sessions.push(s);
74
+ }
75
+ }
76
+
77
+ for (const s of sessions) s.events.sort((a, b) => a.t - b.t);
78
+ return sessions;
79
+ }
80
+
81
+ // .jsonl files `depth` folders below root that were written to after `since`
82
+ function recentFiles(root, since, depth) {
83
+ const out = [];
84
+ const walk = (dir, d) => {
85
+ let names;
86
+ try { names = readdirSync(dir); } catch { return; }
87
+ for (const name of names) {
88
+ const full = join(dir, name);
89
+ let st;
90
+ try { st = statSync(full); } catch { continue; }
91
+ if (st.isDirectory()) { if (d > 0) walk(full, d - 1); }
92
+ else if (d === 0 && name.endsWith('.jsonl') && st.mtimeMs >= since) out.push(full);
93
+ }
94
+ };
95
+ walk(root, depth);
96
+ return out;
97
+ }
98
+
99
+ // ---------------------------------------------------------------- Claude Code
100
+
101
+ // Text Claude Code writes into the user's turn that the user did not type
102
+ const NOT_TYPED = /^\s*(<(local-command-|system-reminder|task-notification|bash-(input|stdout|stderr)|user-memory-input)|\[Request interrupted|Caveat: The messages below|This session is being continued|Base directory for this skill|Continue from where you left off\.?\s*$)/;
103
+ // Work the machine started on its own: scheduled hooks, goal check-ins, auto-resume heartbeats, and the
104
+ // "limit reset, continue" nudge the runner injects after a usage cap
105
+ const AUTO_WORK = /^\s*(Stop hook feedback|Goal check-in|<heartbeat|<automation|\[scheduled|(I hit|hit) (my )?usage limit)/i;
106
+ // Built-in commands whose arguments are settings, not a request
107
+ const BUILTIN = new Set(['/clear', '/compact', '/model', '/effort', '/resume', '/rewind', '/exit', '/config', '/login', '/logout',
108
+ '/permissions', '/add-dir', '/mcp', '/cost', '/usage', '/status', '/doctor', '/help', '/memory', '/init', '/theme', '/vim',
109
+ '/terminal-setup', '/ide', '/hooks', '/agents', '/bug', '/release-notes', '/export', '/context', '/statusline', '/fast',
110
+ '/output-style', '/plugin', '/upgrade', '/privacy-settings', '/remote-control', '/rename', '/tag', '/color']);
111
+
112
+ function readClaudeSession(file, from, until, seen) {
113
+ const s = { agent: 'claude', id: basename(file, '.jsonl'), cwd: null, events: [] };
114
+ forEachLine(file, (line) => {
115
+ // Cheap skips before parsing: harness records, file attachments and tool output are most of the bytes
116
+ const head = line.subarray(0, 400).toString('latin1');
117
+ if (head.startsWith('{"type":"') && !/^\{"type":"(user|assistant)"/.test(head)) return;
118
+ if (head.includes('"attachment":{')) return;
119
+ if (head.includes('"content":[{"tool_use_id"') || head.includes('"content":[{"type":"tool_result"')) return;
120
+
121
+ let r;
122
+ try { r = JSON.parse(line.toString('utf8')); } catch { return; }
123
+ if (r.type !== 'user' && r.type !== 'assistant') return;
124
+ if (r.isSidechain) return;
125
+ const t = Date.parse(r.timestamp);
126
+ if (!(t >= from && t <= until)) return;
127
+ if (r.uuid) {
128
+ if (seen.has(r.uuid)) return;
129
+ seen.add(r.uuid);
130
+ }
131
+ if (r.cwd) s.cwd = r.cwd;
132
+
133
+ if (r.type === 'assistant') {
134
+ const step = claudeStep(r);
135
+ s.events.push({ t, kind: 'step', words: step.words, files: step.files });
136
+ return;
137
+ }
138
+ const e = claudeUserEvent(r);
139
+ if (e) s.events.push({ t, ...e, cwd: r.cwd || s.cwd });
140
+ });
141
+ return s;
142
+ }
143
+
144
+ function claudeUserEvent(r) {
145
+ if (r.isCompactSummary) return null;
146
+ const kind = r.origin?.kind;
147
+ // A background agent reporting back continues the user's request
148
+ if (kind === 'task-notification' || kind === 'peer') return { kind: 'step', words: '', files: [] };
149
+ const text = userText(r.message?.content);
150
+ if (text === null) return null;
151
+ if (/^\s*<task-notification>/.test(text)) return { kind: 'step', words: '', files: [] };
152
+ if (AUTO_WORK.test(text)) return { kind: 'auto' };
153
+ if (r.isMeta) return null;
154
+ if (kind && kind !== 'human') return null;
155
+ // Runs started from a script (claude -p, the SDK): nobody typed these
156
+ if (r.promptSource === 'sdk' || /^sdk/.test(r.entrypoint || '')) return { kind: 'auto' };
157
+ if (text.startsWith('<command-name>') || text.startsWith('<command-message>')) return commandPrompt(text);
158
+ if (!text.trim() || NOT_TYPED.test(text)) return null;
159
+ return { kind: 'prompt', text: text.trim() };
160
+ }
161
+
162
+ // String content, or the text blocks of an array; null when the record is tool output
163
+ function userText(content) {
164
+ if (typeof content === 'string') return content;
165
+ if (!Array.isArray(content)) return null;
166
+ if (content.some(b => b?.type === 'tool_result')) return null;
167
+ return content.filter(b => b?.type === 'text').map(b => b.text || '').join('\n');
168
+ }
169
+
170
+ // "/goal ship the landing page" is stored as tags; the words after a command are the user's own, sometimes the plan itself
171
+ function commandPrompt(text) {
172
+ const name = text.match(/<command-name>\s*([^<\s]+)\s*<\/command-name>/)?.[1];
173
+ const args = text.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]?.trim();
174
+ if (!name || !args || BUILTIN.has(name)) return null;
175
+ return { kind: 'prompt', text: args, command: name };
176
+ }
177
+
178
+ function claudeStep(r) {
179
+ const blocks = Array.isArray(r.message?.content) ? r.message.content : [];
180
+ let words = '';
181
+ const files = [];
182
+ for (const b of blocks) {
183
+ if (b?.type === 'text' && b.text) words += (words ? ' ' : '') + b.text.slice(0, 1500);
184
+ else if (b?.type === 'tool_use' && b.input && typeof b.input === 'object') {
185
+ const p = b.input.file_path || b.input.notebook_path || b.input.path;
186
+ if (typeof p === 'string' && p) files.push(p);
187
+ }
188
+ }
189
+ return { words: words.slice(0, 2000), files };
190
+ }
191
+
192
+ // ---------------------------------------------------------------- Codex
193
+
194
+ // Context Codex puts in the user's slot that the user did not type
195
+ const CODEX_NOT_TYPED = /^\s*(<(environment_context|user_instructions|recommended_plugins|app-context|turn_aborted|subagent)|# AGENTS\.md)/;
196
+ const CODEX_STEP_ITEMS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'function_call_output',
197
+ 'custom_tool_call_output', 'reasoning', 'web_search_call', 'message']);
198
+ const CODEX_STEP_EVENTS = new Set(['agent_message', 'task_complete', 'token_count', 'exec_command_end', 'patch_apply_end', 'turn_aborted']);
199
+
200
+ function readCodexSession(file, from, until) {
201
+ const s = { agent: 'codex', id: basename(file, '.jsonl'), cwd: null, events: [] };
202
+ const lastPrompt = new Map(); // text -> time; some Codex versions log one prompt twice, in two shapes
203
+ forEachLine(file, (line) => {
204
+ const head = line.subarray(0, 120).toString('latin1');
205
+ const isContext = head.includes('"type":"session_meta"') || head.includes('"type":"turn_context"');
206
+ // Codex writes the timestamp first, so lines outside the window are dropped before parsing
207
+ const ts = Date.parse(head.match(/"timestamp":"([^"]+)"/)?.[1] || '');
208
+ if (!isContext && !(ts >= from && ts <= until)) return;
209
+
210
+ let r;
211
+ try { r = JSON.parse(line.toString('utf8')); } catch { return; }
212
+ const p = r.payload || {};
213
+ if (r.type === 'session_meta') { if (p.id) s.id = p.id; if (p.cwd) s.cwd = p.cwd; return; }
214
+ if (r.type === 'turn_context') { if (p.cwd) s.cwd = p.cwd; return; }
215
+ const t = Date.parse(r.timestamp);
216
+ if (!(t >= from && t <= until)) return;
217
+
218
+ const typed = codexPrompt(r);
219
+ if (typed !== null) {
220
+ const text = typed.trim();
221
+ if (!text || CODEX_NOT_TYPED.test(text)) return;
222
+ if (AUTO_WORK.test(text)) { s.events.push({ t, kind: 'auto' }); return; }
223
+ const prev = lastPrompt.get(text);
224
+ if (prev !== undefined && Math.abs(t - prev) < 5 * 60000) return;
225
+ lastPrompt.set(text, t);
226
+ s.events.push({ t, kind: 'prompt', text, cwd: s.cwd });
227
+ return;
228
+ }
229
+ if (r.type === 'response_item' && CODEX_STEP_ITEMS.has(p.type)) {
230
+ if (p.type === 'message' && p.role !== 'assistant') return;
231
+ const words = p.type === 'message' ? (p.content || []).map(c => c?.text || '').join(' ').slice(0, 2000) : '';
232
+ s.events.push({ t, kind: 'step', words, files: patchFiles(p.input || p.arguments) });
233
+ } else if (r.type === 'event_msg' && CODEX_STEP_EVENTS.has(p.type)) {
234
+ s.events.push({ t, kind: 'step', words: p.type === 'agent_message' ? String(p.message || '').slice(0, 2000) : '', files: [] });
235
+ }
236
+ });
237
+ return s;
238
+ }
239
+
240
+ // The user's typed text: `user_message` events (older Codex) or completed UserMessage items (newer).
241
+ // Replayed history in forked threads arrives as response items, not events, so it is not counted twice.
242
+ function codexPrompt(r) {
243
+ if (r.type !== 'event_msg') return null;
244
+ const p = r.payload || {};
245
+ if (p.type === 'user_message') return typeof p.message === 'string' ? p.message : null;
246
+ if (p.type === 'item_completed' && p.item?.type === 'UserMessage') {
247
+ return (p.item.content || []).map(c => (typeof c?.text === 'string' ? c.text : '')).join('\n');
248
+ }
249
+ return null;
250
+ }
251
+
252
+ // Files named in an apply_patch body ("*** Update File: path")
253
+ function patchFiles(input) {
254
+ const text = typeof input === 'string' ? input : input ? JSON.stringify(input) : '';
255
+ if (!text.includes('*** ')) return [];
256
+ const out = [];
257
+ for (const m of text.matchAll(/\*\*\* (?:Add|Update|Delete) File: ([^\n\\"]+)/g)) out.push(m[1].trim());
258
+ return out;
259
+ }
@@ -0,0 +1,315 @@
1
+ function esc(s) {
2
+ if (s === 0) return '0';
3
+ if (!s) return '';
4
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
5
+ }
6
+
7
+ const MIN = 60000;
8
+ const WEEKDAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
9
+ const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
+
11
+ const hours = (ms) => {
12
+ const h = ms / 3600000;
13
+ if (h >= 10) return `${Math.round(h)}h`;
14
+ if (h >= 1) return `${h.toFixed(1).replace(/\.0$/, '')}h`;
15
+ return `${Math.max(1, Math.round(ms / MIN))}m`;
16
+ };
17
+ const dateShort = (iso) => { const d = new Date(iso + 'T00:00:00'); return `${MONTH[d.getMonth()]} ${d.getDate()}`; };
18
+ const weekday = (iso) => WEEKDAY[new Date(iso + 'T00:00:00').getDay()];
19
+ // "Sep 22 to 29" when one month, "Sep 28 to Oct 4" across two
20
+ function rangeLabel(a, b) {
21
+ const da = new Date(a + 'T00:00:00'), db = new Date(b + 'T00:00:00');
22
+ return da.getMonth() === db.getMonth() ? `${MONTH[da.getMonth()]} ${da.getDate()} to ${db.getDate()}` : `${dateShort(a)} to ${dateShort(b)}`;
23
+ }
24
+
25
+ // A closing line with a bit of bite, in his register: lowercase, no wind-up, takes the reader's side against the
26
+ // drift. Built from how the week actually went (the worst day, the kind of detour), never a stock quip.
27
+ function kicker(share, drift) {
28
+ const pct = Math.round(share * 100);
29
+ const off = 100 - pct;
30
+ const worst = drift.worstDay && drift.worstDay.detourMs > 0 ? weekday(drift.worstDay.day).toLowerCase() : null;
31
+ const cat = drift.detours[0]?.category;
32
+ const chase = cat && cat !== 'something else' ? cat : 'a tangent';
33
+ if (pct >= 85) return `${pct}% on plan. who are you and what did you do with the tabs.`;
34
+ 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.`;
35
+ 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.`;
36
+ if (pct >= 20) return `${pct}% on plan. the plan was more of a vibe this week.`;
37
+ return `${pct}%. the plan filed a missing person report.`;
38
+ }
39
+
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 }) {
144
+ const pct = Math.round((drift.share || 0) * 100);
145
+ const { onMs, detourMs } = drift.totals;
146
+ const range = rangeLabel(firstDay(drift), lastDay(drift));
147
+
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 }));
152
+ const worst = drift.worstDay && drift.worstDay.detourMs > 0 ? drift.worstDay : null;
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';
155
+
156
+ const rows = (list, cls) => `<ul class="rows ${cls}">${list.map((d, i) => `
157
+ <li class="row" style="--i:${i}">
158
+ <span class="dot"></span>
159
+ <span class="rlabel">${esc(d.label)}${d.times > 1 ? `<span class="rtimes">&times;${d.times}</span>` : ''}</span>
160
+ <span class="rtime">${hours(d.ms)}</span>
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>`;
164
+
165
+ return `
166
+ <article class="card" id="card" role="img" aria-label="Drift report: ${pct}% of agent time on plan">
167
+ <header class="top">
168
+ <span class="brand">DRIFT REPORT</span>
169
+ <span class="range">${esc(agents)}<br>${esc(range)}</span>
170
+ </header>
171
+
172
+ <section class="hero">
173
+ <div class="big"><span class="num" id="num">${pct}</span><span class="pctsign">%</span></div>
174
+ <p class="cap">of your agent time went to<br><strong>what you said you&rsquo;d do</strong></p>
175
+ </section>
176
+
177
+ <div class="split" aria-hidden="true">
178
+ <div class="bar"><span class="fill" id="fill" style="width:${pct}%"></span></div>
179
+ <div class="legend">
180
+ <span><i class="sw on"></i> On plan &middot; ${hours(onMs)}</span>
181
+ <span><i class="sw off"></i> Detours &middot; ${hours(detourMs)}</span>
182
+ </div>
183
+ </div>
184
+
185
+ ${byKind.length ? `
186
+ <section class="block">
187
+ <h2>Where the rest went</h2>
188
+ ${detourRows}
189
+ </section>` : ''}
190
+
191
+ <section class="facts">
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>` : ''}
193
+ ${streak ? `<div class="fact"><span class="fk">Longest rabbit hole</span><span class="fv">${hours(streak.ms)}</span>${streakName}</div>` : ''}
194
+ </section>
195
+
196
+ <p class="kick">${esc(kicker(drift.share || 0, drift))}</p>
197
+
198
+ <footer class="foot">
199
+ <span class="mover">Mover OS catches the drift while it happens.</span>
200
+ <span class="cmd">npx cchubber</span>
201
+ </footer>
202
+ </article>`;
203
+ }
204
+
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 &middot; ${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&rsquo;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>`;
269
+ }
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
+ `;
289
+ }
290
+
291
+ // For --redact: one row per category, times and minutes summed, biggest first
292
+ function mergeByCategory(detours) {
293
+ const by = new Map();
294
+ for (const d of detours) {
295
+ const c = by.get(d.category) || { category: d.category, ms: 0, times: 0 };
296
+ c.ms += d.ms; c.times += d.times;
297
+ by.set(d.category, c);
298
+ }
299
+ return [...by.values()].sort((a, b) => b.ms - a.ms);
300
+ }
301
+
302
+ function firstDay(drift) { return drift.days?.[0]?.day || new Date(drift.since).toISOString().slice(0, 10); }
303
+ function lastDay(drift) { const d = drift.days; return (d && d[d.length - 1]?.day) || new Date(drift.until).toISOString().slice(0, 10); }
304
+
305
+ // Plain text; the template escapes it. Says which sources set "what you said you'd do".
306
+ function sourceNote(drift) {
307
+ const s = drift.source || {};
308
+ const names = s.planNames || [];
309
+ if (names.length) {
310
+ const where = names.join(' and ');
311
+ const scope = s.scope === 'project' ? ', so on-plan means on the project' : '';
312
+ return `Measured against ${where}${scope}, then your first ask of each session where there was no plan.`;
313
+ }
314
+ return 'Measured against the first thing you asked in each session.';
315
+ }