swarmdo 1.30.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 (65) hide show
  1. package/README.md +6 -3
  2. package/package.json +1 -1
  3. package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
  4. package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
  5. package/v3/@swarmdo/cli/dist/src/apply/apply.js +18 -7
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +4 -1
  7. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  8. package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
  9. package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
  10. package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
  11. package/v3/@swarmdo/cli/dist/src/commands/config.js +1 -0
  12. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +121 -1
  13. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +8 -2
  14. package/v3/@swarmdo/cli/dist/src/commands/index.js +6 -0
  15. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  17. package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
  18. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  19. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  20. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  21. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  22. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  23. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  24. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +3 -1
  25. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +11 -4
  26. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  27. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +20 -1
  28. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  29. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  30. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  31. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  32. package/v3/@swarmdo/cli/dist/src/mcp-client.js +2 -0
  33. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  34. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  35. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +1 -0
  36. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +1 -0
  37. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  38. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  39. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  40. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  41. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  42. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  43. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  44. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  45. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  46. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  47. package/v3/@swarmdo/cli/dist/src/redact/redact.js +7 -2
  48. package/v3/@swarmdo/cli/dist/src/release/release.js +1 -1
  49. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  50. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  51. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  52. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  53. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  54. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  55. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  56. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  57. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  58. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
  59. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +26 -0
  60. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +44 -0
  61. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  62. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  63. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  64. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  65. package/v3/@swarmdo/cli/package.json +1 -1
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { output } from '../output.js';
16
- import { parseGitLog, computeHotspots, formatHotspots } from '../hotspots/hotspots.js';
16
+ import { parseGitLog, computeHotspots, formatHotspots, hotspotsToCsv } from '../hotspots/hotspots.js';
17
17
  const SORT_KEYS = ['risk', 'churn', 'commits', 'authors'];
18
18
  /** Read a numeric flag that the parser may deliver as a number OR a string. */
