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