swarmdo 1.30.0 → 1.45.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 (88) hide show
  1. package/README.md +11 -6
  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 +24 -8
  6. package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
  7. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
  8. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
  9. package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
  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 +7 -1
  14. package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
  15. package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
  16. package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
  17. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
  18. package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
  19. package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
  20. package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
  21. package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
  22. package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
  23. package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
  24. package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
  25. package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
  26. package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
  27. package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
  28. package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
  29. package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
  30. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
  31. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
  32. package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
  33. package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
  34. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
  35. package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
  36. package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
  37. package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
  38. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
  39. package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
  40. package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
  41. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
  42. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
  43. package/v3/@swarmdo/cli/dist/src/index.js +8 -0
  44. package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
  45. package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
  46. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
  47. package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
  48. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
  49. package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
  50. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
  51. package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
  52. package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
  53. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
  54. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
  55. package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
  56. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
  57. package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
  58. package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
  59. package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
  60. package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
  61. package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
  62. package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
  63. package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
  64. package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
  65. package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
  66. package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
  67. package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
  68. package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
  69. package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
  70. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
  71. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
  72. package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
  73. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
  74. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
  75. package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
  76. package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
  77. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
  78. package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
  79. package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
  80. package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
  81. package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
  82. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
  83. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
  84. package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
  85. package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
  86. package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
  87. package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
  88. package/v3/@swarmdo/cli/package.json +1 -1
@@ -759,6 +759,60 @@ const graphCommand = {
759
759
  return { success: true };
760
760
  }
761
761
  };
762
+ // Doctor subcommand — classify blocked tasks live vs dead + a global deadlock
763
+ // verdict (task-deps.ts::taskDagHealth). Airflow's upstream_failed + DAG
764
+ // deadlock detection: surfaces a task that can NEVER run because a prerequisite
765
+ // failed/was cancelled/is missing, and a whole-graph stall a dispatch loop would
766
+ // otherwise spin on forever.
767
+ const doctorCommand = {
768
+ name: 'doctor',
769
+ aliases: ['health'],
770
+ description: 'Diagnose the task DAG: which blocked tasks can still run (live) vs are permanently stuck because a dependency failed/was cancelled/is missing (dead), and whether the whole graph is deadlocked',
771
+ options: [
772
+ { name: 'json', description: 'Machine-readable output', type: 'boolean', default: false },
773
+ { name: 'ci', description: 'Exit 1 if the DAG is deadlocked or any task is permanently (dead) blocked — gate a dispatch loop', type: 'boolean', default: false }
774
+ ],
775
+ examples: [
776
+ { command: 'swarmdo task doctor', description: 'Show live-blocked vs dead-blocked tasks and any deadlock' },
777
+ { command: 'swarmdo task doctor --ci', description: 'Fail (exit 1) when the task graph is stuck' }
778
+ ],
779
+ action: async (ctx) => {
780
+ const { loadTaskStore } = await import('../mcp-tools/task-tools.js');
781
+ const { taskDagHealth } = await import('../mcp-tools/task-deps.js');
782
+ const store = loadTaskStore(ctx.cwd);
783
+ const health = taskDagHealth(store);
784
+ const stuck = health.deadlocked || health.deadBlocked.length > 0;
785
+ const code = ctx.flags.ci === true && stuck ? 1 : 0;
786
+ if (ctx.flags.json === true) {
787
+ output.printJson({
788
+ deadlocked: health.deadlocked,
789
+ liveBlocked: health.liveBlocked.map(t => t.taskId),
790
+ deadBlocked: health.deadBlocked.map(d => ({ taskId: d.task.taskId, rootCause: d.rootCause })),
791
+ });
792
+ return { success: code === 0, exitCode: code, data: health };
793
+ }
794
+ output.writeln();
795
+ if (health.deadlocked) {
796
+ output.writeln(output.error('⚠ DEADLOCKED') + output.dim(' — pending work exists but nothing is ready and nothing is running'));
797
+ output.writeln();
798
+ }
799
+ output.writeln(output.bold(`Live-blocked (${health.liveBlocked.length})`) + output.dim(' — waiting on deps that can still complete'));
800
+ for (const t of health.liveBlocked) {
801
+ output.writeln(` ${output.dim('○')} ${t.taskId} ${t.description.slice(0, 60)}`);
802
+ }
803
+ output.writeln();
804
+ output.writeln(output.bold(`Dead-blocked (${health.deadBlocked.length})`) + output.dim(' — a prerequisite failed/was cancelled/is missing; will NEVER run without intervention'));
805
+ for (const d of health.deadBlocked) {
806
+ output.writeln(` ${output.error('✗')} ${d.task.taskId} ⇐ dead: ${d.rootCause.join(', ') || '(cycle)'}`);
807
+ }
808
+ if (!stuck) {
809
+ output.writeln();
810
+ output.printSuccess('task graph healthy — no permanent blocks or deadlock');
811
+ }
812
+ output.writeln();
813
+ return { success: code === 0, exitCode: code, data: health };
814
+ }
815
+ };
762
816
  // parse-prd subcommand — decompose a PRD/spec doc into the task DAG (Task Master
