cchubber 0.5.7 โ†’ 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.
@@ -0,0 +1,187 @@
1
+ import { readFileSync, existsSync, statSync } from 'fs';
2
+ import { join, dirname, resolve, relative, isAbsolute, sep } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ /**
6
+ * "What you said you'd do", when you wrote it down. Two kinds of written plan are read, both local:
7
+ * - Mover OS: the day's Daily Note (Focus and Tasks), and the plan.md of the vault project a session ran in
8
+ * - anywhere else: a PLAN.md, plan.md or TODO.md in the session's project folder
9
+ * Only checklist items and focus lines are read. Nothing else in these files is used, and nothing leaves the machine.
10
+ */
11
+
12
+ const PLAN_NAMES = ['PLAN.md', 'plan.md', 'TODO.md', 'todo.md'];
13
+ // A plan with more open items than this is a long-running tracker, not a short to-do list. It is still used, as the
14
+ // project's vocabulary, but the report says "on the project" rather than "on this week's list".
15
+ export const BIG_PLAN_ITEMS = 60;
16
+ const CHECKBOX = /^\s*[-*+]\s+\[([ xX~\/>-])\]\s+(.+)$/;
17
+
18
+ // Mover's vault, from ~/.mover/config.json. Only vaultPath is read.
19
+ export function moverVault(configPath = join(homedir(), '.mover', 'config.json')) {
20
+ if (!existsSync(configPath)) return null;
21
+ try {
22
+ const vault = JSON.parse(readFileSync(configPath, 'utf-8')).vaultPath;
23
+ return typeof vault === 'string' && existsSync(vault) ? vault : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Checklist items and focus lines, cleaned of Obsidian syntax and Mover's bookkeeping tags
30
+ export function cleanItem(text) {
31
+ return String(text)
32
+ .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
33
+ .replace(/\[\[([^\]]+)\]\]/g, '$1')
34
+ .replace(/\s\^[\w-]+\s*$/, '')
35
+ .replace(/\[(Added|Due|Moved|Carried|From|UNVERIFIED|HYPOTHESIS ONLY)[^\]]*\]/gi, '')
36
+ .replace(/[*_`]/g, '')
37
+ .replace(/\s+/g, ' ')
38
+ .trim();
39
+ }
40
+
41
+ // A template placeholder like "[The ONE thing that makes today a win]" is not a plan
42
+ const isPlaceholder = (s) => !s || /^\[.*\]$/.test(s) || /^\(.*\)$/.test(s);
43
+
44
+ // Section headers that are structure or bookkeeping, not a statement of work
45
+ const BORING_HEADER = /^(#+\s*)?(\d+\.|phase\b|execution log|the roadmap|the north star|the architecture|changelog|notes|project control|deferred|resolved|table of|contents|appendix|status|done|completed|in progress|todo|backlog|log\b)/i;
46
+
47
+ export function parsePlanText(text) {
48
+ const open = []; // still-to-do items: the plain reading of "what you said you'd do"
49
+ const all = []; // every item, open or done, plus real section headers: the project's stated work, for matching
50
+ for (const line of String(text).split('\n')) {
51
+ const m = line.match(CHECKBOX);
52
+ if (m) {
53
+ const item = cleanItem(m[2]);
54
+ if (isPlaceholder(item) || m[1] === '-') continue; // placeholder or dropped
55
+ all.push(item);
56
+ // [x] done, [~] done but unchecked in Mover's convention; everything else is still open
57
+ if (!/[xX~]/.test(m[1])) open.push(item);
58
+ continue;
59
+ }
60
+ // Section headers carry the intent of long trackers (Mover writes the verbatim brief into each "## R157 ..."),
61
+ // which the terse checkboxes below them do not. Skip the structural and bookkeeping ones.
62
+ const h = line.match(/^(#{2,4})\s+(.+?)\s*#*$/);
63
+ if (h && !BORING_HEADER.test(line)) {
64
+ const item = cleanItem(h[2].replace(/[๐Ÿ๐Ÿ—๏ธโšก๐Ÿงน๐Ÿง ๐Ÿ“š๐Ÿ”„๐ŸŽ“โœ…โš™๏ธ๐Ÿ“‹๐Ÿ”ง]/gu, ''));
65
+ if (item.length > 6 && !isPlaceholder(item)) all.push(item);
66
+ }
67
+ }
68
+ return { open, all };
69
+ }
70
+
71
+ // A Daily Note's Focus lines (Single Test, The Hard Thing) and its task list. The Sacrifice is what you said you
72
+ // would NOT do, so it is left out.
73
+ export function parseDailyNote(text) {
74
+ const items = [];
75
+ let section = null;
76
+ for (const line of String(text).split('\n')) {
77
+ const h = line.match(/^##\s+(.+?)\s*$/);
78
+ if (h) { section = /^focus/i.test(h[1]) ? 'focus' : /^tasks/i.test(h[1]) ? 'tasks' : null; continue; }
79
+ if (/^#\s/.test(line)) { section = null; continue; }
80
+ if (section === 'focus') {
81
+ const f = line.match(/^\s*[-*]\s+\*\*(Single Test|The Hard Thing|Focus|Main Quest)[^*]*\*\*:?\s*(.+)$/i);
82
+ if (f) {
83
+ const item = cleanItem(f[2]);
84
+ if (!isPlaceholder(item)) items.push(item);
85
+ }
86
+ } else if (section === 'tasks') {
87
+ const m = line.match(CHECKBOX);
88
+ if (m && m[1] !== '-') {
89
+ const item = cleanItem(m[2]);
90
+ if (!isPlaceholder(item)) items.push(item);
91
+ }
92
+ }
93
+ }
94
+ return items;
95
+ }
96
+
97
+ export function dailyNotePath(vault, dayKey) {
98
+ return join(vault, '02_Areas', 'Engine', 'Dailies', dayKey.slice(0, 7), `Daily - ${dayKey}.md`);
99
+ }
100
+
101
+ const inside = (child, parent) => {
102
+ const rel = relative(parent, child);
103
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
104
+ };
105
+
106
+ // The plan file for a session's folder: in a Mover vault, the project's plan.md (or dev/plan.md); anywhere else,
107
+ // the nearest PLAN.md / plan.md / TODO.md between the folder and its git root
108
+ export function findPlanFile(cwd, vault) {
109
+ if (!cwd) return null;
110
+ const dir = resolve(cwd);
111
+ if (vault && inside(dir, join(vault, '01_Projects'))) {
112
+ const project = relative(join(vault, '01_Projects'), dir).split(sep)[0];
113
+ if (!project) return null;
114
+ const root = join(vault, '01_Projects', project);
115
+ for (const f of ['plan.md', join('dev', 'plan.md')]) if (existsSync(join(root, f))) return join(root, f);
116
+ return null;
117
+ }
118
+ const home = homedir();
119
+ let d = dir;
120
+ for (let i = 0; i < 6; i++) {
121
+ for (const name of PLAN_NAMES) {
122
+ const f = join(d, name);
123
+ try { if (statSync(f).isFile()) return f; } catch { /* not here */ }
124
+ }
125
+ if (existsSync(join(d, '.git')) || d === home) break;
126
+ const up = dirname(d);
127
+ if (up === d) break;
128
+ d = up;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Plan lookup for the analyzer: planFor(cwd, dayKey) returns { sources: [...], items } or null.
135
+ * `notes` records plans that were found and set aside, so the report can say why.
136
+ */
137
+ export function readStatedPlans({ vault = moverVault() } = {}) {
138
+ const planCache = new Map();
139
+ const dailyCache = new Map();
140
+ const notes = new Map();
141
+
142
+ const plan = (cwd) => {
143
+ const file = findPlanFile(cwd, vault);
144
+ if (!file) return null;
145
+ if (!planCache.has(file)) {
146
+ let parsed = null;
147
+ try { parsed = parsePlanText(readFileSync(file, 'utf-8')); } catch { /* unreadable */ }
148
+ const name = file.split(sep).pop();
149
+ if (parsed && parsed.all.length) {
150
+ const big = parsed.open.length > BIG_PLAN_ITEMS;
151
+ // A short to-do list is matched item by item. A long tracker is matched as a whole: any of the project's
152
+ // work counts as on the plan, so "on-plan" reads as "on this project".
153
+ planCache.set(file, { name, items: parsed.all, scope: big ? 'project' : 'list', open: parsed.open.length });
154
+ if (big) notes.set(file, { name, open: parsed.open.length, scope: 'project' });
155
+ } else {
156
+ planCache.set(file, null);
157
+ }
158
+ }
159
+ return planCache.get(file);
160
+ };
161
+
162
+ const daily = (dayKey) => {
163
+ if (!vault) return null;
164
+ if (!dailyCache.has(dayKey)) {
165
+ const file = dailyNotePath(vault, dayKey);
166
+ let items = [];
167
+ try { items = parseDailyNote(readFileSync(file, 'utf-8')); } catch { /* no note that day */ }
168
+ dailyCache.set(dayKey, items.length ? { name: 'Daily Note', items, scope: 'list' } : null);
169
+ }
170
+ return dailyCache.get(dayKey);
171
+ };
172
+
173
+ return {
174
+ mover: Boolean(vault),
175
+ // A day's Daily-Note focus is the sharpest statement of intent, so it leads; the project's plan backs it.
176
+ planFor(cwd, dayKey) {
177
+ const found = [daily(dayKey), plan(cwd)].filter(Boolean);
178
+ if (!found.length) return null;
179
+ return {
180
+ sources: found.map(f => f.name),
181
+ scope: found.some(f => f.scope === 'list') ? 'list' : 'project',
182
+ items: found.flatMap(f => f.items),
183
+ };
184
+ },
185
+ notes: () => [...notes.values()],
186
+ };
187
+ }
@@ -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
+ }