swarmdo 1.29.0 → 1.37.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.
Files changed (76) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +66 -33
  2. package/README.md +6 -3
  3. package/package.json +1 -1
  4. package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +65 -32
  5. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  6. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  7. package/v3/@swarmdo/cli/dist/src/apply/apply.js +18 -7
  8. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +4 -1
  9. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  10. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  11. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  13. package/v3/@swarmdo/cli/dist/src/commands/config.js +1 -0
  14. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +121 -1
  15. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +8 -2
  16. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  18. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  19. package/v3/@swarmdo/cli/dist/src/commands/usage.js +208 -3
  20. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  21. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  22. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  23. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  24. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  25. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  26. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -1
  27. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +11 -4
  28. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  29. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +20 -1
  30. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  31. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  32. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  33. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  34. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  38. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  39. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  40. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  41. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  42. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  43. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  44. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  45. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  46. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  47. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  48. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  49. package/v3/@swarmdo/cli/dist/src/redact/redact.js +7 -2
  50. package/v3/@swarmdo/cli/dist/src/release/release.js +1 -1
  51. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  52. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  53. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  54. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  55. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  56. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  57. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  58. package/v3/@swarmdo/cli/dist/src/usage/limits.d.ts +77 -0
  59. package/v3/@swarmdo/cli/dist/src/usage/limits.js +134 -0
  60. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  61. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  62. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.d.ts +23 -0
  63. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +95 -0
  64. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +131 -0
  65. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +209 -0
  66. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/usage/spend-forecast.js +33 -0
  68. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.d.ts +14 -0
  69. package/v3/@swarmdo/cli/dist/src/usage/transcript-errors.js +10 -0
  70. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  71. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  72. package/v3/@swarmdo/cli/dist/src/util/csv.d.ts +14 -0
  73. package/v3/@swarmdo/cli/dist/src/util/csv.js +20 -0
  74. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  75. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  76. package/v3/@swarmdo/cli/package.json +1 -1
