@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.
- package/dist/agents/definition.d.ts +53 -0
- package/dist/agents/definition.d.ts.map +1 -0
- package/dist/agents/definition.js +24 -0
- package/dist/agents/definition.js.map +1 -0
- package/dist/agents/distributor.d.ts +23 -0
- package/dist/agents/distributor.d.ts.map +1 -0
- package/dist/agents/distributor.js +85 -0
- package/dist/agents/distributor.js.map +1 -0
- package/dist/agents/index.d.ts +5 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +5 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/official-agents.d.ts +14 -0
- package/dist/agents/official-agents.d.ts.map +1 -0
- package/dist/agents/official-agents.js +522 -0
- package/dist/agents/official-agents.js.map +1 -0
- package/dist/agents/registry.d.ts +27 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/registry.js +105 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +17 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/core/storage/schema.sql +60 -0
- package/dist/core/storage/sqlite.d.ts +73 -0
- package/dist/core/storage/sqlite.d.ts.map +1 -1
- package/dist/core/storage/sqlite.js +159 -0
- package/dist/core/storage/sqlite.js.map +1 -1
- package/dist/daemon/auto-disable-scheduler.d.ts +53 -0
- package/dist/daemon/auto-disable-scheduler.d.ts.map +1 -0
- package/dist/daemon/auto-disable-scheduler.js +114 -0
- package/dist/daemon/auto-disable-scheduler.js.map +1 -0
- package/dist/daemon/handlers/post-tool-use.d.ts +3 -1
- package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
- package/dist/daemon/handlers/post-tool-use.js +14 -2
- package/dist/daemon/handlers/post-tool-use.js.map +1 -1
- package/dist/daemon/handlers/stop.d.ts +3 -1
- package/dist/daemon/handlers/stop.d.ts.map +1 -1
- package/dist/daemon/handlers/stop.js +14 -1
- package/dist/daemon/handlers/stop.js.map +1 -1
- package/dist/daemon/handlers/user-prompt.d.ts +18 -5
- package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
- package/dist/daemon/handlers/user-prompt.js +104 -21
- package/dist/daemon/handlers/user-prompt.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +42 -12
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/routing-observer.d.ts +39 -0
- package/dist/daemon/routing-observer.d.ts.map +1 -0
- package/dist/daemon/routing-observer.js +156 -0
- package/dist/daemon/routing-observer.js.map +1 -0
- package/dist/engine/agent-router.d.ts +99 -0
- package/dist/engine/agent-router.d.ts.map +1 -0
- package/dist/engine/agent-router.js +206 -0
- package/dist/engine/agent-router.js.map +1 -0
- package/dist/engine/conventions/routing.yaml +74 -0
- package/dist/engine/evidence-store.d.ts.map +1 -1
- package/dist/engine/evidence-store.js +3 -0
- package/dist/engine/evidence-store.js.map +1 -1
- package/dist/engine/experiment-router.d.ts +102 -0
- package/dist/engine/experiment-router.d.ts.map +1 -0
- package/dist/engine/experiment-router.js +289 -0
- package/dist/engine/experiment-router.js.map +1 -0
- package/dist/engine/recommender.d.ts +52 -0
- package/dist/engine/recommender.d.ts.map +1 -0
- package/dist/engine/recommender.js +150 -0
- package/dist/engine/recommender.js.map +1 -0
- package/dist/intelligence/classifier.d.ts +18 -4
- package/dist/intelligence/classifier.d.ts.map +1 -1
- package/dist/intelligence/classifier.js +90 -20
- package/dist/intelligence/classifier.js.map +1 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +5 -2
- package/dist/skills/registry.js.map +1 -1
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +482 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/static/index.html +764 -1
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -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
|
|
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
|
|
349
|
-
const
|
|
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:
|
|
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:
|
|
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);
|