cchubber 0.5.8 → 0.6.1

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