@@ -14,12 +14,21 @@
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';
27
+ import { computeModelEfficiency } from '../usage/model-efficiency.js';
28
+ import { detectSpikeDays } from '../usage/reflect.js';
21
29
  const VIEWS = {
22
30
  daily: { dimension: 'day', label: 'Date' },
31
+ weekly: { dimension: 'week', label: 'ISO week' },
23
32
  monthly: { dimension: 'month', label: 'Month' },
24
33
  models: { dimension: 'model', label: 'Model' },
25
34
  projects: { dimension: 'project', label: 'Project' },
@@ -376,6 +385,152 @@ function runGuardView(ctx, collection) {
376
385
  output.writeln(output.dim('exit 1 (--strict + over budget)'));
377
386
  return { success: exitCode === 0, exitCode };
378
387
  }
388
+ const PERIOD_MONTHS = { '1m': 1, '3m': 3, '6m': 6, '12m': 12 };
389
+ /** `usage reflect` — a wrapped-style retrospective over the local transcripts (#47). */
390
+ function runReflectView(ctx, collection) {
391
+ const periodFlag = (typeof ctx.flags.period === 'string' ? ctx.flags.period : '3m').toLowerCase();
392
+ const months = PERIOD_MONTHS[periodFlag];
393
+ if (!months) {
394
+ output.printError(`unknown --period "${periodFlag}" (expected ${Object.keys(PERIOD_MONTHS).join('|')})`);
395
+ return { success: false, exitCode: 1 };
396
+ }
397
+ const to = localDateKey(new Date());
398
+ const from = monthsBefore(to, months);
399
+ const dayRows = aggregateUsage(collection.events, 'day').map((r) => ({ key: r.key, totals: r.totals }));
400
+ const md = new Map();
401
+ const pd = new Map(); // per-(project, day) for top projects
402
+ const hourHistogram = new Array(24).fill(0); // local-hour cost, windowed to [from,to]
403
+ for (const e of collection.events) {
404
+ const k = `${e.model}\u0000${e.dateKey}`;
405
+ const row = md.get(k) ?? { key: e.model, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
406
+ row.totals.costUsd += e.costUsd;
407
+ row.totals.inputTokens += e.inputTokens; // needed for cache-savings pricing
408
+ row.totals.cacheWriteTokens += e.cacheWriteTokens;
409
+ row.totals.cacheReadTokens += e.cacheReadTokens;
410
+ row.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
411
+ md.set(k, row);
412
+ // per-(project, day) fold (space separator is safe — dateKey is fixed-width)
413
+ const pk = `${e.project} ${e.dateKey}`;
414
+ const prow = pd.get(pk) ?? { key: e.project, day: e.dateKey, totals: { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 } };
415
+ prow.totals.costUsd += e.costUsd;
416
+ prow.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
417
+ pd.set(pk, prow);
418
+ if (e.dateKey >= from && e.dateKey <= to)
419
+ hourHistogram[new Date(e.timestampMs).getHours()] += e.costUsd;
420
+ }
421
+ const reflection = computeReflection(dayRows, [...md.values()], from, to, {}, [...pd.values()], hourHistogram);
422
+ // Delegation ratio (#47): reuse the (windowed) tool-error scan to count Task
423
+ // (subagent) calls vs all tool calls — the parsing is already tested there.
424
+ const delDirFlag = ctx.flags.dir;
425
+ const delDirs = typeof delDirFlag === 'string' ? [delDirFlag] : Array.isArray(delDirFlag) ? delDirFlag.map(String) : undefined;
426
+ const delegation = delegationFromReport(collectToolErrors({ dirs: delDirs, since: from, until: to }));
427
+ if (ctx.flags.json === true) {
428
+ output.printJson({ ...reflection, delegation });
429
+ return { success: true, data: reflection };
430
+ }
431
+ if (ctx.flags.html === true) {
432
+ output.writeln(renderReflectionHtml(reflection, { generatedAt: to, delegation }));
433
+ return { success: true, exitCode: 0 };
434
+ }
435
+ const r = reflection;
436
+ const pctStr = (x) => `${Math.round(x * 100)}%`;
437
+ const arrow = r.trend.direction === 'up' ? '↑' : r.trend.direction === 'down' ? '↓' : '→';
438
+ output.writeln(output.bold(`Usage reflect ${r.period.from}..${r.period.to}`) + output.dim(` (${periodFlag}, ${r.period.spanDays} days)`));
439
+ if (r.totals.activeDays === 0) {
440
+ output.writeln(output.info('no Claude Code activity in this window'));
441
+ return { success: true, exitCode: 0 };
442
+ }
443
+ output.writeln('');
444
+ 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}`)}`);
445
+ output.writeln(` Total tokens ${r.totals.totalTokens.toLocaleString()}`);
446
+ if (r.busiestDay)
447
+ output.writeln(` Busiest day ${r.busiestDay.day} ${output.dim(`(${fmtCost(r.busiestDay.costUsd)}, ${r.busiestDay.totalTokens.toLocaleString()} tok)`)}`);
448
+ if (r.spikeDays.length) {
449
+ const top = r.spikeDays.slice(0, 3).map((s) => `${s.day} ${fmtCost(s.costUsd)} (${s.ratioToMedian.toFixed(1)}×)`).join(', ');
450
+ output.writeln(` Spike days ${top} ${output.dim('vs median')}`);
451
+ }
452
+ output.writeln(` Longest streak ${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`);
453
+ output.writeln(` Avg / active day ${fmtCost(r.avgCostPerActiveDay)}`);
454
+ output.writeln(` Cache read share ${pctStr(r.cacheReadPct)}`);
455
+ if (r.cacheSavingsUsd > 0)
456
+ output.writeln(` Cache saved ${fmtCost(r.cacheSavingsUsd)} ${output.dim('vs no caching')}`);
457
+ if (delegation.toolCalls > 0) {
458
+ output.writeln(` Delegation ${pctStr(delegation.ratio)} ${output.dim(`(${delegation.taskCalls} of ${delegation.toolCalls} tool calls to subagents)`)}`);
459
+ }
460
+ output.writeln(` Cost trend ${arrow} ${fmtCost(r.trend.firstHalfCost)} → ${fmtCost(r.trend.secondHalfCost)} ${output.dim(`(${r.trend.direction})`)}`);
461
+ if (r.peakHour)
462
+ output.writeln(` Peak hour ${String(r.peakHour.hour).padStart(2, '0')}:00 ${output.dim(`(${fmtCost(r.peakHour.value)})`)}`);
463
+ if (r.hourHistogram.some((v) => v > 0)) {
464
+ output.writeln(` By hour ${hourSparkline(r.hourHistogram)} ${output.dim('0…23')}`);
465
+ }
466
+ if (r.topModels.length) {
467
+ output.writeln('');
468
+ output.writeln(output.bold(' Top models'));
469
+ r.topModels.forEach((m, i) => {
470
+ output.writeln(` ${i + 1}. ${m.model.padEnd(22)} ${fmtCost(m.costUsd).padStart(9)} ${output.dim(pctStr(m.pct))}`);
471
+ });
472
+ }
473
+ if (r.topProjects.length) {
474
+ output.writeln('');
475
+ output.writeln(output.bold(' Top projects'));
476
+ r.topProjects.forEach((p, i) => {
477
+ const name = p.model.length > 34 ? '…' + p.model.slice(-33) : p.model;
478
+ output.writeln(` ${i + 1}. ${name.padEnd(34)} ${fmtCost(p.costUsd).padStart(9)} ${output.dim(pctStr(p.pct))}`);
479
+ });
480
+ }
481
+ return { success: true, exitCode: 0 };
482
+ }
483
+ /** `usage limits` — forecast the official 5h/7d quota windows from the
484
+ * statusline rate_limits payload (stdin or --payload). #46. */
485
+ function runLimitsView(ctx) {
486
+ const nowMs = Date.now();
487
+ let raw = typeof ctx.flags.payload === 'string' ? ctx.flags.payload : '';
488
+ if (!raw && !process.stdin.isTTY) {
489
+ try {
490
+ raw = readFileSync(0, 'utf8');
491
+ }
492
+ catch { /* no piped input */ }
493
+ }
494
+ if (!raw.trim()) {
495
+ output.writeln(output.info("no rate_limits payload — pipe Claude Code's statusline JSON in, or pass --payload '<json>'"));
496
+ output.writeln(output.dim(' e.g. in a statusline command: … | swarmdo usage limits'));
497
+ return { success: true, exitCode: 0 };
498
+ }
499
+ let payload;
500
+ try {
501
+ payload = JSON.parse(raw);
502
+ }
503
+ catch {
504
+ output.printError('payload is not valid JSON');
505
+ return { success: false, exitCode: 1 };
506
+ }
507
+ const windows = parseRateLimits(payload);
508
+ if (windows.length === 0) {
509
+ output.writeln(output.info('no five_hour / seven_day rate_limits found in the payload'));
510
+ return { success: true, exitCode: 0 };
511
+ }
512
+ const forecasts = windows.map((w) => ({ label: w.label, f: forecastWindow(w.state, nowMs) }));
513
+ if (ctx.flags.json === true) {
514
+ output.printJson(forecasts.map((x) => ({ label: x.label, ...x.f })));
515
+ return { success: true, data: forecasts };
516
+ }
517
+ if (ctx.flags.oneline === true) {
518
+ // Compact statusline-ready indicator, e.g. "5h 72%⚠ · 7d 12%".
519
+ output.writeln(formatLimitSegment(forecasts));
520
+ const worstOne = worstStatus(forecasts.map((x) => x.f));
521
+ return { success: true, exitCode: ctx.flags.strict === true && worstOne === 'over' ? 1 : 0 };
522
+ }
523
+ const worst = worstStatus(forecasts.map((x) => x.f));
524
+ output.writeln(output.bold('Usage limits') + output.dim(` (${worst})`));
525
+ for (const { label, f } of forecasts) {
526
+ const line = formatForecast(label, f, nowMs);
527
+ output.writeln(' ' + (f.status === 'over' ? output.error(line) : f.status === 'warn' ? line : output.dim(line)));
528
+ }
529
+ const exitCode = ctx.flags.strict === true && worst === 'over' ? 1 : 0;
530
+ if (exitCode === 1)
531
+ output.writeln(output.dim('exit 1 (--strict + cap reached)'));
532
+ return { success: exitCode === 0, exitCode };
533
+ }
379
534
  async function run(ctx) {
380
535
  const viewName = (ctx.args[0] || 'daily').toLowerCase();
381
536
  if (viewName === 'blocks') {
@@ -409,9 +564,17 @@ async function run(ctx) {
409
564
  const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
410
565
  return runDiffView(ctx, collectUsage({ dirs }));
411
566
  }
567
+ if (viewName === 'reflect') {
568
+ const dirFlag = ctx.flags.dir;
569
+ const dirs = typeof dirFlag === 'string' ? [dirFlag] : Array.isArray(dirFlag) ? dirFlag.map(String) : undefined;
570
+ return runReflectView(ctx, collectUsage({ dirs }));
571
+ }
572
+ if (viewName === 'limits') {
573
+ return runLimitsView(ctx); // reads the rate_limits payload from stdin/--payload, not transcripts
574
+ }
412
575
  const view = VIEWS[viewName];
413
576
  if (!view) {
414
- output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff)`));
577
+ output.writeln(output.error(`unknown view: ${viewName} (expected ${Object.keys(VIEWS).join('|')}|blocks|errors|cache|guard|diff|reflect|limits)`));
415
578
  return { success: false, exitCode: 1 };
416
579
  }
417
580
  const since = typeof ctx.flags.since === 'string' ? ctx.flags.since : undefined;
@@ -433,12 +596,51 @@ async function run(ctx) {
433
596
  output.writeln(JSON.stringify(jsonPayload(viewName, collection, rows, grand, since, until), null, 2));
434
597
  return { success: true, exitCode: 0, data: grand };
435
598
  }
599
+ if (ctx.flags.csv === true) {
600
+ // Portable export for spreadsheets / expense reports.
601
+ const headers = ['key', 'costUsd', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'entries'];
602
+ 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]);
603
+ output.writeln(toCsv(headers, csvRows));
604
+ return { success: true, exitCode: 0, data: grand };
605
+ }
436
606
  if (collection.events.length === 0) {
437
607
  output.writeln(output.info(`no usage entries found in ${collection.filesScanned} transcript files`));
438
608
  return { success: true, exitCode: 0 };
439
609
  }
440
610
  output.writeln(output.bold(`Claude Code usage — ${viewName}`) + (since || until ? output.dim(` (${since ?? '…'} → ${until ?? '…'})`) : ''));
441
611
  renderTable(view.label, rows, grand);
612
+ if (viewName === 'models') {
613
+ // Effective cost per unit of useful output — surfaces models made expensive
614
+ // by heavy input/cache overhead, beyond their sticker output price.
615
+ const eff = computeModelEfficiency(aggregateUsage(collection.events, 'model'));
616
+ if (eff.length >= 2) {
617
+ const best = eff[0];
618
+ const worst = eff[eff.length - 1];
619
+ output.writeln(output.dim(`effective $/1M output — best ${best.model} ${fmtCost(best.costPerMOutput)}, worst ${worst.model} ${fmtCost(worst.costPerMOutput)}`));
620
+ }
621
+ }
622
+ if (viewName === 'monthly') {
623
+ // Month-end pace projection for the current calendar month.
624
+ const today = localDateKey(new Date());
625
+ const curMonth = today.slice(0, 7);
626
+ const dom = Number(today.slice(8, 10));
627
+ const curRow = rows.find((r) => r.key === curMonth);
628
+ const dim = daysInMonthOf(curMonth);
629
+ if (curRow && dom > 0 && Number.isFinite(dim)) {
630
+ const p = projectMonthEnd(curRow.totals.costUsd, dom, dim);
631
+ output.writeln(output.dim(`${curMonth} on pace for ~${fmtCost(p.projectedUsd)} (${fmtCost(p.monthToDateUsd)} over ${dom}/${p.daysInMonth} days · ~${fmtCost(p.remainingUsd)} to go)`));
632
+ }
633
+ }
634
+ if (viewName === 'sessions') {
635
+ // Outlier-session detection: a single runaway session (one work unit) is more
636
+ // actionable than a spike day. Run the same median-ratio detector over ALL
637
+ // sessions (not the top-N display slice) so the baseline is representative.
638
+ const spikes = detectSpikeDays(aggregateUsage(collection.events, 'session').map((r) => ({ day: r.key, costUsd: r.totals.costUsd })));
639
+ if (spikes.length > 0) {
640
+ const top = spikes.slice(0, 3).map((s) => `${s.day} ${fmtCost(s.costUsd)} (${s.ratioToMedian.toFixed(1)}× median)`).join(', ');
641
+ output.writeln(output.dim(`outlier sessions: ${top}`));
642
+ }
643
+ }
442
644
  output.writeln(output.dim(`${collection.filesScanned} transcript files · ${grand.entries} billed responses · sources: ${collection.dirsScanned.join(', ')}`));
443
645
  if (collection.unpricedModels.length > 0) {
444
646
  const { unpricedResponses, unpricedTokens } = unpricedStats(collection.events);
@@ -449,18 +651,21 @@ async function run(ctx) {
449
651
  export const usageCommand = {
450
652
  name: 'usage',
451
653
  aliases: ['cost'],
452
- description: 'Claude Code token & cost analytics from local transcripts (daily|monthly|models|projects|sessions)',
654
+ description: 'Claude Code token & cost analytics from local transcripts (daily|weekly|monthly|models|projects|sessions)',
453
655
  options: [
454
656
  { name: 'since', description: 'inclusive start date (YYYY-MM-DD or YYYYMMDD)', type: 'string' },
455
657
  { name: 'until', description: 'inclusive end date (YYYY-MM-DD or YYYYMMDD)', type: 'string' },
456
658
  { name: 'dir', description: 'explicit Claude projects dir (replaces auto-discovery)', type: 'string' },
457
659
  { name: 'limit', description: 'max rows for models/projects/sessions views', type: 'number' },
458
660
  { name: 'json', description: 'machine-readable output', type: 'boolean', default: false },
661
+ { name: 'csv', description: 'CSV export (daily|monthly|models|projects|sessions views)', type: 'boolean', default: false },
459
662
  ],
460
663
  examples: [
461
664
  { command: 'swarmdo usage', description: 'Daily token/cost table across all local Claude Code sessions' },
665
+ { command: 'swarmdo usage weekly', description: 'Spend grouped by ISO week' },
462
666
  { command: 'swarmdo usage models --since 2026-07-01', description: 'Spend per model this month' },
463
667
  { command: 'swarmdo usage projects --json', description: 'Per-project totals as JSON' },
668
+ { command: 'swarmdo usage monthly --csv > usage.csv', description: 'Export monthly spend to CSV' },
464
669
  { command: 'swarmdo cost sessions --limit 10', description: 'Ten most expensive sessions (alias)' },
465
670
  ],
466
671
  action: run,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * comms/mailbox.ts — pure engine for the cross-session agent mailbox (#44).
3
+ *
4
+ * A shared `.swarmdo/comms/store.json` lets any Claude Code session on the same
5
+ * repo/machine leave messages for another by session name ("multiplayer
6
+ * swarms"). This module is the pure, I/O-free core: the message model, immutable
7
+ * add, inbox filtering/ordering, read-state tracking, and retention. The command
8
+ * layer (`swarmdo comms`) supplies id/timestamp generation and fs persistence.
9
+ *
10
+ * A message addressed to `all` (case-insensitive) is a broadcast — it appears in
11
+ * every session's inbox.
12
+ */
13
+ export interface Message {
14
+ id: string;
15
+ from: string;
16
+ to: string;
17
+ subject: string;
18
+ body: string;
19
+ /** ISO-8601 creation time. */
20
+ createdAt: string;
21
+ read: boolean;
22
+ }
23
+ export interface Mailbox {
24
+ messages: Record<string, Message>;
25
+ version: string;
26
+ }
27
+ /** The broadcast recipient — a message to this address reaches every inbox. */
28
+ export declare const BROADCAST = "all";
29
+ export declare function emptyMailbox(): Mailbox;
30
+ /**
31
+ * Build a message. `id` and `createdAt` are injected (not generated here) so the
32
+ * engine stays pure and unit-testable; the command layer supplies a crypto id
33
+ * and the wall clock. from/to/subject are trimmed; a blank subject becomes
34
+ * '(no subject)'.
35
+ */
36
+ export declare function createMessage(opts: {
37
+ id: string;
38
+ from: string;
39
+ to: string;
40
+ body: string;
41
+ createdAt: string;
42
+ subject?: string;
43
+ }): Message;
44
+ /** Immutably add a message. Same id is a no-op (idempotent delivery). */
45
+ export declare function addMessage(box: Mailbox, msg: Message): Mailbox;
46
+ export interface InboxFilter {
47
+ /** Recipient session name; matches this recipient plus `all` broadcasts. Omit for every message. */
48
+ to?: string;
49
+ /** Only messages from this sender (case-insensitive). */
50
+ from?: string;
51
+ /** Only unread messages. */
52
+ unreadOnly?: boolean;
53
+ /** Only messages created strictly after this ISO timestamp. */
54
+ since?: string;
55
+ }
56
+ /**
57
+ * Filter the mailbox to an inbox view, sorted NEWEST-FIRST (tiebreak by id).
58
+ * A `to` filter matches the named recipient and `all` broadcasts.
59
+ */
60
+ export declare function filterInbox(box: Mailbox, filter?: InboxFilter): Message[];
61
+ /** Count unread messages for a recipient (includes `all` broadcasts). */
62
+ export declare function unreadCount(box: Mailbox, to?: string): number;
63
+ /**
64
+ * Render a set of messages as a compact context block for hook delivery — the
65
+ * "surface new mail without polling" path. Returns '' for no messages (caller
66
+ * injects nothing). Each body is whitespace-collapsed and capped at maxBodyChars.
67
+ */
68
+ export declare function renderInboxContext(messages: Message[], opts?: {
69
+ header?: string;
70
+ maxBodyChars?: number;
71
+ }): string;
72
+ /** Immutably mark the given ids read. Returns the new box + how many flipped unread→read. */
73
+ export declare function markRead(box: Mailbox, ids: string[]): {
74
+ box: Mailbox;
75
+ marked: number;
76
+ };
77
+ /**
78
+ * Retention: drop READ messages older than maxAgeMs, then — if still over
79
+ * maxCount — drop the oldest READ messages until at the cap. UNREAD messages are
80
+ * never dropped (delivery is guaranteed until acknowledged). `nowMs` is injected
81
+ * to keep this pure.
82
+ */
83
+ export declare function pruneMailbox(box: Mailbox, opts: {
84
+ maxAgeMs?: number;
85
+ maxCount?: number;
86
+ nowMs: number;
87
+ }): Mailbox;
88
+ //# sourceMappingURL=mailbox.d.ts.map
@@ -0,0 +1,138 @@
1
+ /**
2
+ * comms/mailbox.ts — pure engine for the cross-session agent mailbox (#44).
3
+ *
4
+ * A shared `.swarmdo/comms/store.json` lets any Claude Code session on the same
5
+ * repo/machine leave messages for another by session name ("multiplayer
6
+ * swarms"). This module is the pure, I/O-free core: the message model, immutable
7
+ * add, inbox filtering/ordering, read-state tracking, and retention. The command
8
+ * layer (`swarmdo comms`) supplies id/timestamp generation and fs persistence.
9
+ *
10
+ * A message addressed to `all` (case-insensitive) is a broadcast — it appears in
11
+ * every session's inbox.
12
+ */
13
+ /** The broadcast recipient — a message to this address reaches every inbox. */
14
+ export const BROADCAST = 'all';
15
+ export function emptyMailbox() {
16
+ return { messages: {}, version: '1.0.0' };
17
+ }
18
+ /**
19
+ * Build a message. `id` and `createdAt` are injected (not generated here) so the
20
+ * engine stays pure and unit-testable; the command layer supplies a crypto id
21
+ * and the wall clock. from/to/subject are trimmed; a blank subject becomes
22
+ * '(no subject)'.
23
+ */
24
+ export function createMessage(opts) {
25
+ return {
26
+ id: opts.id,
27
+ from: (opts.from || '').trim() || 'unknown',
28
+ to: (opts.to || '').trim(),
29
+ subject: (opts.subject || '').trim() || '(no subject)',
30
+ body: opts.body ?? '',
31
+ createdAt: opts.createdAt,
32
+ read: false,
33
+ };
34
+ }
35
+ /** Immutably add a message. Same id is a no-op (idempotent delivery). */
36
+ export function addMessage(box, msg) {
37
+ if (box.messages[msg.id])
38
+ return box;
39
+ return { ...box, messages: { ...box.messages, [msg.id]: msg } };
40
+ }
41
+ const eqCI = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
42
+ /**
43
+ * Filter the mailbox to an inbox view, sorted NEWEST-FIRST (tiebreak by id).
44
+ * A `to` filter matches the named recipient and `all` broadcasts.
45
+ */
46
+ export function filterInbox(box, filter = {}) {
47
+ const all = Object.values(box.messages || {});
48
+ const filtered = all.filter((m) => {
49
+ if (filter.to !== undefined && !(eqCI(m.to, filter.to) || eqCI(m.to, BROADCAST)))
50
+ return false;
51
+ if (filter.from !== undefined && !eqCI(m.from, filter.from))
52
+ return false;
53
+ if (filter.unreadOnly && m.read)
54
+ return false;
55
+ if (filter.since !== undefined && !(m.createdAt > filter.since))
56
+ return false;
57
+ return true;
58
+ });
59
+ return filtered.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.id.localeCompare(b.id)));
60
+ }
61
+ /** Count unread messages for a recipient (includes `all` broadcasts). */
62
+ export function unreadCount(box, to) {
63
+ return filterInbox(box, { to, unreadOnly: true }).length;
64
+ }
65
+ /**
66
+ * Render a set of messages as a compact context block for hook delivery — the
67
+ * "surface new mail without polling" path. Returns '' for no messages (caller
68
+ * injects nothing). Each body is whitespace-collapsed and capped at maxBodyChars.
69
+ */
70
+ export function renderInboxContext(messages, opts = {}) {
71
+ if (!messages || messages.length === 0)
72
+ return '';
73
+ const header = opts.header ?? '## 📬 New messages (swarmdo comms)';
74
+ const maxBody = opts.maxBodyChars ?? 500;
75
+ const lines = [
76
+ header,
77
+ `You have ${messages.length} new message${messages.length === 1 ? '' : 's'} from other sessions:`,
78
+ '',
79
+ ];
80
+ for (const m of messages) {
81
+ const body = (m.body || '').replace(/\s+/g, ' ').trim();
82
+ const trimmed = body.length > maxBody ? body.slice(0, maxBody).trimEnd() + '…' : body;
83
+ lines.push(`- **from ${m.from}** — ${m.subject}: ${trimmed}`);
84
+ }
85
+ return lines.join('\n');
86
+ }
87
+ /** Immutably mark the given ids read. Returns the new box + how many flipped unread→read. */
88
+ export function markRead(box, ids) {
89
+ const idSet = new Set(ids);
90
+ let marked = 0;
91
+ const messages = {};
92
+ for (const [id, m] of Object.entries(box.messages || {})) {
93
+ if (idSet.has(id) && !m.read) {
94
+ messages[id] = { ...m, read: true };
95
+ marked++;
96
+ }
97
+ else {
98
+ messages[id] = m;
99
+ }
100
+ }
101
+ return { box: marked ? { ...box, messages } : box, marked };
102
+ }
103
+ /**
104
+ * Retention: drop READ messages older than maxAgeMs, then — if still over
105
+ * maxCount — drop the oldest READ messages until at the cap. UNREAD messages are
106
+ * never dropped (delivery is guaranteed until acknowledged). `nowMs` is injected
107
+ * to keep this pure.
108
+ */
109
+ export function pruneMailbox(box, opts) {
110
+ const entries = Object.values(box.messages || {});
111
+ const keep = new Map();
112
+ const droppableRead = [];
113
+ for (const m of entries) {
114
+ if (!m.read) {
115
+ keep.set(m.id, m);
116
+ continue;
117
+ }
118
+ const ageMs = opts.nowMs - Date.parse(m.createdAt);
119
+ if (opts.maxAgeMs !== undefined && Number.isFinite(ageMs) && ageMs > opts.maxAgeMs)
120
+ continue; // aged out
121
+ keep.set(m.id, m);
122
+ droppableRead.push(m);
123
+ }
124
+ if (opts.maxCount !== undefined && keep.size > opts.maxCount) {
125
+ // Drop oldest read first until at the cap (unread are never in droppableRead).
126
+ droppableRead.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
127
+ for (const m of droppableRead) {
128
+ if (keep.size <= opts.maxCount)
129
+ break;
130
+ keep.delete(m.id);
131
+ }
132
+ }
133
+ const messages = {};
134
+ for (const [id, m] of keep)
135
+ messages[id] = m;
136
+ return { ...box, messages };
137
+ }
138
+ //# sourceMappingURL=mailbox.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * comms/store.ts — fs persistence + identity for the cross-session mailbox (#44).
3
+ *
4
+ * Shared by the `swarmdo comms` CLI command AND the comms_send/comms_inbox MCP
5
+ * tools so both read/write the SAME `.swarmdo/comms/store.json` under a given
6
+ * project root. The pure message logic lives in mailbox.ts; this is only I/O.
7
+ */
8
+ import { type Mailbox } from './mailbox.js';
9
+ /** Retention applied opportunistically on send: keep read messages ≤30d, ≤500 total. */
10
+ export declare const RETENTION: {
11
+ maxAgeMs: number;
12
+ maxCount: number;
13
+ };
14
+ export declare function storePath(cwd: string): string;
15
+ export declare function loadMailbox(cwd: string): Mailbox;
16
+ export declare function saveMailbox(cwd: string, box: Mailbox): void;
17
+ /** This session's name: explicit override → SWARMDO_SESSION → hostname → "me". */
18
+ export declare function resolveSelf(flag?: unknown): string;
19
+ /** Crypto-strong message id. */
20
+ export declare function newMessageId(): string;
21
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * comms/store.ts — fs persistence + identity for the cross-session mailbox (#44).
3
+ *
4
+ * Shared by the `swarmdo comms` CLI command AND the comms_send/comms_inbox MCP
5
+ * tools so both read/write the SAME `.swarmdo/comms/store.json` under a given
6
+ * project root. The pure message logic lives in mailbox.ts; this is only I/O.
7
+ */
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { hostname } from 'node:os';
12
+ import { emptyMailbox } from './mailbox.js';
13
+ const STORE_REL = join('.swarmdo', 'comms', 'store.json');
14
+ /** Retention applied opportunistically on send: keep read messages ≤30d, ≤500 total. */
15
+ export const RETENTION = { maxAgeMs: 30 * 24 * 3600 * 1000, maxCount: 500 };
16
+ export function storePath(cwd) {
17
+ return join(cwd, STORE_REL);
18
+ }
19
+ export function loadMailbox(cwd) {
20
+ try {
21
+ const p = storePath(cwd);
22
+ if (existsSync(p))
23
+ return JSON.parse(readFileSync(p, 'utf-8'));
24
+ }
25
+ catch {
26
+ /* corrupt/missing → fresh mailbox */
27
+ }
28
+ return emptyMailbox();
29
+ }
30
+ export function saveMailbox(cwd, box) {
31
+ const p = storePath(cwd);
32
+ mkdirSync(dirname(p), { recursive: true });
33
+ writeFileSync(p, JSON.stringify(box, null, 2) + '\n', 'utf-8');
34
+ }
35
+ /** This session's name: explicit override → SWARMDO_SESSION → hostname → "me". */
36
+ export function resolveSelf(flag) {
37
+ const f = typeof flag === 'string' ? flag.trim() : '';
38
+ return f || (process.env.SWARMDO_SESSION || '').trim() || hostname() || 'me';
39
+ }
40
+ /** Crypto-strong message id. */
41
+ export function newMessageId() {
42
+ return 'msg_' + randomBytes(6).toString('hex');
43
+ }
44
+ //# sourceMappingURL=store.js.map
@@ -60,7 +60,10 @@ function collapseRepeats(lines, minRun) {
60
60
  j++;
61
61
  const run = j - i;
62
62
  out.push(lines[i]);
63
- if (run >= minRun)
63
+ // `run > 1` guards the degenerate minRun=1 case: a single (non-repeated)
64
+ // line is a "run of 1" and must never get a `(×1)` marker — only genuine
65
+ // repeats (2+) collapse.
66
+ if (run > 1 && run >= minRun)
64
67
  out.push(` … (×${run})`);
65
68
  else
66
69
  for (let k = 1; k < run; k++)
@@ -23,10 +23,15 @@ const DEFAULT_MAX_FILES = 12;
23
23
  export function porcelainPath(line) {
24
24
  if (!line || line.length < 4)
25
25
  return null;
26
+ const status = line.slice(0, 2); // XY status code
26
27
  let rest = line.slice(3); // strip the 2 status chars + the separating space
27
- const arrow = rest.indexOf(' -> ');
28
- if (arrow >= 0)
29
- rest = rest.slice(arrow + 4); // rename/copy → destination path
28
+ // Only a rename/copy (status R or C) uses `<old> -> <new>`; for any other
29
+ // status a literal ` -> ` is part of the real filename and must NOT be split.
30
+ if (/[RC]/.test(status)) {
31
+ const arrow = rest.indexOf(' -> ');
32
+ if (arrow >= 0)
33
+ rest = rest.slice(arrow + 4); // destination path
34
+ }
30
35
  rest = rest.trim();
31
36
  if (rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2) {
32
37
  rest = rest.slice(1, -1); // unwrap git's quoted path (escapes left as-is)
@@ -31,7 +31,7 @@ export declare function lintSettingsHooks(file: string, obj: unknown): Finding[]
31
31
  /** .mcp.json: server entry SHAPES only (mcp doctor probes the runtime). */
32
32
  export declare function lintMcpConfig(file: string, obj: unknown): Finding[];
33
33
  /** Pre-1.4 layout leftovers: flat commands / unprefixed skill duplicates. */
34
- export declare function lintLegacyLayout(commandsRoot: string[], skills: string[]): Finding[];
34
+ export declare function lintLegacyLayout(commandsRoot: string[], skills: string[], sdoCommands?: string[]): Finding[];
35
35
  export interface LintInput {
36
36
  swarmdoConfig?: {
37
37
  file: string;
@@ -46,6 +46,8 @@ export interface LintInput {
46
46
  raw: string | null;
47
47
  };
48
48
  commandsRoot?: string[];
49
+ /** entries under `.claude/commands/sDo/` — used to identify flat pre-1.4 twins */
50
+ sdoCommands?: string[];
49
51
  skills?: string[];
50
52
  }
51
53
  export interface LintReport {
@@ -158,11 +158,18 @@ export function lintMcpConfig(file, obj) {
158
158
  return out;
159
159
  }
160
160
  /** Pre-1.4 layout leftovers: flat commands / unprefixed skill duplicates. */
161
- export function lintLegacyLayout(commandsRoot, skills) {
161
+ export function lintLegacyLayout(commandsRoot, skills, sdoCommands = []) {
162
162
  const out = [];
163
- const flat = commandsRoot.filter((n) => n !== 'sDo');
163
+ // A flat command is a pre-1.4 swarmdo leftover ONLY if the same command also
164
+ // exists under sDo/ today (a twin) — mirroring the skill twin-check below.
165
+ // `.claude/commands/` is Claude Code's shared, ecosystem-wide slash-command
166
+ // dir, so a user's own command namespace or another plugin's coexisting with
167
+ // sDo/ must NOT be flagged merely for not being `sDo` (that was #66).
168
+ const base = (n) => n.replace(/\.md$/, '');
169
+ const sdoBase = new Set(sdoCommands.map(base));
170
+ const flat = commandsRoot.filter((n) => n !== 'sDo' && sdoBase.has(base(n)));
164
171
  if (flat.length > 0) {
165
- out.push(f('.claude/commands', 'warn', 'pre-1.4-commands', `${flat.length} entr${flat.length === 1 ? 'y' : 'ies'} outside the sDo/ namespace (${flat.slice(0, 5).join(', ')}${flat.length > 5 ? ', …' : ''}) — swarmdo commands moved to /sDo:* in v1.4.0; re-run \`swarmdo init --force\` to migrate swarmdo-owned ones`));
172
+ out.push(f('.claude/commands', 'warn', 'pre-1.4-commands', `${flat.length} swarmdo command${flat.length === 1 ? '' : 's'} left flat outside the sDo/ namespace (${flat.slice(0, 5).join(', ')}${flat.length > 5 ? ', …' : ''}) — swarmdo commands moved to /sDo:* in v1.4.0; re-run \`swarmdo init --force\` to migrate them`));
166
173
  }
167
174
  const skillSet = new Set(skills);
168
175
  const dupes = skills.filter((s) => !s.startsWith('sdo-') && skillSet.has(`sdo-${s}`));
@@ -186,7 +193,7 @@ export function lintAll(input) {
186
193
  const { obj, findings: jf } = lintJson(input.mcpConfig.file, input.mcpConfig.raw);
187
194
  findings.push(...jf, ...lintMcpConfig(input.mcpConfig.file, obj));
188
195
  }
189
- findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? []));
196
+ findings.push(...lintLegacyLayout(input.commandsRoot ?? [], input.skills ?? [], input.sdoCommands ?? []));
190
197
  return {
191
198
  findings,
192
199
  errors: findings.filter((x) => x.severity === 'error').length,