swarmdo 1.29.0 → 1.30.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.
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
15
15
  import { join, dirname } from 'path';
16
- import { fileURLToPath } from 'url';
16
+ import { fileURLToPath, pathToFileURL } from 'url';
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = dirname(__filename);
@@ -52,13 +52,30 @@ async function gracefulExit(signal) {
52
52
  process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
53
53
  process.on('SIGINT', () => { gracefulExit('SIGINT'); });
54
54
 
55
- // Ensure data dir
56
- if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
57
-
58
55
  // ============================================================================
59
56
  // Simple JSON File Backend (implements IMemoryBackend interface)
60
57
  // ============================================================================
61
58
 
59
+ // Collapse entries that are the SAME memory — identical content signature but
60
+ // distinct ids — keeping the first occurrence. The auto-memory bridge
61
+ // historically re-imported every session with a fresh random id, bloating the
62
+ // store ~22x (#53); this heals already-bloated stores and de-dupes defensively.
63
+ // Keyed by namespace + content hash (or raw content) so genuinely distinct
64
+ // memories are never merged. Pure.
65
+ function dedupeByContentSignature(entries) {
66
+ const seen = new Set();
67
+ const out = [];
68
+ for (const e of entries) {
69
+ const ns = (e && e.namespace) || 'default';
70
+ const body = (e && e.metadata && e.metadata.contentHash) || (e && (e.content || e.value)) || '';
71
+ const sig = ns + '\u0001' + body;
72
+ if (seen.has(sig)) continue;
73
+ seen.add(sig);
74
+ out.push(e);
75
+ }
76
+ return out;
77
+ }
78
+
62
79
  class JsonFileBackend {
63
80
  constructor(filePath) {
64
81
  this.filePath = filePath;
@@ -70,7 +87,12 @@ class JsonFileBackend {
70
87
  try {
71
88
  const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
72
89
  if (Array.isArray(data)) {
73
- for (const entry of data) this.entries.set(entry.id, entry);
90
+ // Collapse content-duplicates left by the pre-#53 import bug (same
91
+ // memory re-imported each session under a fresh id). Auto-heals an
92
+ // already-bloated store by rewriting it once when dupes are dropped.
93
+ const deduped = dedupeByContentSignature(data);
94
+ for (const entry of deduped) this.entries.set(entry.id, entry);
95
+ if (deduped.length < data.length) this._persist();
74
96
  }
75
97
  } catch { /* start fresh */ }
76
98
  }
@@ -617,33 +639,44 @@ async function doImportAll() {
617
639
  // Main
618
640
  // ============================================================================
619
641
 
620
- const command = process.argv[2] || 'status';
621
-
622
- // Dynamic import() failures can surface as unhandled rejections on a later
623
- // microtask even when the awaiting call site already caught them, which would
624
- // otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
625
- // reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
626
- // (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
627
- process.on('unhandledRejection', (reason) => {
628
- if (DEBUG) {
629
- const detail = reason && reason.message ? reason.message : String(reason);
630
- process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
631
- }
632
- });
633
-
634
- try {
635
- switch (command) {
636
- case 'import': await doImport(); break;
637
- case 'import-all': await doImportAll(); break;
638
- case 'sync': await doSync(); break;
639
- case 'status': await doStatus(); break;
640
- default:
641
- console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
642
- process.exit(1);
642
+ // Only dispatch CLI commands when this file is executed directly
643
+ // (`node auto-memory-hook.mjs …`), NOT when it is imported by a test —
644
+ // importing must not run a command or exit the process (#53 regression test
645
+ // imports JsonFileBackend + dedupeByContentSignature below).
646
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
647
+ // Ensure data dir (only when actually running a command, not on import)
648
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
649
+ const command = process.argv[2] || 'status';
650
+
651
+ // Dynamic import() failures can surface as unhandled rejections on a later
652
+ // microtask even when the awaiting call site already caught them, which would
653
+ // otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
654
+ // reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
655
+ // (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
656
+ process.on('unhandledRejection', (reason) => {
657
+ if (DEBUG) {
658
+ const detail = reason && reason.message ? reason.message : String(reason);
659
+ process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
660
+ }
661
+ });
662
+
663
+ try {
664
+ switch (command) {
665
+ case 'import': await doImport(); break;
666
+ case 'import-all': await doImportAll(); break;
667
+ case 'sync': await doSync(); break;
668
+ case 'status': await doStatus(); break;
669
+ default:
670
+ console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
671
+ process.exit(1);
672
+ }
673
+ } catch (err) {
674
+ // Hooks must never crash Claude Code - fail silently
675
+ dim(`Error (non-critical): ${err.message}`);
643
676
  }
644
- } catch (err) {
645
- // Hooks must never crash Claude Code - fail silently
646
- dim(`Error (non-critical): ${err.message}`);
677
+ // Ensure clean exit for Claude Code hooks (exit 0 = success, no stderr = no error)
678
+ process.exit(0);
647
679
  }
648
- // Ensure clean exit for Claude Code hooks (exit 0 = success, no stderr = no error)
649
- process.exit(0);
680
+
681
+ // Exported for unit tests (the run-guard above keeps import side-effect-free).
682
+ export { JsonFileBackend, dedupeByContentSignature };
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![swarmdo](swarmdo/assets/brand/logo-full.svg)](https://swarmdo.com)
4
4
 
5
- [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.29.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
5
+ [![npm version (swarmdo)](https://img.shields.io/badge/npx%20swarmdo-v1.30.0-cb3837?style=for-the-badge&logo=npm&logoColor=white)](https://github.com/SwarmDo/swarmdo/releases)
6
6
  [![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://github.com/SwarmDo/swarmdo/blob/main/LICENSE)
7
7
  [![Website](https://img.shields.io/badge/swarmdo.com-e2a33c?style=for-the-badge&logoColor=black)](https://swarmdo.com)
8
8
  [![Star on GitHub](https://img.shields.io/github/stars/SwarmDo/swarmdo?style=for-the-badge&logo=github&color=gold)](https://github.com/SwarmDo/swarmdo)
@@ -271,7 +271,7 @@ The recent release train added a full day-to-day operations layer around the swa
271
271
 
272
272
  | Command | What it does |
273
273
  |---------|-------------|
274
- | `swarmdo usage` (alias `cost`) | Claude Code **spend analytics** from your local transcripts — `daily`, `monthly`, `models`, `projects`, `sessions`, live 5-hour `blocks` burn, `errors` (tool-failure analytics), `cache` (prompt-cache efficiency + $ saved), and `diff` (period-over-period comparison with per-model movers) |
274
+ | `swarmdo usage` (alias `cost`) | Claude Code **spend analytics** from your local transcripts — `daily`, `monthly` (with a month-end spend projection), `models`, `projects`, `sessions`, live 5-hour `blocks` burn, `errors` (tool-failure analytics), `cache` (prompt-cache efficiency + $ saved), `diff` (period-over-period comparison with per-model movers), `reflect` (a wrapped-style retrospective — busiest day, streak, top models/projects, peak hour, delegation ratio — with a shareable `--html` dashboard), and `limits` (an official-quota **exhaustion forecaster** over Claude Code's `rate_limits`); the standard views take `--csv` for spreadsheet export |
275
275
  | `swarmdo usage guard` | **Budget policy** — limits for the active 5h block / today / month via flags or `SWARMDO_GUARD_*` env → ok / warn / over; `--strict` exits 1, safe for CI gates and Stop hooks |
276
276
  | `swarmdo hud` | **One-screen ops HUD** — 5h block burn, task readiness, daemon workers, memory snapshots (`--watch`, `--json`) |
277
277
  | `swarmdo repair` (alias `tdd-repair`) | **Test-Driven Repair** — a bounded, budget-capped headless `claude` loop that fixes source until a failing test passes; dry-run unless `--confirm` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
15
15
  import { join, dirname } from 'path';
16
- import { fileURLToPath } from 'url';
16
+ import { fileURLToPath, pathToFileURL } from 'url';
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
19
19
  const __dirname = dirname(__filename);
@@ -52,13 +52,30 @@ async function gracefulExit(signal) {
52
52
  process.on('SIGTERM', () => { gracefulExit('SIGTERM'); });
53
53
  process.on('SIGINT', () => { gracefulExit('SIGINT'); });
54
54
 
55
- // Ensure data dir
56
- if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
57
-
58
55
  // ============================================================================
59
56
  // Simple JSON File Backend (implements IMemoryBackend interface)
60
57
  // ============================================================================
61
58
 
59
+ // Collapse entries that are the SAME memory — identical content signature but
60
+ // distinct ids — keeping the first occurrence. The auto-memory bridge
61
+ // historically re-imported every session with a fresh random id, bloating the
62
+ // store ~22x (#53); this heals already-bloated stores and de-dupes defensively.
63
+ // Keyed by namespace + content hash (or raw content) so genuinely distinct
64
+ // memories are never merged. Pure.
65
+ function dedupeByContentSignature(entries) {
66
+ const seen = new Set();
67
+ const out = [];
68
+ for (const e of entries) {
69
+ const ns = (e && e.namespace) || 'default';
70
+ const body = (e && e.metadata && e.metadata.contentHash) || (e && (e.content || e.value)) || '';
71
+ const sig = ns + '\u0001' + body;
72
+ if (seen.has(sig)) continue;
73
+ seen.add(sig);
74
+ out.push(e);
75
+ }
76
+ return out;
77
+ }
78
+
62
79
  class JsonFileBackend {
63
80
  constructor(filePath) {
64
81
  this.filePath = filePath;
@@ -70,7 +87,12 @@ class JsonFileBackend {
70
87
  try {
71
88
  const data = JSON.parse(readFileSync(this.filePath, 'utf-8'));
72
89
  if (Array.isArray(data)) {
73
- for (const entry of data) this.entries.set(entry.id, entry);
90
+ // Collapse content-duplicates left by the pre-#53 import bug (same
91
+ // memory re-imported each session under a fresh id). Auto-heals an
92
+ // already-bloated store by rewriting it once when dupes are dropped.
93
+ const deduped = dedupeByContentSignature(data);
94
+ for (const entry of deduped) this.entries.set(entry.id, entry);
95
+ if (deduped.length < data.length) this._persist();
74
96
  }
75
97
  } catch { /* start fresh */ }
76
98
  }
@@ -371,32 +393,43 @@ async function doStatus() {
371
393
  // Main
372
394
  // ============================================================================
373
395
 
374
- const command = process.argv[2] || 'status';
375
-
376
- // Dynamic import() failures can surface as unhandled rejections on a later
377
- // microtask even when the awaiting call site already caught them, which would
378
- // otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
379
- // reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
380
- // (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
381
- process.on('unhandledRejection', (reason) => {
382
- if (DEBUG) {
383
- const detail = reason && reason.message ? reason.message : String(reason);
384
- process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
385
- }
386
- });
387
-
388
- try {
389
- switch (command) {
390
- case 'import': await doImport(); break;
391
- case 'sync': await doSync(); break;
392
- case 'status': await doStatus(); break;
393
- default:
394
- console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
395
- break;
396
+ // Only dispatch CLI commands when this file is executed directly
397
+ // (`node auto-memory-hook.mjs …`), NOT when it is imported by a test —
398
+ // importing must not run a command or exit the process (#53 regression test
399
+ // imports JsonFileBackend + dedupeByContentSignature below).
400
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
401
+ // Ensure data dir (only when actually running a command, not on import)
402
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
403
+ const command = process.argv[2] || 'status';
404
+
405
+ // Dynamic import() failures can surface as unhandled rejections on a later
406
+ // microtask even when the awaiting call site already caught them, which would
407
+ // otherwise force a non-zero exit. Swallow to keep hooks exit-0, but surface the
408
+ // reason under SWARMDO_DEBUG/DEBUG so genuine async bugs aren't silently hidden
409
+ // (FIX 2 — the previous `() => {}` discarded every rejection process-wide).
410
+ process.on('unhandledRejection', (reason) => {
411
+ if (DEBUG) {
412
+ const detail = reason && reason.message ? reason.message : String(reason);
413
+ process.stderr.write(`[AutoMemory] unhandledRejection (suppressed): ${detail}\n`);
414
+ }
415
+ });
416
+
417
+ try {
418
+ switch (command) {
419
+ case 'import': await doImport(); break;
420
+ case 'sync': await doSync(); break;
421
+ case 'status': await doStatus(); break;
422
+ default:
423
+ console.log('Usage: auto-memory-hook.mjs <import|sync|status>');
424
+ break;
425
+ }
426
+ } catch (err) {
427
+ // Hooks must never crash Claude Code - fail silently
428
+ try { dim(`Error (non-critical): ${err.message}`); } catch (_) {}
396
429
  }
397
- } catch (err) {
398
- // Hooks must never crash Claude Code - fail silently
399
- try { dim(`Error (non-critical): ${err.message}`); } catch (_) {}
430
+ // Force clean exit — process.exitCode alone isn't enough if async errors override it
431
+ process.exit(0);
400
432
  }
401
- // Force clean exit — process.exitCode alone isn't enough if async errors override it
402
- process.exit(0);
433
+
434
+ // Exported for unit tests (the run-guard above keeps import side-effect-free).
435
+ export { JsonFileBackend, dedupeByContentSignature };
@@ -14,10 +14,16 @@
14
14
  */
15
15
  import { output } from '../output.js';
16
16
  import { aggregateBlocks, aggregateUsage, collectUsage, totalUsage, localDateKey, } from '../usage/transcript-usage.js';
17
- import { collectToolErrors } from '../usage/transcript-errors.js';
17
+ import { collectToolErrors, delegationFromReport } from '../usage/transcript-errors.js';
18
18
  import { computeCacheStats } from '../usage/cache-stats.js';
19
19
  import { evaluateGuard } from '../usage/spend-guard.js';
20
20
  import { resolvePeriodPair, parseRange, diffPeriods, modelMovers } from '../usage/diff.js';
21
+ import { computeReflection, monthsBefore, hourSparkline } from '../usage/reflect.js';
22
+ import { renderReflectionHtml } from '../usage/reflect-html.js';
23
+ import { forecastWindow, parseRateLimits, worstStatus, formatForecast, formatLimitSegment } from '../usage/limits.js';
24
+ import { readFileSync } from 'node:fs';
25
+ import { toCsv } from '../util/csv.js';
26
+ import { projectMonthEnd, daysInMonthOf } from '../usage/spend-forecast.js';
21
27
  const VIEWS = {
22
28
  daily: { dimension: 'day', label: 'Date' },
23
29
  monthly: { dimension: 'month', label: 'Month' },
@@ -376,6 +382,143 @@ function runGuardView(ctx, collection) {
376
382
  output.writeln(output.dim('exit 1 (--strict + over budget)'));
377
383
  return { success: exitCode === 0, exitCode };
378
384
  }
385
+ const PERIOD_MONTHS = { '1m': 1, '3m': 3, '6m': 6, '12m': 12 };
386
+ /** `usage reflect` — a wrapped-style retrospective over the local transcripts (#47). */
387
+ function runReflectView(ctx, collection) {
388
+ const periodFlag = (typeof ctx.flags.period === 'string' ? ctx.flags.period : '3m').toLowerCase();
389
+ const months = PERIOD_MONTHS[periodFlag];
390
+ if (!months) {
391
+ output.printError(`unknown --period "${periodFlag}" (expected ${Object.keys(PERIOD_MONTHS).join('|')})`);
392
+ return { success: false, exitCode: 1 };
393
+ }
394
+ const to = localDateKey(new Date());
395
+ const from = monthsBefore(to, months);
396
+ const dayRows = aggregateUsage(collection.events, 'day').map((r) => ({ key: r.key, totals: r.totals }));
397
+ const md = new Map();
398
+ const pd = new Map(); // per-(project, day) for top projects
399
+ const hourHistogram = new Array(24).fill(0); // local-hour cost, windowed to [from,to]
400
+ for (const e of collection.events) {
401
+ const k = `${e.model}\u0000${e.dateKey}`;
402
+ const row = md.get(k) ?? { key: e.model, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
403
+ row.totals.costUsd += e.costUsd;
404
+ row.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
405
+ md.set(k, row);
406
+ // per-(project, day) fold (space separator is safe — dateKey is fixed-width)
407
+ const pk = `${e.project} ${e.dateKey}`;
408
+ const prow = pd.get(pk) ?? { key: e.project, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
409
+ prow.totals.costUsd += e.costUsd;
410
+ prow.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
411
+ pd.set(pk, prow);
412
+ if (e.dateKey >= from && e.dateKey <= to)
413
+ hourHistogram[new Date(e.timestampMs).getHours()] += e.costUsd;
414
+ }
415
+ const reflection = computeReflection(dayRows, [...md.values()], from, to, {}, [...pd.values()], hourHistogram);
416
+ // Delegation ratio (#47): reuse the (windowed) tool-error scan to count Task
417
+ // (subagent) calls vs all tool calls — the parsing is already tested there.
418
+ const delDirFlag = ctx.flags.dir;
419
+ const delDirs = typeof delDirFlag === 'string' ? [delDirFlag] : Array.isArray(delDirFlag) ? delDirFlag.map(String) : undefined;
420
+ const delegation = delegationFromReport(collectToolErrors({ dirs: delDirs, since: from, until: to }));
421
+ if (ctx.flags.json === true) {
422
+ output.printJson({ ...reflection, delegation });
423
+ return { success: true, data: reflection };
424
+ }
425
+ if (ctx.flags.html === true) {
426
+ output.writeln(renderReflectionHtml(reflection, { generatedAt: to, delegation }));
427
+ return { success: true, exitCode: 0 };
428
+ }
429
+ const r = reflection;
430
+ const pctStr = (x) => `${Math.round(x * 100)}%`;
431
+ const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
432
+ output.writeln(output.bold(`Usage reflect ${r.period.from}..${r.period.to}`) + output.dim(` (${periodFlag}, ${r.period.spanDays} days)`));
433
+ if (r.totals.activeDays === 0) {
434
+ output.writeln(output.info('no Claude Code activity in this window'));
435
+ return { success: true, exitCode: 0 };
436
+ }
437
+ output.writeln('');
438
+ output.writeln(` Total spend ${output.bold(fmtCost(r.totals.costUsd))} ${output.dim(`across ${r.totals.activeDays} active day${r.totals.activeDays === 1 ? '' : 's'} of ${r.period.spanDays}`)}`);
439
+ output.writeln(` Total tokens ${r.totals.totalTokens.toLocaleString()}`);
440
+ if (r.busiestDay)
441
+ output.writeln(` Busiest day ${r.busiestDay.day} ${output.dim(`(${fmtCost(r.busiestDay.costUsd)}, ${r.busiestDay.totalTokens.toLocaleString()} tok)`)}`);
442
+ output.writeln(` Longest streak ${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`);
443
+ output.writeln(` Avg / active day ${fmtCost(r.avgCostPerActiveDay)}`);
444
+ output.writeln(` Cache read share ${pctStr(r.cacheReadPct)}`);
445
+ if (delegation.toolCalls > 0) {
446
+ output.writeln(` Delegation ${pctStr(delegation.ratio)} ${output.dim(`(${delegation.taskCalls} of ${delegation.toolCalls} tool calls to subagents)`)}`);
447
+ }
448
+ output.writeln(` Cost trend ${arrow} ${fmtCost(r.trend.firstHalfCost)} → ${fmtCost(r.trend.secondHalfCost)} ${output.dim(`(${r.trend.direction})`)}`);
449
+ if (r.peakHour)
450
+ output.writeln(` Peak hour ${String(r.peakHour.hour).padStart(2, '0')}:00 ${output.dim(`(${fmtCost(r.peakHour.value)})`)}`);
451
+ if (r.hourHistogram.some((v) => v > 0)) {
452
+ output.writeln(` By hour ${hourSparkline(r.hourHistogram)} ${output.dim('0…23')}`);
453
+ }
454
+ if (r.topModels.length) {
455
+ output.writeln('');
456
+ output.writeln(output.bold(' Top models'));
457
+ r.topModels.forEach((m, i) => {
458
+ output.writeln(` ${i + 1}. ${m.model.padEnd(22)} ${fmtCost(m.costUsd).padStart(9)} ${output.dim(pctStr(m.pct))}`);
459
+ });
460
+ }
461
+ if (r.topProjects.length) {
462
+ output.writeln('');
463
+ output.writeln(output.bold(' Top projects'));
464
+ r.topProjects.forEach((p, i) => {
465
+ const name = p.model.length > 34 ? '…' + p.model.slice(-33) : p.model;
466
+ output.writeln(` ${i + 1}. ${name.padEnd(34)} ${fmtCost(p.costUsd).padStart(9)} ${output.dim(pctStr(p.pct))}`);
467
+ });
468
+ }
469
+ return { success: true, exitCode: 0 };
470
+ }
471
+ /** `usage limits` — forecast the official 5h/7d quota windows from the
472
+ * statusline rate_limits payload (stdin or --payload). #46. */
473
+ function runLimitsView(ctx) {
474
+ const nowMs = Date.now();
475
+ let raw = typeof ctx.flags.payload === 'string' ? ctx.flags.payload : '';
476
+ if (!raw && !process.stdin.isTTY) {
477
+ try {
478
+ raw = readFileSync(0, 'utf8');
479
+ }
480
+ catch { /* no piped input */ }
481
+ }
482
+ if (!raw.trim()) {
483
+ output.writeln(output.info("no rate_limits payload — pipe Claude Code's statusline JSON in, or pass --payload '<json>'"));
484
+ output.writeln(output.dim(' e.g. in a statusline command: … | swarmdo usage limits'));
485
+ return { success: true, exitCode: 0 };
486
+ }
487
+ let payload;
488
+ try {
489
+ payload = JSON.parse(raw);
490
+ }
491
+ catch {
492
+ output.printError('payload is not valid JSON');
493
+ return { success: false, exitCode: 1 };
494
+ }
495
+ const windows = parseRateLimits(payload);
496
+ if (windows.length === 0) {
497
+ output.writeln(output.info('no five_hour / seven_day rate_limits found in the payload'));
498
+ return { success: true, exitCode: 0 };
499
+ }
500
+ const forecasts = windows.map((w) => ({ label: w.label, f: forecastWindow(w.state, nowMs) }));
501
+ if (ctx.flags.json === true) {
502
+ output.printJson(forecasts.map((x) => ({ label: x.label, ...x.f })));
503
+ return { success: true, data: forecasts };
504
+ }
505
+ if (ctx.flags.oneline === true) {
506
+ // Compact statusline-ready indicator, e.g. "5h 72%⚠ · 7d 12%".
507
+ output.writeln(formatLimitSegment(forecasts));
508
+ const worstOne = worstStatus(forecasts.map((x) => x.f));
509
+ return { success: true, exitCode: ctx.flags.strict === true && worstOne === 'over' ? 1 : 0 };
510
+ }
511
+ const worst = worstStatus(forecasts.map((x) => x.f));
512
+ output.writeln(output.bold('Usage limits') + output.dim(` (${worst})`));
513
+ for (const { label, f } of forecasts) {
514
+ const line = formatForecast(label, f, nowMs);
515
+ output.writeln(' ' + (f.status === 'over' ? output.error(line) : f.status === 'warn' ? line : output.dim(line)));
516
+ }
517
+ const exitCode = ctx.flags.strict === true && worst === 'over' ? 1 : 0;
518
+ if (exitCode === 1)
519
+ output.writeln(output.dim('exit 1 (--strict + cap reached)'));
520
+ return { success: exitCode === 0, exitCode };
521
+ }
379
522
  async function run(ctx) {
380
523
  const viewName = (ctx.args[0] || 'daily').toLowerCase();
381
524
  if (viewName === 'blocks') {
@@ -409,9 +552,17 @@ async function run(ctx) {
409
552
  const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
410
553
  return runDiffView(ctx, collectUsage({ dirs }));
411
554
  }
555
+ if (viewName === 'reflect') {
556
+ const dirFlag = ctx.flags.dir;
557
+ const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
558
+ return runReflectView(ctx, collectUsage({ dirs }));
559
+ }
560
+ if (viewName === 'limits') {
561
+ return runLimitsView(ctx); // reads the rate_limits payload from stdin/--payload, not transcripts
562
+ }
412
563
  const view = VIEWS[viewName];
413
564
  if (!view) {
414
- output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff)`));
565
+ output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff|reflect|limits)`));
415
566
  return { success: false, exitCode: 1 };
416
567
  }
417
568
  const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : undefined;
@@ -433,12 +584,31 @@ async function run(ctx) {
433
584
  output.writeln(JSON.stringify(jsonPayload(viewName, collection, rows, grand, since, until), null, 2));
434
585
  return { success: true, exitCode: 0, data: grand };
435
586
  }
587
+ if (ctx.flags.csv === true) {
588
+ // Portable export for spreadsheets / expense reports.
589
+ const headers = ['key', 'costUsd', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'entries'];
590
+ const csvRows = rows.map((r) => [r.key, r.totals.costUsd, r.totals.inputTokens, r.totals.outputTokens, r.totals.cacheReadTokens, r.totals.cacheWriteTokens, r.totals.totalTokens, r.totals.entries]);
591
+ output.writeln(toCsv(headers, csvRows));
592
+ return { success: true, exitCode: 0, data: grand };
593
+ }
436
594
  if (collection.events.length === 0) {
437
595
  output.writeln(output.info(`no usage entries found in ${collection.filesScanned} transcript files`));
438
596
  return { success: true, exitCode: 0 };
439
597
  }
440
598
  output.writeln(output.bold(`Claude Code usage — ${viewName}`) + (since || until ? output.dim(` (${since ?? '…'} → ${until ?? '…'})`) : ''));
441
599
  renderTable(view.label, rows, grand);
600
+ if (viewName === 'monthly') {
601
+ // Month-end pace projection for the current calendar month.
602
+ const today = localDateKey(new Date());
603
+ const curMonth = today.slice(0, 7);
604
+ const dom = Number(today.slice(8, 10));
605
+ const curRow = rows.find((r) => r.key === curMonth);
606
+ const dim = daysInMonthOf(curMonth);
607
+ if (curRow && dom > 0 && Number.isFinite(dim)) {
608
+ const p = projectMonthEnd(curRow.totals.costUsd, dom, dim);
609
+ output.writeln(output.dim(`${curMonth} on pace for ~${fmtCost(p.projectedUsd)} (${fmtCost(p.monthToDateUsd)} over ${dom}/${p.daysInMonth} days · ~${fmtCost(p.remainingUsd)} to go)`));
610
+ }
611
+ }
442
612
  output.writeln(output.dim(`${collection.filesScanned} transcript files · ${grand.entries} billed responses · sources: ${collection.dirsScanned.join(', ')}`));
443
613
  if (collection.unpricedModels.length > 0) {
444
614
  const { unpricedResponses, unpricedTokens } = unpricedStats(collection.events);
@@ -456,11 +626,13 @@ export const usageCommand = {
456
626
  { name: 'dir', description: 'explicit Claude projects dir (replaces auto-discovery)', type: 'string' },
457
627
  { name: 'limit', description: 'max rows for models/projects/sessions views', type: 'number' },
458
628
  { name: 'json', description: 'machine-readable output', type: 'boolean', default: false },
629
+ { name: 'csv', description: 'CSV export (daily|monthly|models|projects|sessions views)', type: 'boolean', default: false },
459
630
  ],
460
631
  examples: [
461
632
  { command: 'swarmdo usage', description: 'Daily token/cost table across all local Claude Code sessions' },
462
633
  { command: 'swarmdo usage models --since 2026-07-01', description: 'Spend per model this month' },
463
634
  { command: 'swarmdo usage projects --json', description: 'Per-project totals as JSON' },
635
+ { command: 'swarmdo usage monthly --csv > usage.csv', description: 'Export monthly spend to CSV' },
464
636
  { command: 'swarmdo cost sessions --limit 10', description: 'Ten most expensive sessions (alias)' },
465
637
  ],
466
638
  action: run,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * limits.ts — forecast when a Claude Code usage window will hit its cap.
3
+ *
4
+ * Claude Code passes an official `rate_limits` payload to statusline scripts:
5
+ * for the rolling 5-hour and 7-day windows it reports `used_percentage` and
6
+ * `resets_at`. This is the projection layer on top — at the current burn rate,
7
+ * when will the window hit 100%, and does that land before the window resets?
8
+ * ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
9
+ *
10
+ * Pure + deterministic: it takes a normalized window state + an injected `now`
11
+ * (the statusline shim maps the raw payload to WindowState — see #46), so the
12
+ * projection math is unit-tested with zero clock or wire-format coupling.
13
+ */
14
+ export type LimitStatus = 'ok' | 'warn' | 'over';
15
+ /** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
16
+ export declare const FIVE_HOUR_MS: number;
17
+ export declare const SEVEN_DAY_MS: number;
18
+ export interface NamedWindow {
19
+ /** short label, '5h' | '7d' */
20
+ label: string;
21
+ state: WindowState;
22
+ }
23
+ /**
24
+ * Map Claude Code's statusline `rate_limits` payload to normalized windows.
25
+ * Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
26
+ * snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
27
+ * Windows missing usage or a reset time are dropped. Pure.
28
+ */
29
+ export declare function parseRateLimits(payload: unknown): NamedWindow[];
30
+ export interface WindowState {
31
+ /** percent of the window's quota consumed, 0..100 */
32
+ usedPercentage: number;
33
+ /** window duration in ms (5h = 18_000_000, 7d = 604_800_000) */
34
+ windowMs: number;
35
+ /** epoch ms when the window resets to 0% */
36
+ resetsAtMs: number;
37
+ }
38
+ export interface LimitForecast {
39
+ usedPercentage: number;
40
+ resetsAtMs: number;
41
+ /** ms from now until the window resets (never negative) */
42
+ msToReset: number;
43
+ /** projected epoch ms when usage reaches 100% at the current burn rate, or
44
+ * null when it can't be projected (no burn yet, already at/over 100%, or the
45
+ * window hasn't started per the clock) */
46
+ exhaustionMs: number | null;
47
+ /** true when exhaustion is projected at or before the window resets */
48
+ willExhaust: boolean;
49
+ status: LimitStatus;
50
+ }
51
+ /**
52
+ * Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
53
+ * elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
54
+ */
55
+ export declare function forecastWindow(w: WindowState, nowMs: number, opts?: {
56
+ warnPct?: number;
57
+ }): LimitForecast;
58
+ /** Worst status across windows (over > warn > ok). Pure. */
59
+ export declare function worstStatus(forecasts: LimitForecast[]): LimitStatus;
60
+ /**
61
+ * The binding window — the one that constrains you first. A window projected to
62
+ * exhaust before reset outranks one that won't; among those that will, the
63
+ * earlier exhaustion wins; otherwise the higher used_percentage. null for [].
64
+ */
65
+ export declare function bindingWindow(forecasts: LimitForecast[]): LimitForecast | null;
66
+ /** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
67
+ export declare function humanizeMs(ms: number): string;
68
+ /** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
69
+ * `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
70
+ export declare function formatLimitSegment(forecasts: Array<{
71
+ label: string;
72
+ f: LimitForecast;
73
+ }>): string;
74
+ /** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
75
+ * on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
76
+ export declare function formatForecast(label: string, f: LimitForecast, nowMs: number): string;
77
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1,134 @@
1
+ /**
2
+ * limits.ts — forecast when a Claude Code usage window will hit its cap.
3
+ *
4
+ * Claude Code passes an official `rate_limits` payload to statusline scripts:
5
+ * for the rolling 5-hour and 7-day windows it reports `used_percentage` and
6
+ * `resets_at`. This is the projection layer on top — at the current burn rate,
7
+ * when will the window hit 100%, and does that land before the window resets?
8
+ * ("at this pace you hit the weekly cap Thu 14:00, 6h before it resets").
9
+ *
10
+ * Pure + deterministic: it takes a normalized window state + an injected `now`
11
+ * (the statusline shim maps the raw payload to WindowState — see #46), so the
12
+ * projection math is unit-tested with zero clock or wire-format coupling.
13
+ */
14
+ /** Rolling-window durations Claude Code reports (5-hour + 7-day caps). */
15
+ export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
16
+ export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
17
+ /** Coerce an epoch-seconds, epoch-ms, or ISO-string timestamp to epoch ms. */
18
+ function toEpochMs(v) {
19
+ if (typeof v === 'number' && Number.isFinite(v))
20
+ return v < 1e12 ? v * 1000 : v; // secs vs ms
21
+ if (typeof v === 'string') {
22
+ const t = Date.parse(v);
23
+ return Number.isNaN(t) ? null : t;
24
+ }
25
+ return null;
26
+ }
27
+ function pickWindow(obj, windowMs, label) {
28
+ if (!obj || typeof obj !== 'object')
29
+ return null;
30
+ const o = obj;
31
+ const used = (o.used_percentage ?? o.usedPercentage);
32
+ const resetsAtMs = toEpochMs(o.resets_at ?? o.resetsAt);
33
+ if (typeof used !== 'number' || !Number.isFinite(used) || resetsAtMs === null)
34
+ return null;
35
+ return { label, state: { usedPercentage: Math.max(0, Math.min(100, used)), windowMs, resetsAtMs } };
36
+ }
37
+ /**
38
+ * Map Claude Code's statusline `rate_limits` payload to normalized windows.
39
+ * Tolerant of shape drift: accepts the payload bare or wrapped in `rate_limits`,
40
+ * snake_case or camelCase keys, and epoch-seconds / epoch-ms / ISO `resets_at`.
41
+ * Windows missing usage or a reset time are dropped. Pure.
42
+ */
43
+ export function parseRateLimits(payload) {
44
+ const p = payload && typeof payload === 'object' ? payload : {};
45
+ const rl = (p.rate_limits ?? p.rateLimits ?? p);
46
+ const out = [];
47
+ const w5 = pickWindow(rl.five_hour ?? rl.fiveHour, FIVE_HOUR_MS, '5h');
48
+ const w7 = pickWindow(rl.seven_day ?? rl.sevenDay, SEVEN_DAY_MS, '7d');
49
+ if (w5)
50
+ out.push(w5);
51
+ if (w7)
52
+ out.push(w7);
53
+ return out;
54
+ }
55
+ /**
56
+ * Forecast a single window. Burn rate is `usedPercentage / elapsed`, where
57
+ * elapsed = now − windowStart and windowStart = resetsAt − windowMs. Pure.
58
+ */
59
+ export function forecastWindow(w, nowMs, opts = {}) {
60
+ const warnPct = opts.warnPct ?? 80;
61
+ const used = w.usedPercentage;
62
+ const msToReset = Math.max(0, w.resetsAtMs - nowMs);
63
+ const windowStartMs = w.resetsAtMs - w.windowMs;
64
+ const elapsed = nowMs - windowStartMs;
65
+ let exhaustionMs = null;
66
+ if (used >= 100) {
67
+ // already exhausted — mark exhaustion as "now"
68
+ exhaustionMs = nowMs;
69
+ }
70
+ else if (used > 0 && elapsed > 0) {
71
+ const msTo100 = ((100 - used) * elapsed) / used; // (remaining%) / (used%/elapsed)
72
+ exhaustionMs = nowMs + msTo100;
73
+ }
74
+ const willExhaust = exhaustionMs !== null && exhaustionMs <= w.resetsAtMs;
75
+ const status = used >= 100 ? 'over' : (willExhaust || used >= warnPct) ? 'warn' : 'ok';
76
+ return { usedPercentage: used, resetsAtMs: w.resetsAtMs, msToReset, exhaustionMs, willExhaust, status };
77
+ }
78
+ /** Worst status across windows (over > warn > ok). Pure. */
79
+ export function worstStatus(forecasts) {
80
+ const rank = { ok: 0, warn: 1, over: 2 };
81
+ return forecasts.reduce((acc, f) => (rank[f.status] > rank[acc] ? f.status : acc), 'ok');
82
+ }
83
+ /**
84
+ * The binding window — the one that constrains you first. A window projected to
85
+ * exhaust before reset outranks one that won't; among those that will, the
86
+ * earlier exhaustion wins; otherwise the higher used_percentage. null for [].
87
+ */
88
+ export function bindingWindow(forecasts) {
89
+ if (forecasts.length === 0)
90
+ return null;
91
+ return [...forecasts].sort((a, b) => {
92
+ if (a.willExhaust !== b.willExhaust)
93
+ return a.willExhaust ? -1 : 1;
94
+ if (a.willExhaust && b.willExhaust)
95
+ return a.exhaustionMs - b.exhaustionMs;
96
+ return b.usedPercentage - a.usedPercentage;
97
+ })[0];
98
+ }
99
+ /** Compact human duration for a ms span, e.g. 9000000 → "2h30m", 90000 → "1m". */
100
+ export function humanizeMs(ms) {
101
+ if (ms <= 0)
102
+ return '0m';
103
+ const totalMin = Math.floor(ms / 60_000);
104
+ const d = Math.floor(totalMin / 1440);
105
+ const h = Math.floor((totalMin % 1440) / 60);
106
+ const m = totalMin % 60;
107
+ if (d > 0)
108
+ return `${d}d${h > 0 ? `${h}h` : ''}`;
109
+ if (h > 0)
110
+ return `${h}h${m > 0 ? `${m}m` : ''}`;
111
+ return `${m}m`;
112
+ }
113
+ /** Ultra-compact one-line indicator for a statusline, e.g. "5h 72%⚠ · 7d 12%".
114
+ * `!` marks an over-cap window, `⚠` an at-risk one. Pure. */
115
+ export function formatLimitSegment(forecasts) {
116
+ if (forecasts.length === 0)
117
+ return 'limits: n/a';
118
+ const mark = (s) => (s === 'over' ? '!' : s === 'warn' ? '⚠' : '');
119
+ return forecasts.map(({ label, f }) => `${label} ${Math.round(f.usedPercentage)}%${mark(f.status)}`).join(' · ');
120
+ }
121
+ /** One-line summary for a window, e.g. "5h window: 42% used, resets in 2h14m —
122
+ * on pace to hit the cap in ~1h20m (before reset)". `label` names the window. */
123
+ export function formatForecast(label, f, nowMs) {
124
+ const head = `${label}: ${Math.round(f.usedPercentage)}% used, resets in ${humanizeMs(f.msToReset)}`;
125
+ if (f.status === 'over')
126
+ return `${head} — CAP REACHED`;
127
+ if (f.willExhaust && f.exhaustionMs !== null) {
128
+ const inMs = Math.max(0, f.exhaustionMs - nowMs);
129
+ const before = humanizeMs(f.resetsAtMs - f.exhaustionMs);
130
+ return `${head} — on pace to hit the cap in ~${humanizeMs(inMs)} (${before} before reset)`;
131
+ }
132
+ return `${head} — on pace to stay under the cap`;
133
+ }
134
+ //# sourceMappingURL=limits.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * reflect-html.ts — render a Reflection (see reflect.ts) into a self-contained,
3
+ * shareable HTML dashboard for `swarmdo usage reflect --html`.
4
+ *
5
+ * Pure + deterministic: it takes an already-computed Reflection and returns a
6
+ * complete HTML document string with all CSS inlined (no external requests, no
7
+ * clock — any timestamp is passed in), so it unit-tests without a browser or a
8
+ * network. Every user-controlled string (model ids, project paths) is HTML-
9
+ * escaped. See #47.
10
+ */
11
+ import type { Reflection } from './reflect.js';
12
+ /** HTML-escape a string for safe interpolation into text or attributes. */
13
+ export declare function escapeHtml(s: string): string;
14
+ /** Render the retrospective to a complete self-contained HTML document. Pure. */
15
+ export declare function renderReflectionHtml(r: Reflection, opts?: {
16
+ generatedAt?: string;
17
+ delegation?: {
18
+ taskCalls: number;
19
+ toolCalls: number;
20
+ ratio: number;
21
+ };
22
+ }): string;
23
+ //# sourceMappingURL=reflect-html.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * reflect-html.ts — render a Reflection (see reflect.ts) into a self-contained,
3
+ * shareable HTML dashboard for `swarmdo usage reflect --html`.
4
+ *
5
+ * Pure + deterministic: it takes an already-computed Reflection and returns a
6
+ * complete HTML document string with all CSS inlined (no external requests, no
7
+ * clock — any timestamp is passed in), so it unit-tests without a browser or a
8
+ * network. Every user-controlled string (model ids, project paths) is HTML-
9
+ * escaped. See #47.
10
+ */
11
+ /** HTML-escape a string for safe interpolation into text or attributes. */
12
+ export function escapeHtml(s) {
13
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
14
+ }
15
+ const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
16
+ const num = (n) => n.toLocaleString('en-US');
17
+ const pct = (x) => `${Math.round(x * 100)}%`;
18
+ /** A labelled proportional bar (width is % of the largest value in its group). */
19
+ function bar(label, valueText, fillPct) {
20
+ const w = Math.max(0, Math.min(100, fillPct));
21
+ return `<div class="bar"><div class="bar-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`
22
+ + `<div class="bar-track"><div class="bar-fill" style="width:${w.toFixed(1)}%"></div></div>`
23
+ + `<div class="bar-val">${escapeHtml(valueText)}</div></div>`;
24
+ }
25
+ function shareBars(rows) {
26
+ if (!rows.length)
27
+ return '<p class="empty">—</p>';
28
+ const max = Math.max(...rows.map((r) => r.costUsd), 0) || 1;
29
+ return rows.map((r) => bar(r.model, `${usd(r.costUsd)} · ${pct(r.pct)}`, (r.costUsd / max) * 100)).join('');
30
+ }
31
+ function hourBars(hist) {
32
+ if (!hist.some((v) => v > 0))
33
+ return '<p class="empty">—</p>';
34
+ const max = Math.max(...hist) || 1;
35
+ const cells = hist.map((v, h) => {
36
+ const hpct = (v / max) * 100;
37
+ return `<div class="hour" title="${String(h).padStart(2, '0')}:00 — ${usd(v)}">`
38
+ + `<div class="hour-bar" style="height:${hpct.toFixed(1)}%"></div>`
39
+ + `<div class="hour-tick">${h % 6 === 0 ? String(h).padStart(2, '0') : ''}</div></div>`;
40
+ }).join('');
41
+ return `<div class="hours">${cells}</div>`;
42
+ }
43
+ function stat(label, value) {
44
+ return `<div class="stat"><div class="stat-val">${escapeHtml(value)}</div><div class="stat-label">${escapeHtml(label)}</div></div>`;
45
+ }
46
+ /** Render the retrospective to a complete self-contained HTML document. Pure. */
47
+ export function renderReflectionHtml(r, opts = {}) {
48
+ const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
49
+ const peak = r.peakHour ? `${String(r.peakHour.hour).padStart(2, '0')}:00` : '—';
50
+ const busiest = r.busiestDay ? `${r.busiestDay.day} (${usd(r.busiestDay.costUsd)})` : '—';
51
+ const gen = opts.generatedAt ? `<p class="gen">generated ${escapeHtml(opts.generatedAt)}</p>` : '';
52
+ return `<!doctype html>
53
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
54
+ <title>Claude Code Reflect — ${escapeHtml(r.period.from)}..${escapeHtml(r.period.to)}</title>
55
+ <style>
56
+ :root{--bg:#0f1117;--card:#181b24;--fg:#e6e8ee;--muted:#8b90a0;--accent:#7c9cff;--track:#262a36}
57
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;padding:32px}
58
+ .wrap{max-width:840px;margin:0 auto}
59
+ h1{font-size:22px;margin:0 0 2px}.sub{color:var(--muted);margin:0 0 24px}.gen{color:var(--muted);font-size:12px;margin:24px 0 0}
60
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:28px}
61
+ .stat{background:var(--card);border-radius:12px;padding:16px}
62
+ .stat-val{font-size:22px;font-weight:600}.stat-label{color:var(--muted);font-size:12px;margin-top:4px}
63
+ h2{font-size:14px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:28px 0 12px}
64
+ .bar{display:grid;grid-template-columns:minmax(0,1fr) 3fr minmax(0,auto);align-items:center;gap:10px;margin:6px 0}
65
+ .bar-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}
66
+ .bar-track{background:var(--track);border-radius:6px;height:14px;overflow:hidden}
67
+ .bar-fill{background:var(--accent);height:100%;border-radius:6px}
68
+ .bar-val{font-size:12px;color:var(--muted);white-space:nowrap}
69
+ .hours{display:flex;align-items:flex-end;gap:3px;height:90px}
70
+ .hour{flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;height:100%}
71
+ .hour-bar{width:100%;background:var(--accent);border-radius:3px 3px 0 0;min-height:1px}
72
+ .hour-tick{font-size:9px;color:var(--muted);margin-top:4px;height:12px}
73
+ .empty{color:var(--muted)}
74
+ </style></head><body><div class="wrap">
75
+ <h1>Claude Code Reflect ${escapeHtml(arrow)}</h1>
76
+ <p class="sub">${escapeHtml(r.period.from)} .. ${escapeHtml(r.period.to)} · ${r.period.spanDays} days · ${r.totals.activeDays} active</p>
77
+ <div class="grid">
78
+ ${stat('Total spend', usd(r.totals.costUsd))}
79
+ ${stat('Total tokens', num(r.totals.totalTokens))}
80
+ ${stat('Avg / active day', usd(r.avgCostPerActiveDay))}
81
+ ${stat('Longest streak', `${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`)}
82
+ ${stat('Peak hour', peak)}
83
+ ${stat('Cache read', pct(r.cacheReadPct))}
84
+ ${stat('Busiest day', busiest)}
85
+ ${stat('Trend', `${arrow} ${pct(r.trend.firstHalfCost > 0 ? (r.trend.secondHalfCost - r.trend.firstHalfCost) / r.trend.firstHalfCost : 0)}`)}
86
+ ${opts.delegation && opts.delegation.toolCalls > 0 ? stat('Delegation', pct(opts.delegation.ratio)) : ''}
87
+ </div>
88
+ <h2>Cost by hour of day</h2>${hourBars(r.hourHistogram)}
89
+ <h2>Top models</h2>${shareBars(r.topModels)}
90
+ <h2>Top projects</h2>${shareBars(r.topProjects)}
91
+ ${gen}
92
+ </div></body></html>`;
93
+ }
94
+ //# sourceMappingURL=reflect-html.js.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * reflect.ts — a "wrapped"-style retrospective over Claude Code usage.
3
+ *
4
+ * Anthropic's Reflect (2026-07-09) summarizes claude.ai *chats*; it doesn't
5
+ * touch Claude Code terminal work. swarmdo already parses the local transcripts
6
+ * for `usage daily/monthly/blocks/errors/cache`, so this is the retrospective
7
+ * layer on top: fold the existing per-day and per-(model,day) aggregation rows
8
+ * into headline stats — totals, the busiest day, the top models, the longest
9
+ * active-day streak, the cost trend, and cache efficiency.
10
+ *
11
+ * Pure + deterministic: it takes already-aggregated rows (no transcripts, no
12
+ * clock — the period bounds are passed in), so the whole thing is unit-tested
13
+ * without touching disk. The command layer in ../commands/usage.ts feeds it the
14
+ * rows the parser already produces. See #47.
15
+ */
16
+ import type { DayRow, ModelRow } from './diff.js';
17
+ export interface ModelShare {
18
+ model: string;
19
+ costUsd: number;
20
+ totalTokens: number;
21
+ /** share of total cost, 0..1 (0 when the period cost is 0) */
22
+ pct: number;
23
+ }
24
+ export interface ReflectionTotals {
25
+ costUsd: number;
26
+ totalTokens: number;
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ cacheReadTokens: number;
30
+ cacheWriteTokens: number;
31
+ entries: number;
32
+ /** days in [from,to] with any token activity */
33
+ activeDays: number;
34
+ }
35
+ export interface Reflection {
36
+ period: {
37
+ from: string;
38
+ to: string; /** inclusive calendar span in days */
39
+ spanDays: number;
40
+ };
41
+ totals: ReflectionTotals;
42
+ /** highest-cost active day in the period, or null if the period is empty */
43
+ busiestDay: {
44
+ day: string;
45
+ costUsd: number;
46
+ totalTokens: number;
47
+ } | null;
48
+ /** models ranked by cost desc (capped to opts.topModels) */
49
+ topModels: ModelShare[];
50
+ /** projects ranked by cost desc (ModelShare.model holds the project path) */
51
+ topProjects: ModelShare[];
52
+ /** busiest local hour-of-day by cost (0..23), or null if no activity */
53
+ peakHour: {
54
+ hour: number;
55
+ value: number;
56
+ } | null;
57
+ /** cost per local hour-of-day, as supplied by the caller (length 24 when set) */
58
+ hourHistogram: number[];
59
+ /** longest run of consecutive active calendar days */
60
+ longestStreak: number;
61
+ /** cacheRead / (input + cacheWrite + cacheRead), 0..1 */
62
+ cacheReadPct: number;
63
+ /** total cost / active days (0 when no active days) */
64
+ avgCostPerActiveDay: number;
65
+ /** first-half vs second-half spend across the period */
66
+ trend: {
67
+ firstHalfCost: number;
68
+ secondHalfCost: number;
69
+ direction: 'up' | 'down' | 'flat';
70
+ };
71
+ }
72
+ export interface ReflectOptions {
73
+ /** how many models to keep in topModels (default 5) */
74
+ topModels?: number;
75
+ /** relative change below which the trend reads 'flat' (default 0.05 = 5%) */
76
+ flatThreshold?: number;
77
+ }
78
+ /** The calendar day after an ISO date (handles month/year rollover). Pure. */
79
+ export declare function nextDay(iso: string): string;
80
+ /**
81
+ * The ISO date `n` whole months before `iso`, clamping the day to the target
82
+ * month's length so "Mar 31 − 1 month" is Feb 28/29 (not a rolled-over Mar 3).
83
+ * Pure. Used to derive a `--period 1m|3m|…` window start from today.
84
+ */
85
+ export declare function monthsBefore(iso: string, n: number): string;
86
+ /** Inclusive whole-day span between two ISO dates (from <= to). Pure. */
87
+ export declare function spanDays(from: string, to: string): number;
88
+ /** Longest run of consecutive calendar days present in the set. Pure. */
89
+ export declare function longestStreakOf(days: string[]): number;
90
+ /** Render a numeric series as a Unicode block sparkline (one char per bucket).
91
+ * Zero buckets are blank; the rest scale ▁..█ against the max. Pure — used for
92
+ * the terminal cost-by-hour view (the HTML dashboard draws the same data). */
93
+ export declare function hourSparkline(hist: number[]): string;
94
+ /** Window + aggregate `{key, day, totals}` rows into cost-ranked shares. Pure.
95
+ * Reused for both models and projects (ModelShare.model carries whichever key). */
96
+ export declare function rankShares(rows: ModelRow[], from: string, to: string, totalCost: number, topN: number): ModelShare[];
97
+ /** Busiest bucket in a per-hour cost histogram (argmax; earliest hour wins ties),
98
+ * or null when every bucket is empty. Pure. */
99
+ export declare function peakHourOf(hourHistogram: number[]): {
100
+ hour: number;
101
+ value: number;
102
+ } | null;
103
+ /** Fold pre-aggregated usage rows into a retrospective. Pure. */
104
+ export declare function computeReflection(dayRows: DayRow[], modelRows: ModelRow[], from: string, to: string, opts?: ReflectOptions, projectRows?: ModelRow[], hourHistogram?: number[]): Reflection;
105
+ //# sourceMappingURL=reflect.d.ts.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * reflect.ts — a "wrapped"-style retrospective over Claude Code usage.
3
+ *
4
+ * Anthropic's Reflect (2026-07-09) summarizes claude.ai *chats*; it doesn't
5
+ * touch Claude Code terminal work. swarmdo already parses the local transcripts
6
+ * for `usage daily/monthly/blocks/errors/cache`, so this is the retrospective
7
+ * layer on top: fold the existing per-day and per-(model,day) aggregation rows
8
+ * into headline stats — totals, the busiest day, the top models, the longest
9
+ * active-day streak, the cost trend, and cache efficiency.
10
+ *
11
+ * Pure + deterministic: it takes already-aggregated rows (no transcripts, no
12
+ * clock — the period bounds are passed in), so the whole thing is unit-tested
13
+ * without touching disk. The command layer in ../commands/usage.ts feeds it the
14
+ * rows the parser already produces. See #47.
15
+ */
16
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
17
+ /** The calendar day after an ISO date (handles month/year rollover). Pure. */
18
+ export function nextDay(iso) {
19
+ const [y, m, d] = iso.split('-').map(Number);
20
+ const dt = new Date(y, m - 1, d + 1);
21
+ const yy = dt.getFullYear();
22
+ const mm = String(dt.getMonth() + 1).padStart(2, '0');
23
+ const dd = String(dt.getDate()).padStart(2, '0');
24
+ return `${yy}-${mm}-${dd}`;
25
+ }
26
+ /**
27
+ * The ISO date `n` whole months before `iso`, clamping the day to the target
28
+ * month's length so "Mar 31 − 1 month" is Feb 28/29 (not a rolled-over Mar 3).
29
+ * Pure. Used to derive a `--period 1m|3m|…` window start from today.
30
+ */
31
+ export function monthsBefore(iso, n) {
32
+ const [y, m, d] = iso.split('-').map(Number);
33
+ const monthIndex = m - 1 - n; // may be negative — Date normalizes the year
34
+ const lastDay = new Date(y, monthIndex + 1, 0).getDate();
35
+ const dt = new Date(y, monthIndex, Math.min(d, lastDay));
36
+ const yy = dt.getFullYear();
37
+ const mm = String(dt.getMonth() + 1).padStart(2, '0');
38
+ const dd = String(dt.getDate()).padStart(2, '0');
39
+ return `${yy}-${mm}-${dd}`;
40
+ }
41
+ /** Inclusive whole-day span between two ISO dates (from <= to). Pure. */
42
+ export function spanDays(from, to) {
43
+ const a = new Date(from + 'T00:00:00');
44
+ const b = new Date(to + 'T00:00:00');
45
+ return Math.floor((b.getTime() - a.getTime()) / 86_400_000) + 1;
46
+ }
47
+ /** Longest run of consecutive calendar days present in the set. Pure. */
48
+ export function longestStreakOf(days) {
49
+ const uniq = [...new Set(days)].sort();
50
+ let best = 0;
51
+ let run = 0;
52
+ let prev = null;
53
+ for (const d of uniq) {
54
+ run = prev !== null && nextDay(prev) === d ? run + 1 : 1;
55
+ if (run > best)
56
+ best = run;
57
+ prev = d;
58
+ }
59
+ return best;
60
+ }
61
+ const within = (key, from, to) => key >= from && key <= to;
62
+ const SPARK_BLOCKS = ' ▁▂▃▄▅▆▇█'; // index 0 = empty (zero), 1..8 = ▁..█ scaled to max
63
+ /** Render a numeric series as a Unicode block sparkline (one char per bucket).
64
+ * Zero buckets are blank; the rest scale ▁..█ against the max. Pure — used for
65
+ * the terminal cost-by-hour view (the HTML dashboard draws the same data). */
66
+ export function hourSparkline(hist) {
67
+ const max = Math.max(0, ...hist);
68
+ if (max <= 0)
69
+ return ' '.repeat(hist.length);
70
+ return hist.map((v) => SPARK_BLOCKS[v <= 0 ? 0 : Math.min(8, 1 + Math.floor((v / max) * 7))]).join('');
71
+ }
72
+ /** Window + aggregate `{key, day, totals}` rows into cost-ranked shares. Pure.
73
+ * Reused for both models and projects (ModelShare.model carries whichever key). */
74
+ export function rankShares(rows, from, to, totalCost, topN) {
75
+ const per = new Map();
76
+ for (const r of rows) {
77
+ if (!within(r.day, from, to))
78
+ continue;
79
+ const slot = per.get(r.key) ?? { costUsd: 0, totalTokens: 0 };
80
+ slot.costUsd += r.totals.costUsd;
81
+ slot.totalTokens += r.totals.totalTokens;
82
+ per.set(r.key, slot);
83
+ }
84
+ return [...per.entries()]
85
+ .map(([key, v]) => ({ model: key, costUsd: v.costUsd, totalTokens: v.totalTokens, pct: totalCost > 0 ? v.costUsd / totalCost : 0 }))
86
+ .filter((m) => m.costUsd > 0 || m.totalTokens > 0)
87
+ .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens || (a.model < b.model ? -1 : 1))
88
+ .slice(0, topN);
89
+ }
90
+ /** Busiest bucket in a per-hour cost histogram (argmax; earliest hour wins ties),
91
+ * or null when every bucket is empty. Pure. */
92
+ export function peakHourOf(hourHistogram) {
93
+ let best = -1;
94
+ let bestVal = 0;
95
+ for (let h = 0; h < hourHistogram.length; h++) {
96
+ if (hourHistogram[h] > bestVal) {
97
+ bestVal = hourHistogram[h];
98
+ best = h;
99
+ }
100
+ }
101
+ return best < 0 ? null : { hour: best, value: bestVal };
102
+ }
103
+ /** Fold pre-aggregated usage rows into a retrospective. Pure. */
104
+ export function computeReflection(dayRows, modelRows, from, to, opts = {}, projectRows = [], hourHistogram = []) {
105
+ if (!DATE.test(from) || !DATE.test(to))
106
+ throw new Error(`bad period bounds: ${from}..${to}`);
107
+ if (from > to)
108
+ throw new Error(`period "${from}..${to}" is reversed (from > to)`);
109
+ const topN = opts.topModels ?? 5;
110
+ const flat = opts.flatThreshold ?? 0.05;
111
+ const days = dayRows.filter((r) => within(r.key, from, to));
112
+ const totals = {
113
+ costUsd: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0,
114
+ cacheReadTokens: 0, cacheWriteTokens: 0, entries: 0, activeDays: 0,
115
+ };
116
+ let busiestDay = null;
117
+ const activeDayKeys = [];
118
+ const mid = spanDays(from, to) / 2;
119
+ let firstHalfCost = 0;
120
+ let secondHalfCost = 0;
121
+ for (const r of days) {
122
+ const t = r.totals;
123
+ totals.costUsd += t.costUsd;
124
+ totals.totalTokens += t.totalTokens;
125
+ totals.inputTokens += t.inputTokens;
126
+ totals.outputTokens += t.outputTokens;
127
+ totals.cacheReadTokens += t.cacheReadTokens;
128
+ totals.cacheWriteTokens += t.cacheWriteTokens;
129
+ if (t.totalTokens > 0) {
130
+ totals.activeDays += 1;
131
+ activeDayKeys.push(r.key);
132
+ }
133
+ if (!busiestDay || t.costUsd > busiestDay.costUsd) {
134
+ busiestDay = { day: r.key, costUsd: t.costUsd, totalTokens: t.totalTokens };
135
+ }
136
+ // Trend: which half of the period does this day fall in?
137
+ if (spanDays(from, r.key) <= mid)
138
+ firstHalfCost += t.costUsd;
139
+ else
140
+ secondHalfCost += t.costUsd;
141
+ }
142
+ // A period with zero active days has no meaningful busiest day.
143
+ if (totals.activeDays === 0)
144
+ busiestDay = null;
145
+ // Cost-ranked shares over the same window, for both models and projects.
146
+ const topModels = rankShares(modelRows, from, to, totals.costUsd, topN);
147
+ const topProjects = rankShares(projectRows, from, to, totals.costUsd, topN);
148
+ const inputSide = totals.inputTokens + totals.cacheWriteTokens + totals.cacheReadTokens;
149
+ const relChange = firstHalfCost > 0 ? (secondHalfCost - firstHalfCost) / firstHalfCost : (secondHalfCost > 0 ? 1 : 0);
150
+ const direction = Math.abs(relChange) < flat ? 'flat' : relChange > 0 ? 'up' : 'down';
151
+ return {
152
+ period: { from, to, spanDays: spanDays(from, to) },
153
+ totals,
154
+ busiestDay,
155
+ topModels,
156
+ topProjects,
157
+ peakHour: peakHourOf(hourHistogram),
158
+ hourHistogram,
159
+ longestStreak: longestStreakOf(activeDayKeys),
160
+ cacheReadPct: inputSide > 0 ? totals.cacheReadTokens / inputSide : 0,
161
+ avgCostPerActiveDay: totals.activeDays > 0 ? totals.costUsd / totals.activeDays : 0,
162
+ trend: { firstHalfCost, secondHalfCost, direction },
163
+ };
164
+ }
165
+ //# sourceMappingURL=reflect.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * spend-forecast.ts — project month-end Claude Code spend from month-to-date
3
+ * burn, for budgeting ("on pace for ~$420 this month").
4
+ *
5
+ * The quota forecaster (limits.ts) answers "will I hit the rolling cap"; this
6
+ * answers "what will the calendar month cost at the current daily average".
7
+ * Pure + deterministic: month-to-date total, the day-of-month, and the days in
8
+ * the month are passed in, so it's unit-tested without a clock.
9
+ */
10
+ export interface SpendProjection {
11
+ monthToDateUsd: number;
12
+ dayOfMonth: number;
13
+ daysInMonth: number;
14
+ /** month-to-date / day-of-month */
15
+ dailyAverageUsd: number;
16
+ /** dailyAverage × daysInMonth — projected month-end total */
17
+ projectedUsd: number;
18
+ /** projectedUsd − monthToDateUsd — the remaining projected spend */
19
+ remainingUsd: number;
20
+ }
21
+ /** Days in the calendar month of an ISO `YYYY-MM` (or `YYYY-MM-DD`). Pure. */
22
+ export declare function daysInMonthOf(iso: string): number;
23
+ /**
24
+ * Project month-end spend at the current daily average. `dayOfMonth` is how many
25
+ * days (inclusive of today) have accrued the month-to-date total. Pure.
26
+ */
27
+ export declare function projectMonthEnd(monthToDateUsd: number, dayOfMonth: number, daysInMonth: number): SpendProjection;
28
+ //# sourceMappingURL=spend-forecast.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * spend-forecast.ts — project month-end Claude Code spend from month-to-date
3
+ * burn, for budgeting ("on pace for ~$420 this month").
4
+ *
5
+ * The quota forecaster (limits.ts) answers "will I hit the rolling cap"; this
6
+ * answers "what will the calendar month cost at the current daily average".
7
+ * Pure + deterministic: month-to-date total, the day-of-month, and the days in
8
+ * the month are passed in, so it's unit-tested without a clock.
9
+ */
10
+ /** Days in the calendar month of an ISO `YYYY-MM` (or `YYYY-MM-DD`). Pure. */
11
+ export function daysInMonthOf(iso) {
12
+ const [y, m] = iso.split('-').map(Number);
13
+ if (!Number.isFinite(y) || !Number.isFinite(m) || m < 1 || m > 12)
14
+ return NaN;
15
+ return new Date(y, m, 0).getDate(); // day 0 of the next month = last day of this one
16
+ }
17
+ /**
18
+ * Project month-end spend at the current daily average. `dayOfMonth` is how many
19
+ * days (inclusive of today) have accrued the month-to-date total. Pure.
20
+ */
21
+ export function projectMonthEnd(monthToDateUsd, dayOfMonth, daysInMonth) {
22
+ const dailyAverageUsd = dayOfMonth > 0 ? monthToDateUsd / dayOfMonth : 0;
23
+ const projectedUsd = dailyAverageUsd * daysInMonth;
24
+ return {
25
+ monthToDateUsd,
26
+ dayOfMonth,
27
+ daysInMonth,
28
+ dailyAverageUsd,
29
+ projectedUsd,
30
+ remainingUsd: Math.max(0, projectedUsd - monthToDateUsd),
31
+ };
32
+ }
33
+ //# sourceMappingURL=spend-forecast.js.map
@@ -31,6 +31,20 @@ export interface ToolErrorReport {
31
31
  filesScanned: number;
32
32
  sessionsWithErrors: number;
33
33
  }
34
+ export interface Delegation {
35
+ /** tool_use calls to the `Task` tool (subagent spawns) */
36
+ taskCalls: number;
37
+ /** all tool_use calls */
38
+ toolCalls: number;
39
+ /** taskCalls / toolCalls, 0..1 (0 when no tool calls) */
40
+ ratio: number;
41
+ }
42
+ /**
43
+ * Delegation ratio: what fraction of tool calls spawned a subagent (the `Task`
44
+ * tool), from an already-collected tool report. The report's parsing/counting
45
+ * is the tested collectToolErrors path — this just extracts the Task share. Pure.
46
+ */
47
+ export declare function delegationFromReport(report: Pick<ToolErrorReport, 'tools' | 'totalCalls'>): Delegation;
34
48
  export interface ParsedLine {
35
49
  type?: string;
36
50
  timestamp?: string;
@@ -12,6 +12,16 @@
12
12
  */
13
13
  import * as fs from 'node:fs';
14
14
  import { defaultClaudeProjectDirs, findTranscriptFiles, normalizeDateBound } from './transcript-usage.js';
15
+ /**
16
+ * Delegation ratio: what fraction of tool calls spawned a subagent (the `Task`
17
+ * tool), from an already-collected tool report. The report's parsing/counting
18
+ * is the tested collectToolErrors path — this just extracts the Task share. Pure.
19
+ */
20
+ export function delegationFromReport(report) {
21
+ const taskCalls = report.tools.find((t) => t.tool === 'Task')?.calls ?? 0;
22
+ const toolCalls = report.totalCalls;
23
+ return { taskCalls, toolCalls, ratio: toolCalls > 0 ? taskCalls / toolCalls : 0 };
24
+ }
15
25
  export function newAccum() {
16
26
  return { idToTool: new Map(), calls: new Map(), errors: new Map(), sigs: new Map(), sessionsWithErrors: new Set() };
17
27
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * csv.ts — minimal RFC 4180 CSV serialization for exporting tabular command
3
+ * output (e.g. `usage --csv` into a spreadsheet / expense report).
4
+ *
5
+ * Pure and dependency-free. A field is quoted only when it must be — it contains
6
+ * a comma, a double-quote, or a line break — and interior double-quotes are
7
+ * doubled. Rows are joined with '\n' (LF), which every spreadsheet importer
8
+ * accepts.
9
+ */
10
+ /** Quote + escape a single CSV field per RFC 4180 (only when necessary). Pure. */
11
+ export declare function escapeCsvField(value: string | number | boolean | null | undefined): string;
12
+ /** Serialize a header row + data rows to a CSV string. Pure. */
13
+ export declare function toCsv(headers: string[], rows: Array<Array<string | number | boolean | null | undefined>>): string;
14
+ //# sourceMappingURL=csv.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * csv.ts — minimal RFC 4180 CSV serialization for exporting tabular command
3
+ * output (e.g. `usage --csv` into a spreadsheet / expense report).
4
+ *
5
+ * Pure and dependency-free. A field is quoted only when it must be — it contains
6
+ * a comma, a double-quote, or a line break — and interior double-quotes are
7
+ * doubled. Rows are joined with '\n' (LF), which every spreadsheet importer
8
+ * accepts.
9
+ */
10
+ /** Quote + escape a single CSV field per RFC 4180 (only when necessary). Pure. */
11
+ export function escapeCsvField(value) {
12
+ const s = value === null || value === undefined ? '' : String(value);
13
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
14
+ }
15
+ /** Serialize a header row + data rows to a CSV string. Pure. */
16
+ export function toCsv(headers, rows) {
17
+ const line = (cells) => cells.map(escapeCsvField).join(',');
18
+ return [line(headers), ...rows.map(line)].join('\n');
19
+ }
20
+ //# sourceMappingURL=csv.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmdo/cli",
3
- "version": "1.29.0",
3
+ "version": "1.30.0",
4
4
  "type": "module",
5
5
  "description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",