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.
- package/README.md +11 -6
- package/package.json +1 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/affected/affected.js +7 -2
- package/v3/@swarmdo/cli/dist/src/apply/apply.js +24 -8
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +47 -1
- package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +11 -1
- package/v3/@swarmdo/cli/dist/src/commands/changelog.js +15 -4
- package/v3/@swarmdo/cli/dist/src/commands/comms.d.ts +16 -0
- package/v3/@swarmdo/cli/dist/src/commands/comms.js +280 -0
- package/v3/@swarmdo/cli/dist/src/commands/compact.js +6 -2
- package/v3/@swarmdo/cli/dist/src/commands/config.js +7 -1
- package/v3/@swarmdo/cli/dist/src/commands/coupling.d.ts +17 -0
- package/v3/@swarmdo/cli/dist/src/commands/coupling.js +85 -0
- package/v3/@swarmdo/cli/dist/src/commands/hooks.js +176 -1
- package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +10 -3
- package/v3/@swarmdo/cli/dist/src/commands/index.js +12 -3
- package/v3/@swarmdo/cli/dist/src/commands/permissions.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/commands/permissions.js +92 -0
- package/v3/@swarmdo/cli/dist/src/commands/redact.js +11 -0
- package/v3/@swarmdo/cli/dist/src/commands/release.js +27 -5
- package/v3/@swarmdo/cli/dist/src/commands/task.js +57 -2
- package/v3/@swarmdo/cli/dist/src/commands/usage.js +34 -1
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.d.ts +88 -0
- package/v3/@swarmdo/cli/dist/src/comms/mailbox.js +138 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.d.ts +21 -0
- package/v3/@swarmdo/cli/dist/src/comms/store.js +44 -0
- package/v3/@swarmdo/cli/dist/src/compact/compact.js +4 -1
- package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +8 -3
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.d.ts +30 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/agents-lint.js +87 -0
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.d.ts +6 -1
- package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +14 -4
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.d.ts +56 -0
- package/v3/@swarmdo/cli/dist/src/coupling/coupling.js +86 -0
- package/v3/@swarmdo/cli/dist/src/env/env.js +40 -14
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.d.ts +46 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/command-guard.js +120 -0
- package/v3/@swarmdo/cli/dist/src/hooks-recipe/recipe.js +28 -1
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +3 -0
- package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +9 -0
- package/v3/@swarmdo/cli/dist/src/index.js +8 -0
- package/v3/@swarmdo/cli/dist/src/license/license.js +4 -1
- package/v3/@swarmdo/cli/dist/src/mcp-client.js +4 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.d.ts +11 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/comms-tools.js +93 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.d.ts +14 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/coupling-tools.js +56 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/index.js +2 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/profiles.js +1 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.d.ts +35 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-deps.js +87 -0
- package/v3/@swarmdo/cli/dist/src/mcp-tools/task-tools.js +4 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/memory/memory-bridge.js +4 -2
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.d.ts +82 -0
- package/v3/@swarmdo/cli/dist/src/memory-inject/select.js +164 -0
- package/v3/@swarmdo/cli/dist/src/memory-vault/import.js +11 -1
- package/v3/@swarmdo/cli/dist/src/pack/pack.js +6 -2
- package/v3/@swarmdo/cli/dist/src/permissions/audit.d.ts +50 -0
- package/v3/@swarmdo/cli/dist/src/permissions/audit.js +127 -0
- package/v3/@swarmdo/cli/dist/src/plugins/store/search.js +1 -1
- package/v3/@swarmdo/cli/dist/src/redact/redact.js +22 -5
- package/v3/@swarmdo/cli/dist/src/redact/sarif.d.ts +28 -0
- package/v3/@swarmdo/cli/dist/src/redact/sarif.js +53 -0
- package/v3/@swarmdo/cli/dist/src/release/release.d.ts +19 -0
- package/v3/@swarmdo/cli/dist/src/release/release.js +29 -1
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +2 -0
- package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +5 -0
- package/v3/@swarmdo/cli/dist/src/swarmvector/semantic-router.js +8 -3
- package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +9 -2
- package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -4
- package/v3/@swarmdo/cli/dist/src/update/checker.d.ts +1 -0
- package/v3/@swarmdo/cli/dist/src/update/checker.js +7 -2
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.d.ts +29 -0
- package/v3/@swarmdo/cli/dist/src/usage/model-efficiency.js +28 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect-html.js +1 -0
- package/v3/@swarmdo/cli/dist/src/usage/reflect.d.ts +35 -1
- package/v3/@swarmdo/cli/dist/src/usage/reflect.js +58 -4
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.d.ts +8 -1
- package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +21 -1
- package/v3/@swarmdo/cli/dist/src/util/since.d.ts +13 -0
- package/v3/@swarmdo/cli/dist/src/util/since.js +21 -0
- package/v3/@swarmdo/cli/dist/src/util/stdout.d.ts +23 -1
- package/v3/@swarmdo/cli/dist/src/util/stdout.js +34 -2
- 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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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[];
|