@winspan/claude-forge 8.13.0 → 8.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/agents/definition.d.ts +53 -0
  2. package/dist/agents/definition.d.ts.map +1 -0
  3. package/dist/agents/definition.js +24 -0
  4. package/dist/agents/definition.js.map +1 -0
  5. package/dist/agents/distributor.d.ts +23 -0
  6. package/dist/agents/distributor.d.ts.map +1 -0
  7. package/dist/agents/distributor.js +85 -0
  8. package/dist/agents/distributor.js.map +1 -0
  9. package/dist/agents/index.d.ts +5 -0
  10. package/dist/agents/index.d.ts.map +1 -0
  11. package/dist/agents/index.js +5 -0
  12. package/dist/agents/index.js.map +1 -0
  13. package/dist/agents/official-agents.d.ts +14 -0
  14. package/dist/agents/official-agents.d.ts.map +1 -0
  15. package/dist/agents/official-agents.js +522 -0
  16. package/dist/agents/official-agents.js.map +1 -0
  17. package/dist/agents/registry.d.ts +27 -0
  18. package/dist/agents/registry.d.ts.map +1 -0
  19. package/dist/agents/registry.js +105 -0
  20. package/dist/agents/registry.js.map +1 -0
  21. package/dist/cli/commands/init.d.ts.map +1 -1
  22. package/dist/cli/commands/init.js +17 -0
  23. package/dist/cli/commands/init.js.map +1 -1
  24. package/dist/core/storage/schema.sql +60 -0
  25. package/dist/core/storage/sqlite.d.ts +73 -0
  26. package/dist/core/storage/sqlite.d.ts.map +1 -1
  27. package/dist/core/storage/sqlite.js +159 -0
  28. package/dist/core/storage/sqlite.js.map +1 -1
  29. package/dist/daemon/auto-disable-scheduler.d.ts +53 -0
  30. package/dist/daemon/auto-disable-scheduler.d.ts.map +1 -0
  31. package/dist/daemon/auto-disable-scheduler.js +114 -0
  32. package/dist/daemon/auto-disable-scheduler.js.map +1 -0
  33. package/dist/daemon/handlers/post-tool-use.d.ts +3 -1
  34. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  35. package/dist/daemon/handlers/post-tool-use.js +14 -2
  36. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  37. package/dist/daemon/handlers/stop.d.ts +3 -1
  38. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  39. package/dist/daemon/handlers/stop.js +14 -1
  40. package/dist/daemon/handlers/stop.js.map +1 -1
  41. package/dist/daemon/handlers/user-prompt.d.ts +18 -5
  42. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  43. package/dist/daemon/handlers/user-prompt.js +104 -21
  44. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  45. package/dist/daemon/index.d.ts.map +1 -1
  46. package/dist/daemon/index.js +42 -12
  47. package/dist/daemon/index.js.map +1 -1
  48. package/dist/daemon/routing-observer.d.ts +39 -0
  49. package/dist/daemon/routing-observer.d.ts.map +1 -0
  50. package/dist/daemon/routing-observer.js +156 -0
  51. package/dist/daemon/routing-observer.js.map +1 -0
  52. package/dist/engine/agent-router.d.ts +99 -0
  53. package/dist/engine/agent-router.d.ts.map +1 -0
  54. package/dist/engine/agent-router.js +206 -0
  55. package/dist/engine/agent-router.js.map +1 -0
  56. package/dist/engine/conventions/routing.yaml +74 -0
  57. package/dist/engine/evidence-store.d.ts.map +1 -1
  58. package/dist/engine/evidence-store.js +3 -0
  59. package/dist/engine/evidence-store.js.map +1 -1
  60. package/dist/engine/experiment-router.d.ts +102 -0
  61. package/dist/engine/experiment-router.d.ts.map +1 -0
  62. package/dist/engine/experiment-router.js +289 -0
  63. package/dist/engine/experiment-router.js.map +1 -0
  64. package/dist/engine/recommender.d.ts +52 -0
  65. package/dist/engine/recommender.d.ts.map +1 -0
  66. package/dist/engine/recommender.js +150 -0
  67. package/dist/engine/recommender.js.map +1 -0
  68. package/dist/intelligence/classifier.d.ts +18 -4
  69. package/dist/intelligence/classifier.d.ts.map +1 -1
  70. package/dist/intelligence/classifier.js +90 -20
  71. package/dist/intelligence/classifier.js.map +1 -1
  72. package/dist/skills/registry.d.ts.map +1 -1
  73. package/dist/skills/registry.js +5 -2
  74. package/dist/skills/registry.js.map +1 -1
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +482 -11
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/static/index.html +764 -1
  80. package/package.json +1 -1
