opencode-swarm-plugin 0.48.1 → 0.50.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 CHANGED
@@ -505,15 +505,18 @@ See **[evals/README.md](./evals/README.md)** for full architecture, scorer detai
505
505
  ### Setup & Configuration
506
506
 
507
507
  ```bash
508
- swarm setup # Interactive installer for all dependencies
509
- swarm doctor # Check dependency health (CASS, UBS, Ollama)
510
- swarm init # Initialize hive in current project
511
- swarm config # Show config file paths
512
- swarm update # Update swarm plugin and bundled skills
513
- swarm migrate # Migrate from legacy PGLite to libSQL
514
- swarm version # Show version info
508
+ swarm setup # Interactive installer for all dependencies
509
+ swarm setup -y # Non-interactive mode (auto-migrate stray databases)
510
+ swarm doctor # Check dependency health (CASS, UBS, Ollama)
511
+ swarm init # Initialize hive in current project
512
+ swarm config # Show config file paths
513
+ swarm update # Update swarm plugin and bundled skills
514
+ swarm migrate # Migrate from legacy PGLite to libSQL
515
+ swarm version # Show version info
515
516
  ```
516
517
 
518
+ **Database Consolidation**: `swarm setup` automatically detects and migrates stray databases (`.opencode/swarm.db`, `.hive/swarm-mail.db`, nested package databases) to the global database at `~/.config/swarm-tools/swarm.db`. Use `-y` flag to migrate without prompting.
519
+
517
520
  ### Observability Commands
518
521
 