763
817
  // parity). The LLM decomposition lives in ../task/parse-prd.ts (injectable +
764
818
  // unit-tested); this layer gates the billable call and creates the linked tasks.
@@ -890,7 +944,7 @@ const parsePrdCommand = {
890
944
  export const taskCommand = {
891
945
  name: 'task',
892
946
  description: 'Task management commands',
893
- subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand],
947
+ subcommands: [createCommand, parsePrdCommand, listCommand, statusCommand, cancelCommand, assignCommand, retryCommand, dispatchCommand, readyCommand, graphCommand, doctorCommand],
894
948
  options: [],
895
949
  examples: [
896
950
  { command: 'swarmdo task create -t implementation -d "Add user auth"', description: 'Create a task' },
@@ -917,7 +971,8 @@ export const taskCommand = {
917
971
  `${output.highlight('assign')} - Assign task to agent(s)`,
918
972
  `${output.highlight('retry')} - Retry a failed task`,
919
973
  `${output.highlight('ready')} - List ready vs dependency-blocked tasks`,
920
- `${output.highlight('graph')} - Show the dependency graph`
974
+ `${output.highlight('graph')} - Show the dependency graph`,
975
+ `${output.highlight('doctor')} - Diagnose live vs dead blocks + deadlock (--ci gates)`
921
976
  ]);
922
977
  output.writeln();
923
978
  output.writeln('Run "swarmdo task <subcommand> --help" for subcommand help');
@@ -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)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * agents-lint.ts — static validation of Claude Code subagent definitions
3
+ * (`.claude/agents/*.md`).
4
+ *
5
+ * Each subagent file is YAML frontmatter (`name` + `description` required;
6
+ * `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
7
+ * ignores a subagent whose frontmatter is malformed, whose `name` collides with
8
+ * another file, or whose `model` is invalid — so "why isn't my subagent
9
+ * loading?" is an undebuggable footgun, and users mass-copy these files from
10
+ * large community collections where typos ride along. This catches those
11
+ * before CC does.
12
+ *
13
+ * Pure: parsed text in, findings out — same `Finding` shape + report machinery
14
+ * as the rest of config-lint; the command layer only reads files.
15
+ */
16
+ import type { Finding } from './lint.js';
17
+ /** Models a Claude Code subagent `model:` field may name. */
18
+ export declare const AGENT_MODELS: string[];
19
+ /** Lint ONE subagent file's raw text. Pure. */
20
+ export declare function lintAgentFile(file: string, raw: string): Finding[];
21
+ export interface AgentFile {
22
+ file: string;
23
+ raw: string;
24
+ }
25
+ /**
26
+ * Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
27
+ * `name` detection (Claude Code loads only one of a colliding pair). Pure.
28
+ */
29
+ export declare function lintAgents(files: AgentFile[]): Finding[];
30
+ //# sourceMappingURL=agents-lint.d.ts.map
@@ -0,0 +1,87 @@
1
+ /**
2
+ * agents-lint.ts — static validation of Claude Code subagent definitions
3
+ * (`.claude/agents/*.md`).
4
+ *
5
+ * Each subagent file is YAML frontmatter (`name` + `description` required;
6
+ * `tools`, `model` optional) plus a system-prompt body. Claude Code SILENTLY
7
+ * ignores a subagent whose frontmatter is malformed, whose `name` collides with
8
+ * another file, or whose `model` is invalid — so "why isn't my subagent
9
+ * loading?" is an undebuggable footgun, and users mass-copy these files from
10
+ * large community collections where typos ride along. This catches those
11
+ * before CC does.
12
+ *
13
+ * Pure: parsed text in, findings out — same `Finding` shape + report machinery
14
+ * as the rest of config-lint; the command layer only reads files.
15
+ */
16
+ import { parse as parseYaml } from 'yaml';
17
+ const f = (file, severity, rule, message) => ({ file, severity, rule, message });
18
+ /** Models a Claude Code subagent `model:` field may name. */
19
+ export const AGENT_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'];
20
+ // Leading (optional BOM) `---` fence, YAML body (non-greedy) up to a closing
21
+ // `---` on its own line, then the optional system-prompt body. CRLF-tolerant.
22
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
23
+ function parseAgent(raw) {
24
+ const m = raw.replace(/^\uFEFF/, "").match(FRONTMATTER_RE);
25
+ if (!m)
26
+ return { meta: null, body: '', malformed: 'no YAML frontmatter block (a subagent file must start with `---` … `---`)' };
27
+ const body = m[2] ?? '';
28
+ let fm;
29
+ try {
30
+ fm = parseYaml(m[1]);
31
+ }
32
+ catch (e) {
33
+ return { meta: null, body, malformed: `frontmatter is not valid YAML: ${e.message}` };
34
+ }
35
+ if (fm === null || typeof fm !== 'object' || Array.isArray(fm)) {
36
+ return { meta: null, body, malformed: 'frontmatter must be a YAML mapping of key: value' };
37
+ }
38
+ return { meta: fm, body };
39
+ }
40
+ function lintParsed(file, p) {
41
+ if (p.malformed)
42
+ return [f(file, 'error', 'agent-malformed-frontmatter', p.malformed)];
43
+ const out = [];
44
+ const meta = p.meta;
45
+ if (typeof meta.name !== 'string' || meta.name.trim() === '') {
46
+ out.push(f(file, 'error', 'agent-missing-name', 'frontmatter is missing a non-empty `name`'));
47
+ }
48
+ if (typeof meta.description !== 'string' || meta.description.trim() === '') {
49
+ out.push(f(file, 'error', 'agent-missing-description', 'frontmatter is missing a non-empty `description` (Claude Code routes to a subagent by its description)'));
50
+ }
51
+ if (meta.model !== undefined && !AGENT_MODELS.includes(String(meta.model))) {
52
+ out.push(f(file, 'error', 'agent-bad-model', `model "${String(meta.model)}" is not one of: ${AGENT_MODELS.join(', ')}`));
53
+ }
54
+ if (p.body.trim() === '') {
55
+ out.push(f(file, 'warn', 'agent-empty-body', 'no system prompt below the frontmatter — the subagent has no instructions'));
56
+ }
57
+ return out;
58
+ }
59
+ /** Lint ONE subagent file's raw text. Pure. */
60
+ export function lintAgentFile(file, raw) {
61
+ return lintParsed(file, parseAgent(raw));
62
+ }
63
+ /**
64
+ * Lint a set of subagent files: every per-file rule PLUS cross-file duplicate
65
+ * `name` detection (Claude Code loads only one of a colliding pair). Pure.
66
+ */
67
+ export function lintAgents(files) {
68
+ const out = [];
69
+ const nameToFiles = new Map();
70
+ for (const { file, raw } of files) {
71
+ const p = parseAgent(raw);
72
+ out.push(...lintParsed(file, p));
73
+ const name = p.meta && typeof p.meta.name === 'string' ? p.meta.name.trim() : '';
74
+ if (name) {
75
+ const arr = nameToFiles.get(name) ?? [];
76
+ arr.push(file);
77
+ nameToFiles.set(name, arr);
78
+ }
79
+ }
80
+ for (const [name, group] of nameToFiles) {
81
+ if (group.length > 1) {
82
+ out.push(f(group[0], 'error', 'agent-duplicate-name', `subagent name "${name}" is declared by ${group.length} files (${group.join(', ')}) — Claude Code loads only one; rename the rest`));
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ //# sourceMappingURL=agents-lint.js.map
@@ -17,6 +17,7 @@ export interface Finding {
17
17
  }
18
18
  export declare const TOPOLOGIES: string[];
19
19
  export declare const MEMORY_BACKENDS: string[];
20
+ import { type AgentFile } from './agents-lint.js';
20
21
  export declare const KNOWN_CONFIG_KEYS: string[];
21
22
  export declare const HOOK_EVENTS: string[];
22
23
  /** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
@@ -31,7 +32,7 @@ export declare function lintSettingsHooks(file: string, obj: unknown): Finding[]
31
32
  /** .mcp.json: server entry SHAPES only (mcp doctor probes the runtime). */
32
33
  export declare function lintMcpConfig(file: string, obj: unknown): Finding[];
33
34
  /** Pre-1.4 layout leftovers: flat commands / unprefixed skill duplicates. */
34
- export declare function lintLegacyLayout(commandsRoot: string[], skills: string[]): Finding[];
35
+ export declare function lintLegacyLayout(commandsRoot: string[], skills: string[], sdoCommands?: string[]): Finding[];
35
36
  export interface LintInput {
36
37
  swarmdoConfig?: {
37
38
  file: string;
@@ -46,7 +47,11 @@ export interface LintInput {
46
47
  raw: string | null;
47
48
  };
48
49
  commandsRoot?: string[];
50
+ /** entries under `.claude/commands/sDo/` — used to identify flat pre-1.4 twins */
51
+ sdoCommands?: string[];
49
52
  skills?: string[];
53
+ /** `.claude/agents/*.md` subagent definitions (raw text per file) */
54
+ agentFiles?: AgentFile[];
50
55
  }
51
56
  export interface LintReport {
52
57
  findings: Finding[];