@@ -7,7 +7,11 @@ import express from 'express';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { fileURLToPath } from 'url';
10
+ import { homedir } from 'os';
11
+ import yaml from 'js-yaml';
12
+ import { Recommender } from '../engine/recommender.js';
10
13
  import { logger } from '../core/utils/logger.js';
14
+ import { ErrorHandler } from '../core/utils/error-handler.js';
11
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
16
  export class WebServer {
13
17
  options;
@@ -342,30 +346,498 @@ export class WebServer {
342
346
  confirm: decisions.filter(d => d.level === 'confirm').length,
343
347
  allow: decisions.filter(d => d.level === 'allow').length,
344
348
  };
345
- // Daily activity (last 7 days)
346
- const now = Date.now();
349
+ // Daily activity (last 7 days, local dates to match DB-stored local ISO strings)
350
+ const today = new Date();
347
351
  const dailyActivity = Array.from({ length: 7 }, (_, i) => {
348
- const dayStart = now - (6 - i) * 24 * 60 * 60 * 1000;
349
- const dayEnd = dayStart + 24 * 60 * 60 * 1000;
350
- const dayEvents = events.filter(e => {
351
- const ts = new Date(e.timestamp).getTime();
352
- return ts >= dayStart && ts < dayEnd;
353
- });
352
+ const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (6 - i));
353
+ const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
354
+ const dayEvents = events.filter(e => e.timestamp.slice(0, 10) === dateStr);
354
355
  return {
355
- date: new Date(dayStart).toISOString().split('T')[0],
356
+ date: dateStr,
356
357
  eventCount: dayEvents.length,
357
358
  sessionCount: new Set(dayEvents.map(e => e.session_id)).size,
358
359
  };
359
360
  });
360
361
  res.json({
361
362
  totalSessions: sessions.length,
362
- totalEvents: events.length,
363
+ totalEvents: storage.countEvents({}),
363
364
  totalDecisions: decisions.length,
364
365
  toolUsage,
365
366
  decisionLevels,
366
367
  dailyActivity,
367
368
  });
368
369
  });