519
522
  **swarm query** - SQL analytics with presets
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Test: swarm setup database consolidation
4
+ *
5
+ * Tests that the swarm setup command can detect and migrate stray databases
6
+ * when the -y flag is provided.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { execSync } from "child_process";
14
+
15
+ describe("swarm setup -y database consolidation", () => {
16
+ let testDir: string;
17
+ let projectPath: string;
18
+
19
+ beforeEach(() => {
20
+ // Create a temporary project directory
21
+ testDir = join(tmpdir(), `swarm-setup-test-${Date.now()}`);
22
+ projectPath = join(testDir, "test-project");
23
+ mkdirSync(projectPath, { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ // Clean up
28
+ if (existsSync(testDir)) {
29
+ rmSync(testDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("consolidateDatabases can be imported from swarm-mail", async () => {
34
+ // Verify the import works
35
+ const { consolidateDatabases } = await import("swarm-mail");
36
+ expect(typeof consolidateDatabases).toBe("function");
37
+ });
38
+
39
+ test("getGlobalDbPath can be imported from swarm-mail", async () => {
40
+ // Verify the import works
41
+ const { getGlobalDbPath } = await import("swarm-mail");
42
+ expect(typeof getGlobalDbPath).toBe("function");
43
+
44
+ // Verify it returns a path
45
+ const path = getGlobalDbPath();
46
+ expect(typeof path).toBe("string");
47
+ expect(path.length).toBeGreaterThan(0);
48
+ });
49
+
50
+ test("consolidateDatabases respects yes flag", async () => {
51
+ const { consolidateDatabases, getGlobalDbPath } = await import("swarm-mail");
52
+ const globalDbPath = getGlobalDbPath();
53
+
54
+ // Call with yes: true (non-interactive mode)
55
+ const report = await consolidateDatabases(projectPath, globalDbPath, {
56
+ yes: true,
57
+ interactive: false,
58
+ });
59
+
60
+ // Should return a report object with correct structure
61
+ expect(report).toBeDefined();
62
+ expect(typeof report.straysFound).toBe("number");
63
+ expect(typeof report.straysMigrated).toBe("number");
64
+ expect(typeof report.totalRowsMigrated).toBe("number");
65
+ expect(Array.isArray(report.migrations)).toBe(true);
66
+ expect(Array.isArray(report.errors)).toBe(true);
67
+ });
68
+
69
+ test("consolidateDatabases can run in non-interactive mode", async () => {
70
+ const { consolidateDatabases, getGlobalDbPath } = await import("swarm-mail");
71
+ const globalDbPath = getGlobalDbPath();
72
+
73
+ // Call with interactive: false (non-interactive mode, same as -y)
74
+ const report = await consolidateDatabases(projectPath, globalDbPath, {
75
+ yes: false,
76
+ interactive: false,
77
+ });
78
+
79
+ // Should return a report object without prompting
80
+ expect(report).toBeDefined();
81
+ expect(typeof report.straysFound).toBe("number");
82
+ expect(Array.isArray(report.migrations)).toBe(true);
83
+ });
84
+ });
package/bin/swarm.test.ts CHANGED
@@ -2132,3 +2132,173 @@ describe("swarm export", () => {
2132
2132
  });
2133
2133
  });
2134
2134
 
2135
+ // ============================================================================
2136
+ // DB Repair Command Tests (TDD)
2137
+ // ============================================================================
2138
+
2139
+ describe("swarm db repair", () => {
2140
+ let testDir: string;
2141
+
2142
+ beforeEach(() => {
2143
+ testDir = join(tmpdir(), `swarm-db-repair-test-${Date.now()}`);
2144
+ mkdirSync(testDir, { recursive: true });
2145
+ });
2146
+
2147
+ afterEach(() => {
2148
+ if (existsSync(testDir)) {
2149
+ rmSync(testDir, { recursive: true, force: true });
2150
+ }
2151
+ });
2152
+
2153
+ describe("parseRepairArgs", () => {
2154
+ function parseRepairArgs(args: string[]): { dryRun: boolean } {
2155
+ let dryRun = false;
2156
+
2157
+ for (const arg of args) {
2158
+ if (arg === "--dry-run") {
2159
+ dryRun = true;
2160
+ }
2161
+ }
2162
+
2163
+ return { dryRun };
2164
+ }
2165
+
2166
+ test("parses --dry-run flag", () => {
2167
+ const result = parseRepairArgs(["--dry-run"]);
2168
+ expect(result.dryRun).toBe(true);
2169
+ });
2170
+
2171
+ test("defaults to dryRun=false", () => {
2172
+ const result = parseRepairArgs([]);
2173
+ expect(result.dryRun).toBe(false);
2174
+ });
2175
+ });
2176
+
2177
+ describe("executeRepair", () => {
2178
+ /**
2179
+ * Execute repair operation
2180
+ * @param dryRun - If true, only count what would be deleted
2181
+ * @returns Summary of changes
2182
+ */
2183
+ async function executeRepair(dryRun: boolean): Promise<{
2184
+ nullBeads: number;
2185
+ orphanedRecipients: number;
2186
+ messagesWithoutRecipients: number;
2187
+ expiredReservations: number;
2188
+ }> {
2189
+ // Mock implementation for testing
2190
+ // Real implementation will use getSwarmMailLibSQL()
2191
+ return {
2192
+ nullBeads: dryRun ? 427 : 0,
2193
+ orphanedRecipients: dryRun ? 208 : 0,
2194
+ messagesWithoutRecipients: dryRun ? 72 : 0,
2195
+ expiredReservations: dryRun ? 213 : 0,
2196
+ };
2197
+ }
2198
+
2199
+ test("dry run returns counts without deleting", async () => {
2200
+ const result = await executeRepair(true);
2201
+
2202
+ expect(result.nullBeads).toBeGreaterThanOrEqual(0);
2203
+ expect(result.orphanedRecipients).toBeGreaterThanOrEqual(0);
2204
+ expect(result.messagesWithoutRecipients).toBeGreaterThanOrEqual(0);
2205
+ expect(result.expiredReservations).toBeGreaterThanOrEqual(0);
2206
+ });
2207
+
2208
+ test("actual repair returns zero after cleanup", async () => {
2209
+ const result = await executeRepair(false);
2210
+
2211
+ // After cleanup, all counts should be zero (mock behavior)
2212
+ expect(result.nullBeads).toBe(0);
2213
+ expect(result.orphanedRecipients).toBe(0);
2214
+ expect(result.messagesWithoutRecipients).toBe(0);
2215
+ expect(result.expiredReservations).toBe(0);
2216
+ });
2217
+ });
2218
+
2219
+ describe("formatRepairSummary", () => {
2220
+ function formatRepairSummary(
2221
+ counts: {
2222
+ nullBeads: number;
2223
+ orphanedRecipients: number;
2224
+ messagesWithoutRecipients: number;
2225
+ expiredReservations: number;
2226
+ },
2227
+ dryRun: boolean,
2228
+ ): string {
2229
+ const lines: string[] = [];
2230
+
2231
+ if (dryRun) {
2232
+ lines.push("DRY RUN - No changes made");
2233
+ lines.push("");
2234
+ lines.push("Would delete:");
2235
+ } else {
2236
+ lines.push("Deleted:");
2237
+ }
2238
+
2239
+ lines.push(` - ${counts.nullBeads} beads with NULL IDs`);
2240
+ lines.push(` - ${counts.orphanedRecipients} orphaned message_recipients`);
2241
+ lines.push(` - ${counts.messagesWithoutRecipients} messages without recipients`);
2242
+ lines.push(` - ${counts.expiredReservations} expired unreleased reservations`);
2243
+
2244
+ const total =
2245
+ counts.nullBeads +
2246
+ counts.orphanedRecipients +
2247
+ counts.messagesWithoutRecipients +
2248
+ counts.expiredReservations;
2249
+
2250
+ lines.push("");
2251
+ lines.push(`Total: ${total} records ${dryRun ? "would be cleaned" : "cleaned"}`);
2252
+
2253
+ return lines.join("\n");
2254
+ }
2255
+
2256
+ test("formats dry run summary", () => {
2257
+ const counts = {
2258
+ nullBeads: 427,
2259
+ orphanedRecipients: 208,
2260
+ messagesWithoutRecipients: 72,
2261
+ expiredReservations: 213,
2262
+ };
2263
+
2264
+ const result = formatRepairSummary(counts, true);
2265
+
2266
+ expect(result).toContain("DRY RUN");
2267
+ expect(result).toContain("Would delete:");
2268
+ expect(result).toContain("427 beads with NULL IDs");
2269
+ expect(result).toContain("208 orphaned message_recipients");
2270
+ expect(result).toContain("72 messages without recipients");
2271
+ expect(result).toContain("213 expired unreleased reservations");
2272
+ expect(result).toContain("Total: 920 records would be cleaned");
2273
+ });
2274
+
2275
+ test("formats actual repair summary", () => {
2276
+ const counts = {
2277
+ nullBeads: 427,
2278
+ orphanedRecipients: 208,
2279
+ messagesWithoutRecipients: 72,
2280
+ expiredReservations: 213,
2281
+ };
2282
+
2283
+ const result = formatRepairSummary(counts, false);
2284
+
2285
+ expect(result).not.toContain("DRY RUN");
2286
+ expect(result).toContain("Deleted:");
2287
+ expect(result).toContain("Total: 920 records cleaned");
2288
+ });
2289
+
2290
+ test("handles zero counts", () => {
2291
+ const counts = {
2292
+ nullBeads: 0,
2293
+ orphanedRecipients: 0,
2294
+ messagesWithoutRecipients: 0,
2295
+ expiredReservations: 0,
2296
+ };
2297
+
2298
+ const result = formatRepairSummary(counts, false);
2299
+
2300
+ expect(result).toContain("Total: 0 records cleaned");
2301
+ });
2302
+ });
2303
+ });
2304
+
package/bin/swarm.ts CHANGED
@@ -50,6 +50,8 @@ import {
50
50
  resolvePartialId,
51
51
  createDurableStreamAdapter,
52
52
  createDurableStreamServer,
53
+ consolidateDatabases,
54
+ getGlobalDbPath,
53
55
  } from "swarm-mail";
54
56
  import { execSync, spawn } from "child_process";
55
57
  import { tmpdir } from "os";
@@ -92,6 +94,9 @@ import {
92
94
  formatHealthDashboard,
93
95
  } from "../src/observability-health.js";
94
96
 
97
+ // Swarm insights
98
+ import { getRejectionAnalytics } from "../src/swarm-insights.js";
99
+
95
100
  // Eval tools
96
101
  import { getPhase, getScoreHistory, recordEvalRun, getEvalHistoryPath } from "../src/eval-history.js";
97
102
  import { DEFAULT_THRESHOLDS, checkGate } from "../src/eval-gates.js";
@@ -102,8 +107,11 @@ import { detectRegressions } from "../src/regression-detection.js";
102
107
  import { allTools } from "../src/index.js";
103
108
 
104
109
  const __dirname = dirname(fileURLToPath(import.meta.url));
105
- // When bundled to dist/bin/swarm.js, need to go up two levels to find package.json
106
- const pkgPath = join(__dirname, "..", "..", "package.json");
110
+ // When running from bin/swarm.ts, go up one level to find package.json
111
+ // When bundled to dist/bin/swarm.js, go up two levels
112
+ const pkgPath = existsSync(join(__dirname, "..", "package.json"))
113
+ ? join(__dirname, "..", "package.json")
114
+ : join(__dirname, "..", "..", "package.json");
107
115
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
108
116
  const VERSION: string = pkg.version;
109
117
 
@@ -2163,6 +2171,85 @@ async function setup(forceReinstall = false, nonInteractive = false) {
2163
2171
  p.log.message(dim(' No OpenCode config found (skipping MCP check)'));
2164
2172
  }
2165
2173
 
2174
+ // Check for stray databases and consolidate to global database
2175
+ p.log.step("Checking for stray databases...");
2176
+ const globalDbPath = getGlobalDbPath();
2177
+
2178
+ try {
2179
+ const report = await consolidateDatabases(cwd, globalDbPath, {
2180
+ yes: nonInteractive,
2181
+ interactive: !nonInteractive,
2182
+ });
2183
+
2184
+ if (report.straysFound > 0) {
2185
+ if (report.totalRowsMigrated > 0) {
2186
+ p.log.success(
2187
+ `Migrated ${report.totalRowsMigrated} records from ${report.straysMigrated} stray database(s)`
2188
+ );
2189
+ for (const migration of report.migrations) {
2190
+ const { migrated, skipped } = migration.result;
2191
+ if (migrated > 0 || skipped > 0) {
2192
+ p.log.message(
2193
+ dim(
2194
+ ` ${migration.path}: ${migrated} migrated, ${skipped} skipped`
2195
+ )
2196
+ );
2197
+ }
2198
+ }
2199
+ } else {
2200
+ p.log.message(
2201
+ dim(" All data already in global database (no migration needed)")
2202
+ );
2203
+ }
2204
+ } else {
2205
+ p.log.message(dim(" No stray databases found"));
2206
+ }
2207
+
2208
+ if (report.errors.length > 0) {
2209
+ p.log.warn(`${report.errors.length} error(s) during consolidation`);
2210
+ for (const error of report.errors) {
2211
+ p.log.message(dim(` ${error}`));
2212
+ }
2213
+ }
2214
+ } catch (error) {
2215
+ p.log.warn("Database consolidation check failed");
2216
+ if (error instanceof Error) {
2217
+ p.log.message(dim(` ${error.message}`));
2218
+ }
2219
+ // Don't fail setup - this is non-critical
2220
+ }
2221
+
2222
+ // Run database repair after consolidation
2223
+ p.log.step("Running database integrity check...");
2224
+ try {
2225
+ const repairResult = await runDbRepair({ dryRun: false });
2226
+
2227
+ if (repairResult.totalCleaned === 0) {
2228
+ p.log.success("Database integrity verified - no issues found");
2229
+ } else {
2230
+ p.log.success(`Cleaned ${repairResult.totalCleaned} orphaned/invalid records`);
2231
+
2232
+ if (repairResult.nullBeads > 0) {
2233
+ p.log.message(dim(` - ${repairResult.nullBeads} beads with NULL IDs`));
2234
+ }
2235
+ if (repairResult.orphanedRecipients > 0) {
2236
+ p.log.message(dim(` - ${repairResult.orphanedRecipients} orphaned message recipients`));
2237
+ }
2238
+ if (repairResult.messagesWithoutRecipients > 0) {
2239
+ p.log.message(dim(` - ${repairResult.messagesWithoutRecipients} messages without recipients`));
2240
+ }
2241
+ if (repairResult.expiredReservations > 0) {
2242
+ p.log.message(dim(` - ${repairResult.expiredReservations} expired reservations`));
2243
+ }
2244
+ }
2245
+ } catch (error) {
2246
+ p.log.warn("Database repair check failed (non-critical)");
2247
+ if (error instanceof Error) {
2248
+ p.log.message(dim(` ${error.message}`));
2249
+ }
2250
+ // Don't fail setup - this is non-critical
2251
+ }
2252
+
2166
2253
  // Model defaults: opus for coordinator, sonnet for worker, haiku for lite
2167
2254
  const DEFAULT_COORDINATOR = "anthropic/claude-opus-4-5";
2168
2255
  const DEFAULT_WORKER = "anthropic/claude-sonnet-4-5";
@@ -3274,6 +3361,7 @@ ${cyan("Stats & History:")}
3274
3361
  swarm stats Show swarm health metrics powered by swarm-insights (last 7 days)
3275
3362
  swarm stats --since 24h Show stats for custom time period
3276
3363
  swarm stats --regressions Show eval regressions (>10% score drops)
3364
+ swarm stats --rejections Show rejection reason analytics
3277
3365
  swarm stats --json Output as JSON for scripting
3278
3366
  swarm o11y Show observability health dashboard (hook coverage, events, sessions)
3279
3367
  swarm o11y --since 7d Custom time period for event stats (default: 7 days)
@@ -4532,7 +4620,178 @@ async function logs() {
4532
4620
  *
4533
4621
  * Helps debug which database is being used and its schema state.
4534
4622
  */
4623
+ /**
4624
+ * Run database repair programmatically
4625
+ * Returns counts of cleaned records
4626
+ */
4627
+ async function runDbRepair(options: { dryRun: boolean }): Promise<{
4628
+ nullBeads: number;
4629
+ orphanedRecipients: number;
4630
+ messagesWithoutRecipients: number;
4631
+ expiredReservations: number;
4632
+ totalCleaned: number;
4633
+ }> {
4634
+ const { dryRun } = options;
4635
+ const globalDbPath = getGlobalDbPath();
4636
+ const swarmMail = await getSwarmMailLibSQL(globalDbPath);
4637
+ const db = await swarmMail.getDatabase();
4638
+
4639
+ // Count records before cleanup
4640
+ const nullBeadsResult = await db.query<{ count: number }>("SELECT COUNT(*) as count FROM beads WHERE id IS NULL");
4641
+ const nullBeads = Number(nullBeadsResult[0]?.count ?? 0);
4642
+
4643
+ const orphanedRecipientsResult = await db.query<{ count: number }>(
4644
+ "SELECT COUNT(*) as count FROM message_recipients WHERE NOT EXISTS (SELECT 1 FROM agents WHERE agents.name = message_recipients.agent_name)"
4645
+ );
4646
+ const orphanedRecipients = Number(orphanedRecipientsResult[0]?.count ?? 0);
4647
+
4648
+ const messagesWithoutRecipientsResult = await db.query<{ count: number }>(
4649
+ "SELECT COUNT(*) as count FROM messages WHERE NOT EXISTS (SELECT 1 FROM message_recipients WHERE message_recipients.message_id = messages.id)"
4650
+ );
4651
+ const messagesWithoutRecipients = Number(messagesWithoutRecipientsResult[0]?.count ?? 0);
4652
+
4653
+ const expiredReservationsResult = await db.query<{ count: number }>(
4654
+ "SELECT COUNT(*) as count FROM reservations WHERE released_at IS NULL AND expires_at < strftime('%s', 'now') * 1000"
4655
+ );
4656
+ const expiredReservations = Number(expiredReservationsResult[0]?.count ?? 0);
4657
+
4658
+ const totalCleaned = nullBeads + orphanedRecipients + messagesWithoutRecipients + expiredReservations;
4659
+
4660
+ // If dry run or nothing to clean, return early
4661
+ if (dryRun || totalCleaned === 0) {
4662
+ return {
4663
+ nullBeads,
4664
+ orphanedRecipients,
4665
+ messagesWithoutRecipients,
4666
+ expiredReservations,
4667
+ totalCleaned,
4668
+ };
4669
+ }
4670
+
4671
+ // Execute cleanup queries
4672
+ if (nullBeads > 0) {
4673
+ await db.query("DELETE FROM beads WHERE id IS NULL");
4674
+ }
4675
+
4676
+ if (orphanedRecipients > 0) {
4677
+ await db.query(
4678
+ "DELETE FROM message_recipients WHERE NOT EXISTS (SELECT 1 FROM agents WHERE agents.name = message_recipients.agent_name)"
4679
+ );
4680
+ }
4681
+
4682
+ if (messagesWithoutRecipients > 0) {
4683
+ await db.query(
4684
+ "DELETE FROM messages WHERE NOT EXISTS (SELECT 1 FROM message_recipients WHERE message_recipients.message_id = messages.id)"
4685
+ );
4686
+ }
4687
+
4688
+ if (expiredReservations > 0) {
4689
+ await db.query(
4690
+ "UPDATE reservations SET released_at = strftime('%s', 'now') * 1000 WHERE released_at IS NULL AND expires_at < strftime('%s', 'now') * 1000"
4691
+ );
4692
+ }
4693
+
4694
+ return {
4695
+ nullBeads,
4696
+ orphanedRecipients,
4697
+ messagesWithoutRecipients,
4698
+ expiredReservations,
4699
+ totalCleaned,
4700
+ };
4701
+ }
4702
+
4703
+ /**
4704
+ * Database repair command (CLI interface)
4705
+ * Executes cleanup SQL to remove orphaned/invalid data
4706
+ */
4707
+ async function dbRepair() {
4708
+ const args = process.argv.slice(4); // Skip 'swarm', 'db', 'repair'
4709
+ let dryRun = false;
4710
+
4711
+ // Parse --dry-run flag
4712
+ for (const arg of args) {
4713
+ if (arg === "--dry-run") {
4714
+ dryRun = true;
4715
+ }
4716
+ }
4717
+
4718
+ p.intro(dryRun ? "swarm db repair (DRY RUN)" : "swarm db repair");
4719
+
4720
+ const s = p.spinner();
4721
+ s.start("Analyzing database...");
4722
+
4723
+ try {
4724
+ // Use shared helper for analysis
4725
+ const result = await runDbRepair({ dryRun: true });
4726
+
4727
+ s.stop("Analysis complete");
4728
+
4729
+ // Show counts
4730
+ p.log.step(dryRun ? "Would delete:" : "Deleting:");
4731
+ if (result.nullBeads > 0) {
4732
+ p.log.message(` - ${result.nullBeads} beads with NULL IDs`);
4733
+ }
4734
+ if (result.orphanedRecipients > 0) {
4735
+ p.log.message(` - ${result.orphanedRecipients} orphaned message_recipients`);
4736
+ }
4737
+ if (result.messagesWithoutRecipients > 0) {
4738
+ p.log.message(` - ${result.messagesWithoutRecipients} messages without recipients`);
4739
+ }
4740
+ if (result.expiredReservations > 0) {
4741
+ p.log.message(` - ${result.expiredReservations} expired unreleased reservations`);
4742
+ }
4743
+
4744
+ if (result.totalCleaned === 0) {
4745
+ p.outro(green("✓ Database is clean! No records to delete."));
4746
+ return;
4747
+ }
4748
+
4749
+ console.log();
4750
+ p.log.message(dim(`Total: ${result.totalCleaned} records ${dryRun ? "would be" : "will be"} cleaned`));
4751
+ console.log();
4752
+
4753
+ // If dry run, stop here
4754
+ if (dryRun) {
4755
+ p.outro(dim("Run without --dry-run to execute cleanup"));
4756
+ return;
4757
+ }
4758
+
4759
+ // Confirm before actual deletion
4760
+ const confirmed = await p.confirm({
4761
+ message: `Delete ${result.totalCleaned} records?`,
4762
+ initialValue: false,
4763
+ });
4764
+
4765
+ if (p.isCancel(confirmed) || !confirmed) {
4766
+ p.cancel("Cleanup cancelled");
4767
+ return;
4768
+ }
4769
+
4770
+ // Execute cleanup
4771
+ const cleanupSpinner = p.spinner();
4772
+ cleanupSpinner.start("Cleaning database...");
4773
+
4774
+ await runDbRepair({ dryRun: false });
4775
+
4776
+ cleanupSpinner.stop("Cleanup complete");
4777
+
4778
+ p.outro(green(`✓ Successfully cleaned ${result.totalCleaned} records`));
4779
+ } catch (error) {
4780
+ s.stop("Error");
4781
+ p.log.error(error instanceof Error ? error.message : String(error));
4782
+ process.exit(1);
4783
+ }
4784
+ }
4785
+
4535
4786
  async function db() {
4787
+ const args = process.argv.slice(3);
4788
+
4789
+ // Check for 'repair' subcommand
4790
+ if (args[0] === "repair") {
4791
+ await dbRepair();
4792
+ return;
4793
+ }
4794
+
4536
4795
  const projectPath = process.cwd();
4537
4796
  const projectName = basename(projectPath);
4538
4797
  const hash = hashLibSQLProjectPath(projectPath);
@@ -4801,6 +5060,7 @@ async function stats() {
4801
5060
  let period = "7d"; // default to 7 days
4802
5061
  let format: "text" | "json" = "text";
4803
5062
  let showRegressions = false;
5063
+ let showRejections = false;
4804
5064
 
4805
5065
  for (let i = 0; i < args.length; i++) {
4806
5066
  if (args[i] === "--since" || args[i] === "-s") {
@@ -4810,6 +5070,8 @@ async function stats() {
4810
5070
  format = "json";
4811
5071
  } else if (args[i] === "--regressions") {
4812
5072
  showRegressions = true;
5073
+ } else if (args[i] === "--rejections") {
5074
+ showRejections = true;
4813
5075
  }
4814
5076
  }
4815
5077
 
@@ -4966,6 +5228,42 @@ async function stats() {
4966
5228
  console.log("✅ No eval regressions detected (>10% threshold)\n");
4967
5229
  }
4968
5230
  }
5231
+ } else if (showRejections) {
5232
+ // If --rejections flag, show rejection analytics
5233
+ const rejectionAnalytics = await getRejectionAnalytics(swarmMail);
5234
+
5235
+ if (format === "json") {
5236
+ console.log(JSON.stringify(rejectionAnalytics, null, 2));
5237
+ } else {
5238
+ console.log();
5239
+ const boxWidth = 61;
5240
+ const pad = (text: string) => text + " ".repeat(Math.max(0, boxWidth - text.length));
5241
+
5242
+ console.log("┌─────────────────────────────────────────────────────────────┐");
5243
+ console.log("│" + pad(" REJECTION ANALYSIS (last " + period + ")") + "│");
5244
+ console.log("├─────────────────────────────────────────────────────────────┤");
5245
+ console.log("│" + pad(" Total Reviews: " + rejectionAnalytics.totalReviews) + "│");
5246
+
5247
+ const rejectionRate = rejectionAnalytics.totalReviews > 0
5248
+ ? (100 - rejectionAnalytics.approvalRate).toFixed(0)
5249
+ : "0";
5250
+ console.log("│" + pad(" Approved: " + rejectionAnalytics.approved + " (" + rejectionAnalytics.approvalRate.toFixed(0) + "%)") + "│");
5251
+ console.log("│" + pad(" Rejected: " + rejectionAnalytics.rejected + " (" + rejectionRate + "%)") + "│");
5252
+ console.log("│" + pad("") + "│");
5253
+
5254
+ if (rejectionAnalytics.topReasons.length > 0) {
5255
+ console.log("│" + pad(" Top Rejection Reasons:") + "│");
5256
+ for (const reason of rejectionAnalytics.topReasons) {
5257
+ const line = " ├── " + reason.category + ": " + reason.count + " (" + reason.percentage.toFixed(0) + "%)";
5258
+ console.log("│" + pad(line) + "│");
5259
+ }
5260
+ } else {
5261
+ console.log("│" + pad(" No rejections in this period") + "│");
5262
+ }
5263
+
5264
+ console.log("└─────────────────────────────────────────────────────────────┘");
5265
+ console.log();
5266
+ }
4969
5267
  } else {
4970
5268
  // Normal stats output
4971
5269
  if (format === "json") {