19
19
  function numFlag(v, def) {
@@ -33,6 +33,7 @@ async function run(ctx) {
33
33
  }
34
34
  // Global --format (text|json|table); text and table both render the table.
35
35
  const asJson = ctx.flags.format === 'json';
36
+ const asCsv = ctx.flags.csv === true; // dedicated flag (global --format has no csv choice)
36
37
  // Capture history: SOH-delimited header + numstat, no merges. `%aN` (not
37
38
  // `%an`) resolves author names through `.mailmap`, so name/email variants of
38
39
  // the same person fold into one identity — otherwise the author-spread factor
@@ -50,7 +51,10 @@ async function run(ctx) {
50
51
  }
51
52
  const now = Date.now();
52
53
  const spots = computeHotspots(parseGitLog(raw), now, { by, top: top > 0 ? top : undefined, minCommits });
53
- if (asJson) {
54
+ if (asCsv) {
55
+ process.stdout.write(hotspotsToCsv(spots) + '\n');
56
+ }
57
+ else if (asJson) {
54
58
  process.stdout.write(JSON.stringify({ generated: new Date(now).toISOString(), by, count: spots.length, hotspots: spots }, null, 2) + '\n');
55
59
  }
56
60
  else {
@@ -72,10 +76,12 @@ export const hotspotsCommand = {
72
76
  { name: 'top', description: 'keep only the top N files (default 20; 0 = all)', type: 'string' },
73
77
  { name: 'min-commits', description: 'drop files with fewer than N commits (default 1)', type: 'string' },
74
78
  { name: 'by', description: `sort key: ${SORT_KEYS.join('|')} (default risk)`, type: 'string' },
79
+ { name: 'csv', description: 'export the ranking as CSV (for spreadsheets)', type: 'boolean' },
75
80
  ],
76
81
  examples: [
77
82
  { command: 'swarmdo hotspots src --since 90d', description: 'Risk hotspots under src/ in the last 90 days' },
78
83
  { command: 'swarmdo hotspots --by churn --top 10 --format json', description: 'Top-10 by churn as JSON' },
84
+ { command: 'swarmdo hotspots --csv > hotspots.csv', description: 'Export the risk ranking to CSV' },
79
85
  ],
80
86
  action: run,
81
87
  };
@@ -100,6 +100,12 @@ const commandLoaders = {
100
100
  // Isolated git worktrees for parallel work (claude-squad/vibe-kanban demand)
101
101
  worktree: () => import('./worktree.js'),
102
102
  wt: () => import('./worktree.js'), // alias via loader key
103
+ // Cross-session agent mailbox (multiplayer swarms — dorsa_rohani demand)
104
+ comms: () => import('./comms.js'),
105
+ mailbox: () => import('./comms.js'), // alias via loader key
106
+ // Static audit of Claude Code permission rules (conflicts / dead / over-broad)
107
+ permissions: () => import('./permissions.js'),
108
+ perms: () => import('./permissions.js'), // alias via loader key
103
109
  // Export Claude Code sessions to Markdown (conversation-exporter demand)
104
110
  transcript: () => import('./transcript.js'),
105
111
  tx: () => import('./transcript.js'), // alias via loader key
@@ -0,0 +1,14 @@
1
+ /**
2
+ * permissions.ts — `swarmdo permissions` (alias `perms`) — audit Claude Code
3
+ * permission rules for conflicts, dead/shadowed rules, and over-broad grants.
4
+ *
5
+ * Complements `config lint` (which validates the hooks block): this reads the
6
+ * `permissions.allow` / `deny` / `ask` arrays from `.claude/settings.json` +
7
+ * `settings.local.json` (+ `~/.claude/settings.json` with `--global`), merges
8
+ * them into the effective ruleset Claude Code enforces, and runs the pure
9
+ * permissions/audit.ts analyzer. Read-only — never edits settings.
10
+ */
11
+ import type { Command } from '../types.js';
12
+ export declare const permissionsCommand: Command;
13
+ export default permissionsCommand;
14
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * permissions.ts — `swarmdo permissions` (alias `perms`) — audit Claude Code
3
+ * permission rules for conflicts, dead/shadowed rules, and over-broad grants.
4
+ *
5
+ * Complements `config lint` (which validates the hooks block): this reads the
6
+ * `permissions.allow` / `deny` / `ask` arrays from `.claude/settings.json` +
7
+ * `settings.local.json` (+ `~/.claude/settings.json` with `--global`), merges
8
+ * them into the effective ruleset Claude Code enforces, and runs the pure
9
+ * permissions/audit.ts analyzer. Read-only — never edits settings.
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { output } from '../output.js';
15
+ import { auditPermissions } from '../permissions/audit.js';
16
+ const strArray = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
17
+ /** Extract permissions.allow/deny/ask from a settings file, or null if none. */
18
+ function readPerms(file) {
19
+ try {
20
+ if (!existsSync(file))
21
+ return null;
22
+ const obj = JSON.parse(readFileSync(file, 'utf8'));
23
+ const perm = obj?.permissions;
24
+ if (!perm || typeof perm !== 'object')
25
+ return null;
26
+ const sets = { allow: strArray(perm.allow), deny: strArray(perm.deny), ask: strArray(perm.ask) };
27
+ return sets.allow.length + sets.deny.length + sets.ask.length > 0 ? sets : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export const permissionsCommand = {
34
+ name: 'permissions',
35
+ aliases: ['perms'],
36
+ description: 'Audit Claude Code permission rules (allow/deny/ask) for conflicts, dead/shadowed rules, and over-broad grants',
37
+ options: [
38
+ { name: 'global', description: 'Also include ~/.claude/settings.json', type: 'boolean' },
39
+ { name: 'strict', description: 'Exit 1 if any error-severity finding (CI gate)', type: 'boolean' },
40
+ { name: 'json', description: 'Machine-readable output', type: 'boolean' },
41
+ ],
42
+ examples: [
43
+ { command: 'swarmdo permissions', description: "Audit this project's permission rules" },
44
+ { command: 'swarmdo permissions --global --strict', description: 'Include user-global settings; fail on conflicts' },
45
+ ],
46
+ action: async (ctx) => {
47
+ const cwd = ctx.cwd || process.cwd();
48
+ const files = [join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')];
49
+ if (ctx.flags.global === true)
50
+ files.push(join(homedir(), '.claude', 'settings.json'));
51
+ const used = [];
52
+ const merged = { allow: [], deny: [], ask: [] };
53
+ for (const file of files) {
54
+ const p = readPerms(file);
55
+ if (!p)
56
+ continue;
57
+ used.push(file);
58
+ merged.allow.push(...(p.allow ?? []));
59
+ merged.deny.push(...(p.deny ?? []));
60
+ merged.ask.push(...(p.ask ?? []));
61
+ }
62
+ const totalRules = merged.allow.length + merged.deny.length + merged.ask.length;
63
+ if (used.length === 0) {
64
+ if (ctx.flags.json === true)
65
+ output.printJson({ files: [], rules: 0, findings: [] });
66
+ else
67
+ output.printInfo('No permission rules found in .claude/settings*.json.');
68
+ return { success: true };
69
+ }
70
+ const findings = auditPermissions(merged);
71
+ const errors = findings.filter((f) => f.severity === 'error').length;
72
+ const warns = findings.filter((f) => f.severity === 'warn').length;
73
+ if (ctx.flags.json === true) {
74
+ output.printJson({ files: used, rules: totalRules, findings });
75
+ }
76
+ else if (findings.length === 0) {
77
+ output.printSuccess(`permissions audit: ${totalRules} rule(s) across ${used.length} file(s) — no issues`);
78
+ }
79
+ else {
80
+ const icon = (s) => (s === 'error' ? '✖' : s === 'warn' ? '⚠' : '·');
81
+ output.writeln(output.bold('Permission audit') + output.dim(` (${totalRules} rules, ${used.length} file${used.length === 1 ? '' : 's'})`));
82
+ for (const x of findings)
83
+ output.writeln(` ${icon(x.severity)} ${output.dim(`[${x.rule}]`)} ${x.message}`);
84
+ output.writeln('');
85
+ output.writeln(`${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'}, ${findings.length - errors - warns} info`);
86
+ }
87
+ const failed = ctx.flags.strict === true && errors > 0;
88
+ return { success: !failed, exitCode: failed ? 1 : 0, data: { rules: totalRules, findings } };
89
+ },
90
+ };
91
+ export default permissionsCommand;
92
+ //# sourceMappingURL=permissions.js.map
@@ -24,8 +24,11 @@ import { forecastWindow, parseRateLimits, worstStatus, formatForecast, formatLim
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { toCsv } from '../util/csv.js';
26
26
  import { projectMonthEnd, daysInMonthOf } from '../usage/spend-forecast.js';
27
+ import { computeModelEfficiency } from '../usage/model-efficiency.js';
28
+ import { detectSpikeDays } from '../usage/reflect.js';
27
29
  const VIEWS = {
28
30
  daily: { dimension: 'day', label: 'Date' },
31
+ weekly: { dimension: 'week', label: 'ISO week' },
29
32
  monthly: { dimension: 'month', label: 'Month' },
30
33
  models: { dimension: 'model', label: 'Model' },
31
34
  projects: { dimension: 'project', label: 'Project' },
@@ -401,6 +404,9 @@ function runReflectView(ctx, collection) {
401
404
  const k = `${e.model}\u0000${e.dateKey}`;
402
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 } };
403
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;
404
410
  row.totals.totalTokens += e.inputTokens + e.outputTokens + e.cacheWriteTokens + e.cacheReadTokens;
405
411
  md.set(k, row);
406
412
  // per-(project, day) fold (space separator is safe — dateKey is fixed-width)
@@ -439,9 +445,15 @@ function runReflectView(ctx, collection) {
439
445
  output.writeln(` Total tokens ${r.totals.totalTokens.toLocaleString()}`);
440
446
  if (r.busiestDay)
441
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
+ }
442
452
  output.writeln(` Longest streak ${r.longestStreak} day${r.longestStreak === 1 ? '' : 's'}`);
443
453
  output.writeln(` Avg / active day ${fmtCost(r.avgCostPerActiveDay)}`);
444
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')}`);
445
457
  if (delegation.toolCalls > 0) {
446
458
  output.writeln(` Delegation ${pctStr(delegation.ratio)} ${output.dim(`(${delegation.taskCalls} of ${delegation.toolCalls} tool calls to subagents)`)}`);
447
459
  }
@@ -597,6 +609,16 @@ async function run(ctx) {
597
609
  }
598
610
  output.writeln(output.bold(`Claude Code usage — ${viewName}`) + (since || until ? output.dim(` (${since ?? '…'} → ${until ?? '…'})`) : ''));
599
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
+ }
600
622
  if (viewName === 'monthly') {
601
623
  // Month-end pace projection for the current calendar month.
602
624
  const today = localDateKey(new Date());
@@ -609,6 +631,16 @@ async function run(ctx) {
609
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)`));
610
632
  }
611
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
+ }
612
644
  output.writeln(output.dim(`${collection.filesScanned} transcript files · ${grand.entries} billed responses · sources: ${collection.dirsScanned.join(', ')}`));
613
645
  if (collection.unpricedModels.length > 0) {
614
646
  const { unpricedResponses, unpricedTokens } = unpricedStats(collection.events);
@@ -619,7 +651,7 @@ async function run(ctx) {
619
651
  export const usageCommand = {
620
652
  name: 'usage',
621
653
  aliases: ['cost'],
622
- 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)',
623
655
  options: [
624
656
  { name: 'since', description: 'inclusive start date (YYYY-MM-DD or YYYYMMDD)', type: 'string' },
625
657
  { name: 'until', description: 'inclusive end date (YYYY-MM-DD or YYYYMMDD)', type: 'string' },
@@ -630,6 +662,7 @@ export const usageCommand = {
630
662
  ],
631
663
  examples: [
632
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' },
633
666
  { command: 'swarmdo usage models --since 2026-07-01', description: 'Spend per model this month' },
634
667
  { command: 'swarmdo usage projects --json', description: 'Per-project totals as JSON' },
635
668
  { command: 'swarmdo usage monthly --csv > usage.csv', description: 'Export monthly spend to CSV' },
@@ -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,