370
+ // ── Agent Routing API ─────────────────────────────────────────────────
371
+ // Overview stats for the Agent Routing page
372
+ this.app.get('/api/routing/stats', (req, res) => {
373
+ const windowHours = parseInt(req.query.window || '168'); // default 7d
374
+ const since = Date.now() - windowHours * 3600 * 1000;
375
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
376
+ const total = events.length;
377
+ const byAgent = {};
378
+ let forced = 0;
379
+ let obeyedCount = 0;
380
+ let refusedCount = 0;
381
+ let unknownCount = 0;
382
+ let fallbackUsedCount = 0;
383
+ const latencies = [];
384
+ const byVersion = {};
385
+ for (const e of events) {
386
+ if (e.is_forced)
387
+ forced++;
388
+ if (e.fallback_used)
389
+ fallbackUsedCount++;
390
+ if (typeof e.classification_ms === 'number')
391
+ latencies.push(e.classification_ms);
392
+ const key = e.routed_to_name ?? '—';
393
+ const bucket = (byAgent[key] ||= { total: 0, obeyed: 0, refused: 0, unknown: 0 });
394
+ bucket.total++;
395
+ if (e.obeyed === 1) {
396
+ bucket.obeyed++;
397
+ obeyedCount++;
398
+ }
399
+ else if (e.obeyed === 0) {
400
+ bucket.refused++;
401
+ refusedCount++;
402
+ }
403
+ else {
404
+ bucket.unknown++;
405
+ unknownCount++;
406
+ }
407
+ const v = e.injection_version ?? '—';
408
+ const vb = (byVersion[v] ||= { total: 0, obeyed: 0 });
409
+ vb.total++;
410
+ if (e.obeyed === 1)
411
+ vb.obeyed++;
412
+ }
413
+ latencies.sort((a, b) => a - b);
414
+ const p = (pct) => latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * pct))];
415
+ res.json({
416
+ windowHours,
417
+ total,
418
+ forced,
419
+ obeyedCount,
420
+ refusedCount,
421
+ unknownCount,
422
+ obedienceRate: forced === 0 ? null : obeyedCount / forced,
423
+ refusalRate: forced === 0 ? null : refusedCount / forced,
424
+ fallbackUsedCount,
425
+ fallbackRate: total === 0 ? null : fallbackUsedCount / total,
426
+ latency: {
427
+ p50: p(0.5),
428
+ p95: p(0.95),
429
+ p99: p(0.99),
430
+ count: latencies.length,
431
+ },
432
+ byAgent,
433
+ byVersion,
434
+ });
435
+ });
436
+ // Recent routing events (timeline / detail)
437
+ this.app.get('/api/routing/events', (req, res) => {
438
+ const limit = parseInt(req.query.limit || '50');
439
+ const sessionId = req.query.session;
440
+ const projectPath = req.query.project;
441
+ const agent = req.query.agent;
442
+ const obeyedParam = req.query.obeyed;
443
+ const filter = { limit };
444
+ if (sessionId)
445
+ filter.session_id = sessionId;
446
+ if (projectPath)
447
+ filter.project_path = projectPath;
448
+ if (agent)
449
+ filter.routed_to_name = agent;
450
+ if (obeyedParam === 'null')
451
+ filter.obeyed = null;
452
+ else if (obeyedParam === '0')
453
+ filter.obeyed = 0;
454
+ else if (obeyedParam === '1')
455
+ filter.obeyed = 1;
456
+ const rows = storage.queryRoutingEvents(filter);
457
+ res.json(rows);
458
+ });
459
+ // Refusal clustering (Plan A: SQL GROUP BY by taskType × agent)
460
+ this.app.get('/api/routing/refusals', (req, res) => {
461
+ const windowHours = parseInt(req.query.window || '168');
462
+ const since = Date.now() - windowHours * 3600 * 1000;
463
+ const events = storage.queryRoutingEvents({
464
+ since_ts: since,
465
+ obeyed: 0,
466
+ limit: 1000,
467
+ });
468
+ // Group by (taskType, routed_to_name)
469
+ const groups = new Map();
470
+ for (const e of events) {
471
+ let taskType = 'unknown';
472
+ try {
473
+ const parsed = JSON.parse(e.intent_json ?? '{}');
474
+ if (typeof parsed.taskType === 'string')
475
+ taskType = parsed.taskType;
476
+ }
477
+ catch { /* ignore */ }
478
+ const key = `${taskType}__${e.routed_to_name ?? '—'}`;
479
+ const g = groups.get(key) ?? {
480
+ taskType,
481
+ agent: e.routed_to_name ?? '—',
482
+ count: 0,
483
+ samples: [],
484
+ };
485
+ g.count++;
486
+ if (g.samples.length < 5) {
487
+ g.samples.push({
488
+ prompt: e.prompt.slice(0, 200),
489
+ refusal_reason: e.refusal_reason ?? null,
490
+ ts: e.ts,
491
+ });
492
+ }
493
+ groups.set(key, g);
494
+ }
495
+ const sorted = Array.from(groups.values()).sort((a, b) => b.count - a.count);
496
+ res.json({ windowHours, groups: sorted });
497
+ });
498
+ // Routing violations analysis (Phase 3 Feature 3)
499
+ this.app.get('/api/routing/violations', (req, res) => {
500
+ const windowHours = parseInt(req.query.window || '168'); // default 7d
501
+ const since = Date.now() - windowHours * 3600 * 1000;
502
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
503
+ // Analyze violation patterns: consecutive refusals for same (taskType, agent)
504
+ const patterns = new Map();
505
+ for (const e of events) {
506
+ if (!e.is_forced || e.obeyed === null)
507
+ continue; // only analyze forced routes with judgement
508
+ let taskType = 'unknown';
509
+ try {
510
+ const parsed = JSON.parse(e.intent_json ?? '{}');
511
+ if (typeof parsed.taskType === 'string')
512
+ taskType = parsed.taskType;
513
+ }
514
+ catch { /* ignore */ }
515
+ const key = `${taskType}__${e.routed_to_name ?? '—'}`;
516
+ const p = patterns.get(key) ?? {
517
+ taskType,
518
+ agent: e.routed_to_name ?? '—',
519
+ totalAttempts: 0,
520
+ refusals: 0,
521
+ refusalRate: 0,
522
+ recentRefusals: 0,
523
+ severity: 'low',
524
+ samples: [],
525
+ };
526
+ p.totalAttempts++;
527
+ if (e.obeyed === 0) {
528
+ p.refusals++;
529
+ if (p.samples.length < 5) {
530
+ p.samples.push({
531
+ prompt: e.prompt.slice(0, 200),
532
+ refusal_reason: e.refusal_reason ?? null,
533
+ ts: e.ts,
534
+ });
535
+ }
536
+ }
537
+ patterns.set(key, p);
538
+ }
539
+ // Calculate metrics and severity
540
+ const violations = Array.from(patterns.values())
541
+ .map(p => {
542
+ p.refusalRate = p.totalAttempts === 0 ? 0 : p.refusals / p.totalAttempts;
543
+ // Calculate recent refusals (last 5 attempts for this pattern)
544
+ const recentEvents = events
545
+ .filter(e => {
546
+ let taskType = 'unknown';
547
+ try {
548
+ const parsed = JSON.parse(e.intent_json ?? '{}');
549
+ if (typeof parsed.taskType === 'string')
550
+ taskType = parsed.taskType;
551
+ }
552
+ catch { /* ignore */ }
553
+ return taskType === p.taskType && e.routed_to_name === p.agent;
554
+ })
555
+ .slice(0, 5);
556
+ p.recentRefusals = recentEvents.filter(e => e.obeyed === 0).length;
557
+ // Determine severity
558
+ if (p.refusalRate >= 0.8 && p.totalAttempts >= 5)
559
+ p.severity = 'critical';
560
+ else if (p.refusalRate >= 0.6 && p.totalAttempts >= 3)
561
+ p.severity = 'high';
562
+ else if (p.refusalRate >= 0.4)
563
+ p.severity = 'medium';
564
+ else
565
+ p.severity = 'low';
566
+ return p;
567
+ })
568
+ .filter(p => p.refusals > 0) // only show patterns with at least 1 refusal
569
+ .sort((a, b) => {
570
+ // Sort by severity, then refusal rate
571
+ const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
572
+ if (severityOrder[a.severity] !== severityOrder[b.severity]) {
573
+ return severityOrder[b.severity] - severityOrder[a.severity];
574
+ }
575
+ return b.refusalRate - a.refusalRate;
576
+ });
577
+ res.json({ windowHours, violations });
578
+ });
579
+ // Routing config editor API (Phase 3 Feature 2)
580
+ this.app.get('/api/routing/config', (_req, res) => {
581
+ const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
582
+ const defaultPath = path.join(__dirname, 'engine', 'conventions', 'routing.yaml');
583
+ let content = '';
584
+ let source = 'none';
585
+ if (fs.existsSync(userPath)) {
586
+ content = fs.readFileSync(userPath, 'utf-8');
587
+ source = 'user';
588
+ }
589
+ else if (fs.existsSync(defaultPath)) {
590
+ content = fs.readFileSync(defaultPath, 'utf-8');
591
+ source = 'default';
592
+ }
593
+ res.json({ content, source, userPath, defaultPath });
594
+ });
595
+ this.app.put('/api/routing/config', (req, res) => {
596
+ const { content } = req.body;
597
+ if (typeof content !== 'string') {
598
+ res.status(400).json({ error: 'content must be a string' });
599
+ return;
600
+ }
601
+ const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
602
+ const dir = path.dirname(userPath);
603
+ try {
604
+ // Validate YAML syntax before saving
605
+ yaml.load(content);
606
+ // Ensure directory exists
607
+ if (!fs.existsSync(dir)) {
608
+ fs.mkdirSync(dir, { recursive: true });
609
+ }
610
+ // Write to user override path
611
+ fs.writeFileSync(userPath, content, 'utf-8');
612
+ logger.info(`[Web] Routing config updated: ${userPath}`);
613
+ res.json({ success: true, path: userPath });
614
+ }
615
+ catch (err) {
616
+ logger.warn(`[Web] Failed to save routing config: ${err}`);
617
+ res.status(400).json({ error: String(err) });
618
+ }
619
+ });
620
+ // ── Phase 5: A/B Testing APIs ────────────────────────────────────────
621
+ const experimentsPath = path.join(homedir(), '.claude-forge', 'routing-experiments.yaml');
622
+ this.app.get('/api/routing/experiments/config', (_req, res) => {
623
+ let content = '';
624
+ let source = 'none';
625
+ if (fs.existsSync(experimentsPath)) {
626
+ content = fs.readFileSync(experimentsPath, 'utf-8');
627
+ source = 'user';
628
+ }
629
+ res.json({ content, source, path: experimentsPath });
630
+ });
631
+ this.app.put('/api/routing/experiments/config', async (req, res) => {
632
+ const { content } = req.body;
633
+ if (typeof content !== 'string') {
634
+ res.status(400).json({ error: 'content must be a string' });
635
+ return;
636
+ }
637
+ try {
638
+ const parsed = yaml.load(content, { schema: yaml.CORE_SCHEMA });
639
+ // Reuse validateConfig for structural enforcement (weights, groups, etc.).
640
+ const { validateConfig } = await import('../engine/experiment-router.js');
641
+ const cfg = validateConfig(parsed);
642
+ if (!cfg) {
643
+ res.status(400).json({ error: 'experiments YAML failed validation (see daemon logs)' });
644
+ return;
645
+ }
646
+ const dir = path.dirname(experimentsPath);
647
+ if (!fs.existsSync(dir))
648
+ fs.mkdirSync(dir, { recursive: true });
649
+ fs.writeFileSync(experimentsPath, content, 'utf-8');
650
+ logger.info(`[Web] Experiments config updated: ${experimentsPath}`);
651
+ res.json({ success: true, path: experimentsPath });
652
+ }
653
+ catch (err) {
654
+ logger.warn(`[Web] Failed to save experiments config: ${err}`);
655
+ res.status(400).json({ error: String(err) });
656
+ }
657
+ });
658
+ this.app.get('/api/routing/experiments/analysis', async (_req, res) => {
659
+ try {
660
+ if (!fs.existsSync(experimentsPath)) {
661
+ res.json({ enabled: false, experimentId: null, groups: [] });
662
+ return;
663
+ }
664
+ const raw = fs.readFileSync(experimentsPath, 'utf-8');
665
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
666
+ const { validateConfig } = await import('../engine/experiment-router.js');
667
+ const cfg = validateConfig(parsed);
668
+ if (!cfg || !cfg.experiment) {
669
+ res.json({ enabled: cfg?.enabled ?? false, experimentId: null, groups: [] });
670
+ return;
671
+ }
672
+ const stats = storage.queryExperimentStats(cfg.experiment.id);
673
+ const groups = cfg.experiment.groups.map(g => {
674
+ const s = stats.find(row => row.group_id === g.id);
675
+ const total = s?.total ?? 0;
676
+ const obeyed = s?.obeyed ?? 0;
677
+ const refused = s?.refused ?? 0;
678
+ return {
679
+ id: g.id,
680
+ name: g.name,
681
+ weight: g.weight,
682
+ total,
683
+ obeyed,
684
+ refused,
685
+ unknown: s?.unknown ?? 0,
686
+ obeyedRate: total > 0 ? obeyed / total : null,
687
+ avgClassificationMs: s?.avg_classification_ms ?? null,
688
+ };
689
+ });
690
+ // Simple z-test between the two groups with the largest samples.
691
+ let zScore = null;
692
+ let sampleAdequate = false;
693
+ let suggestedWinner = null;
694
+ if (groups.length >= 2) {
695
+ const sorted = [...groups].sort((a, b) => b.total - a.total).slice(0, 2);
696
+ const [g1, g2] = sorted;
697
+ sampleAdequate = g1.total >= 50 && g2.total >= 50;
698
+ if (sampleAdequate) {
699
+ const p1 = g1.obeyed / g1.total;
700
+ const p2 = g2.obeyed / g2.total;
701
+ const pPool = (g1.obeyed + g2.obeyed) / (g1.total + g2.total);
702
+ const se = Math.sqrt(pPool * (1 - pPool) * (1 / g1.total + 1 / g2.total));
703
+ zScore = se > 0 ? (p1 - p2) / se : 0;
704
+ if (Math.abs(zScore) > 1.96) {
705
+ suggestedWinner = p1 > p2 ? g1.id : g2.id;
706
+ }
707
+ }
708
+ }
709
+ res.json({
710
+ enabled: cfg.enabled,
711
+ experimentId: cfg.experiment.id,
712
+ experimentName: cfg.experiment.name,
713
+ startedAt: cfg.experiment.startedAt,
714
+ endedAt: cfg.experiment.endedAt,
715
+ groups,
716
+ zScore,
717
+ sampleAdequate,
718
+ suggestedWinner,
719
+ });
720
+ }
721
+ catch (err) {
722
+ logger.warn(`[Web] Experiments analysis failed: ${err}`);
723
+ res.status(500).json({ error: String(err) });
724
+ }
725
+ });
726
+ this.app.post('/api/routing/experiments/promote', async (req, res) => {
727
+ const { groupId } = req.body;
728
+ if (typeof groupId !== 'string' || groupId.length === 0) {
729
+ res.status(400).json({ error: 'groupId is required' });
730
+ return;
731
+ }
732
+ try {
733
+ if (!fs.existsSync(experimentsPath)) {
734
+ res.status(400).json({ error: 'experiments config does not exist' });
735
+ return;
736
+ }
737
+ const raw = fs.readFileSync(experimentsPath, 'utf-8');
738
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
739
+ const { validateConfig } = await import('../engine/experiment-router.js');
740
+ const cfg = validateConfig(parsed);
741
+ if (!cfg || !cfg.experiment) {
742
+ res.status(400).json({ error: 'experiments config has no active experiment' });
743
+ return;
744
+ }
745
+ const group = cfg.experiment.groups.find(g => g.id === groupId);
746
+ if (!group) {
747
+ res.status(404).json({ error: `group '${groupId}' not found` });
748
+ return;
749
+ }
750
+ // Step 1: backup existing routing.yaml if present
751
+ const routingPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
752
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
753
+ let backupPath = null;
754
+ if (fs.existsSync(routingPath)) {
755
+ backupPath = `${routingPath}.bak-${ts}`;
756
+ fs.copyFileSync(routingPath, backupPath);
757
+ }
758
+ else {
759
+ const dir = path.dirname(routingPath);
760
+ if (!fs.existsSync(dir))
761
+ fs.mkdirSync(dir, { recursive: true });
762
+ }
763
+ // Step 2: write the winner's rules as the new routing.yaml
764
+ const newRouting = yaml.dump({ schemaVersion: '1.0', rules: group.rules });
765
+ fs.writeFileSync(routingPath, newRouting, 'utf-8');
766
+ // Step 3: mark experiment as ended (enabled=false, endedAt=now)
767
+ const endedAt = new Date().toISOString();
768
+ const updated = yaml.dump({
769
+ schemaVersion: '1.0',
770
+ enabled: false,
771
+ experiment: {
772
+ ...cfg.experiment,
773
+ endedAt,
774
+ },
775
+ });
776
+ fs.writeFileSync(experimentsPath, updated, 'utf-8');
777
+ logger.info(`[Web] Promoted group '${groupId}' from experiment '${cfg.experiment.id}'; backup: ${backupPath}`);
778
+ res.json({ promoted: groupId, routingPath, backupPath, endedAt });
779
+ }
780
+ catch (err) {
781
+ logger.warn(`[Web] Failed to promote experiment group: ${err}`);
782
+ res.status(500).json({ error: String(err) });
783
+ }
784
+ });
785
+ // ── Phase 5 Feature 2: Rule States (auto-disable) ────────────────────
786
+ this.app.get('/api/routing/rule-states', (req, res) => {
787
+ const disabledOnly = req.query.disabled === '1' || req.query.disabled === 'true';
788
+ const rows = storage.listRuleStates({ disabledOnly });
789
+ res.json({ ruleStates: rows });
790
+ });
791
+ this.app.put('/api/routing/rule-states', (req, res) => {
792
+ const { taskType, agent, disabled, reason } = req.body ?? {};
793
+ if (typeof taskType !== 'string' || taskType.length === 0) {
794
+ res.status(400).json({ error: 'taskType is required' });
795
+ return;
796
+ }
797
+ if (typeof agent !== 'string' || agent.length === 0) {
798
+ res.status(400).json({ error: 'agent is required' });
799
+ return;
800
+ }
801
+ if (typeof disabled !== 'boolean') {
802
+ res.status(400).json({ error: 'disabled must be boolean' });
803
+ return;
804
+ }
805
+ try {
806
+ storage.setRuleState({
807
+ taskType,
808
+ agent,
809
+ disabled,
810
+ reason: typeof reason === 'string' ? reason : null,
811
+ autoDisabled: false, // manual toggle
812
+ });
813
+ logger.info(`[Web] Rule state updated: ${taskType}__${agent} disabled=${disabled}`);
814
+ res.json({ success: true });
815
+ }
816
+ catch (err) {
817
+ logger.warn(`[Web] Failed to set rule state: ${err}`);
818
+ res.status(500).json({ error: String(err) });
819
+ }
820
+ });
821
+ // ── Phase 5 Feature 3: Rule Recommendations ──────────────────────────
822
+ this.app.get('/api/routing/recommendations', (req, res) => {
823
+ const { router, agents } = this.options;
824
+ if (!router || !agents) {
825
+ res.json({ recommendations: [], reason: 'router/agents not injected' });
826
+ return;
827
+ }
828
+ const windowDays = Math.max(1, Math.min(90, parseInt(req.query.days || '7')));
829
+ try {
830
+ const recommender = new Recommender(storage, router, agents, {
831
+ windowMs: windowDays * 24 * 3600 * 1000,
832
+ });
833
+ const recommendations = recommender.analyze();
834
+ res.json({ windowDays, recommendations });
835
+ }
836
+ catch (err) {
837
+ logger.warn(`[Web] Recommender failed: ${err}`);
838
+ res.status(500).json({ error: String(err) });
839
+ }
840
+ });
369
841
  // SSE: real-time event stream
370
842
  this.app.get('/api/events/stream', (req, res) => {
371
843
  res.writeHead(200, {
@@ -419,7 +891,6 @@ export class WebServer {
419
891
  });
420
892
  this.server?.on('error', (error) => {
421
893
  if (error.code === 'EADDRINUSE') {
422
- const { ErrorHandler } = require('../core/utils/error-handler.js');
423
894
  ErrorHandler.handle(error, 'Web Server');
424
895
  }
425
896
  reject(error);