@winspan/claude-forge 8.18.0 → 8.25.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 (35) hide show
  1. package/dist/claudemd/resume-manager.d.ts.map +1 -1
  2. package/dist/claudemd/resume-manager.js +8 -7
  3. package/dist/claudemd/resume-manager.js.map +1 -1
  4. package/dist/cli/commands/agents.d.ts +3 -0
  5. package/dist/cli/commands/agents.d.ts.map +1 -0
  6. package/dist/cli/commands/agents.js +62 -0
  7. package/dist/cli/commands/agents.js.map +1 -0
  8. package/dist/cli/index.js +2 -0
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/core/storage/schema.sql +3 -0
  11. package/dist/core/storage/sqlite.d.ts +4 -0
  12. package/dist/core/storage/sqlite.d.ts.map +1 -1
  13. package/dist/core/storage/sqlite.js +24 -3
  14. package/dist/core/storage/sqlite.js.map +1 -1
  15. package/dist/daemon/handlers/stop.d.ts +3 -1
  16. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  17. package/dist/daemon/handlers/stop.js +13 -2
  18. package/dist/daemon/handlers/stop.js.map +1 -1
  19. package/dist/daemon/index.d.ts.map +1 -1
  20. package/dist/daemon/index.js +1 -1
  21. package/dist/daemon/index.js.map +1 -1
  22. package/dist/daemon/routing-observer.d.ts +1 -0
  23. package/dist/daemon/routing-observer.d.ts.map +1 -1
  24. package/dist/daemon/routing-observer.js +29 -23
  25. package/dist/daemon/routing-observer.js.map +1 -1
  26. package/dist/intelligence/task-segmenter.d.ts +1 -0
  27. package/dist/intelligence/task-segmenter.d.ts.map +1 -1
  28. package/dist/intelligence/task-segmenter.js +10 -0
  29. package/dist/intelligence/task-segmenter.js.map +1 -1
  30. package/dist/web/server.d.ts +1 -0
  31. package/dist/web/server.d.ts.map +1 -1
  32. package/dist/web/server.js +832 -0
  33. package/dist/web/server.js.map +1 -1
  34. package/dist/web/static/index.html +786 -2
  35. package/package.json +1 -1
@@ -13,21 +13,45 @@ import { Recommender } from '../engine/recommender.js';
13
13
  import { logger } from '../core/utils/logger.js';
14
14
  import { ErrorHandler } from '../core/utils/error-handler.js';
15
15
  import { ConfigManager } from '../core/config.js';
16
+ import { ClaudeProvider } from '../core/ai/provider.js';
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  export class WebServer {
18
19
  options;
19
20
  app;
20
21
  server = null;
21
22
  agents;
23
+ router;
22
24
  constructor(options) {
23
25
  this.options = options;
24
26
  this.app = express();
25
27
  this.app.use(express.json());
26
28
  this.agents = options.agents;
29
+ this.router = options.router;
27
30
  this.setupRoutes();
28
31
  }
29
32
  setupRoutes() {
30
33
  const { storage, ruleEngine } = this.options;
34
+ const resolvePatchTarget = (targetType, targetName) => {
35
+ if (targetType === 'agent') {
36
+ return {
37
+ filePath: path.join(homedir(), '.claude', 'agents', `${targetName}.md`),
38
+ backupDir: path.join(homedir(), '.claude-forge', 'backups', 'agents'),
39
+ };
40
+ }
41
+ if (targetType === 'skill') {
42
+ return {
43
+ filePath: path.join(homedir(), '.claude', 'skills', `${targetName}.md`),
44
+ backupDir: path.join(homedir(), '.claude-forge', 'backups', 'skills'),
45
+ };
46
+ }
47
+ if (targetType === 'routing_rule') {
48
+ return {
49
+ filePath: path.join(homedir(), '.claude-forge', 'routing.yaml'),
50
+ backupDir: path.join(homedir(), '.claude-forge', 'backups', 'routing'),
51
+ };
52
+ }
53
+ throw new Error(`Unsupported targetType: ${targetType}`);
54
+ };
31
55
  // Serve static files (support both dist/ and src/ layouts)
32
56
  const candidates = [
33
57
  path.join(__dirname, 'static'), // dist/web/static
@@ -436,6 +460,161 @@ export class WebServer {
436
460
  byVersion,
437
461
  });
438
462
  });
463
+ // Performance analysis for the Agent Routing page
464
+ this.app.get('/api/routing/performance', (req, res) => {
465
+ const windowHours = parseInt(req.query.window || '168');
466
+ const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
467
+ const since = Date.now() - windowHours * 3600 * 1000;
468
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
469
+ const relevant = events.filter(e => e.routed_to_name);
470
+ const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
471
+ const obeyedCount = judged.filter(e => e.obeyed === 1).length;
472
+ const refusedCount = judged.filter(e => e.obeyed === 0).length;
473
+ const unknownCount = relevant.length - judged.length;
474
+ const latencies = relevant
475
+ .map(e => e.classification_ms)
476
+ .filter((ms) => typeof ms === 'number');
477
+ const executionLatencies = relevant
478
+ .map(e => e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null))
479
+ .filter((ms) => typeof ms === 'number');
480
+ const avgClassificationMs = latencies.length === 0
481
+ ? null
482
+ : Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length);
483
+ const avgExecutionMs = executionLatencies.length === 0
484
+ ? null
485
+ : Math.round(executionLatencies.reduce((sum, n) => sum + n, 0) / executionLatencies.length);
486
+ const sortedExecution = [...executionLatencies].sort((a, b) => a - b);
487
+ const p95ExecutionMs = sortedExecution.length === 0
488
+ ? null
489
+ : sortedExecution[Math.min(sortedExecution.length - 1, Math.floor(sortedExecution.length * 0.95))];
490
+ const byAgentMap = new Map();
491
+ const dailyMap = new Map();
492
+ for (const e of relevant) {
493
+ const agent = e.routed_to_name ?? '—';
494
+ const agentBucket = byAgentMap.get(agent) ?? {
495
+ agent,
496
+ total: 0,
497
+ judged: 0,
498
+ obeyed: 0,
499
+ refused: 0,
500
+ unknown: 0,
501
+ latencySum: 0,
502
+ latencyCount: 0,
503
+ executionSum: 0,
504
+ executionCount: 0,
505
+ };
506
+ agentBucket.total++;
507
+ if (e.obeyed === 1) {
508
+ agentBucket.judged++;
509
+ agentBucket.obeyed++;
510
+ }
511
+ else if (e.obeyed === 0) {
512
+ agentBucket.judged++;
513
+ agentBucket.refused++;
514
+ }
515
+ else {
516
+ agentBucket.unknown++;
517
+ }
518
+ if (typeof e.classification_ms === 'number') {
519
+ agentBucket.latencySum += e.classification_ms;
520
+ agentBucket.latencyCount++;
521
+ }
522
+ const executionMs = e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null);
523
+ if (typeof executionMs === 'number') {
524
+ agentBucket.executionSum += executionMs;
525
+ agentBucket.executionCount++;
526
+ }
527
+ byAgentMap.set(agent, agentBucket);
528
+ const date = new Date(e.ts).toISOString().slice(0, 10);
529
+ const dayBucket = dailyMap.get(date) ?? {
530
+ date,
531
+ total: 0,
532
+ obeyed: 0,
533
+ refused: 0,
534
+ unknown: 0,
535
+ latencySum: 0,
536
+ latencyCount: 0,
537
+ executionSum: 0,
538
+ executionCount: 0,
539
+ };
540
+ dayBucket.total++;
541
+ if (e.obeyed === 1)
542
+ dayBucket.obeyed++;
543
+ else if (e.obeyed === 0)
544
+ dayBucket.refused++;
545
+ else
546
+ dayBucket.unknown++;
547
+ if (typeof e.classification_ms === 'number') {
548
+ dayBucket.latencySum += e.classification_ms;
549
+ dayBucket.latencyCount++;
550
+ }
551
+ if (typeof executionMs === 'number') {
552
+ dayBucket.executionSum += executionMs;
553
+ dayBucket.executionCount++;
554
+ }
555
+ dailyMap.set(date, dayBucket);
556
+ }
557
+ const byAgent = Array.from(byAgentMap.values())
558
+ .map(a => ({
559
+ agent: a.agent,
560
+ total: a.total,
561
+ judged: a.judged,
562
+ obeyed: a.obeyed,
563
+ refused: a.refused,
564
+ unknown: a.unknown,
565
+ obedienceRate: a.judged === 0 ? null : a.obeyed / a.judged,
566
+ refusalRate: a.judged === 0 ? null : a.refused / a.judged,
567
+ avgClassificationMs: a.latencyCount === 0 ? null : Math.round(a.latencySum / a.latencyCount),
568
+ avgExecutionMs: a.executionCount === 0 ? null : Math.round(a.executionSum / a.executionCount),
569
+ }))
570
+ .sort((a, b) => b.total - a.total);
571
+ const dailyTrend = Array.from(dailyMap.values())
572
+ .map(d => ({
573
+ date: d.date,
574
+ total: d.total,
575
+ obeyed: d.obeyed,
576
+ refused: d.refused,
577
+ unknown: d.unknown,
578
+ avgClassificationMs: d.latencyCount === 0 ? null : Math.round(d.latencySum / d.latencyCount),
579
+ avgExecutionMs: d.executionCount === 0 ? null : Math.round(d.executionSum / d.executionCount),
580
+ }))
581
+ .sort((a, b) => a.date.localeCompare(b.date));
582
+ const highRefusalAgents = byAgent
583
+ .filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
584
+ .sort((a, b) => {
585
+ const rateDiff = (b.refusalRate ?? 0) - (a.refusalRate ?? 0);
586
+ return rateDiff !== 0 ? rateDiff : b.judged - a.judged;
587
+ })
588
+ .slice(0, 10)
589
+ .map(a => ({
590
+ agent: a.agent,
591
+ totalAttempts: a.judged,
592
+ obeyed: a.obeyed,
593
+ refused: a.refused,
594
+ refusalRate: a.refusalRate,
595
+ avgClassificationMs: a.avgClassificationMs,
596
+ avgExecutionMs: a.avgExecutionMs,
597
+ }));
598
+ res.json({
599
+ windowHours,
600
+ minAttempts,
601
+ summary: {
602
+ totalRouted: relevant.length,
603
+ totalJudged: judged.length,
604
+ obeyed: obeyedCount,
605
+ refused: refusedCount,
606
+ unknown: unknownCount,
607
+ obedienceRate: judged.length === 0 ? null : obeyedCount / judged.length,
608
+ refusalRate: judged.length === 0 ? null : refusedCount / judged.length,
609
+ avgClassificationMs,
610
+ avgExecutionMs,
611
+ p95ExecutionMs,
612
+ },
613
+ byAgent,
614
+ dailyTrend,
615
+ highRefusalAgents,
616
+ });
617
+ });
439
618
  // Recent routing events (timeline / detail)
440
619
  this.app.get('/api/routing/events', (req, res) => {
441
620
  const limit = parseInt(req.query.limit || '50');
@@ -841,6 +1020,236 @@ export class WebServer {
841
1020
  res.status(500).json({ error: String(err) });
842
1021
  }
843
1022
  });
1023
+ // AI-assisted optimization recommendations
1024
+ this.app.get('/api/routing/ai-optimization', async (req, res) => {
1025
+ const { router, agents } = this.options;
1026
+ if (!router || !agents) {
1027
+ res.status(400).json({ error: 'router/agents not injected' });
1028
+ return;
1029
+ }
1030
+ const windowHours = Math.max(1, Math.min(720, parseInt(req.query.window || '168')));
1031
+ const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
1032
+ const since = Date.now() - windowHours * 3600 * 1000;
1033
+ try {
1034
+ const config = new ConfigManager().get();
1035
+ const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
1036
+ if (!apiKey) {
1037
+ res.status(400).json({ error: 'AI API key not configured' });
1038
+ return;
1039
+ }
1040
+ const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
1041
+ const recommender = new Recommender(storage, router, agents, { windowMs: windowHours * 3600 * 1000 });
1042
+ const ruleRecommendations = recommender.analyze();
1043
+ const performanceRes = await (async () => {
1044
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
1045
+ const relevant = events.filter(e => e.routed_to_name);
1046
+ const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
1047
+ const byAgent = new Map();
1048
+ for (const e of relevant) {
1049
+ const key = e.routed_to_name ?? '—';
1050
+ const bucket = byAgent.get(key) ?? { total: 0, judged: 0, obeyed: 0, refused: 0, latencySum: 0, latencyCount: 0 };
1051
+ bucket.total++;
1052
+ if (e.obeyed === 1) {
1053
+ bucket.judged++;
1054
+ bucket.obeyed++;
1055
+ }
1056
+ else if (e.obeyed === 0) {
1057
+ bucket.judged++;
1058
+ bucket.refused++;
1059
+ }
1060
+ if (typeof e.classification_ms === 'number') {
1061
+ bucket.latencySum += e.classification_ms;
1062
+ bucket.latencyCount++;
1063
+ }
1064
+ byAgent.set(key, bucket);
1065
+ }
1066
+ return {
1067
+ totalRouted: relevant.length,
1068
+ totalJudged: judged.length,
1069
+ byAgent: Array.from(byAgent.entries()).map(([agent, b]) => ({
1070
+ agent,
1071
+ total: b.total,
1072
+ judged: b.judged,
1073
+ obeyed: b.obeyed,
1074
+ refused: b.refused,
1075
+ refusalRate: b.judged === 0 ? null : b.refused / b.judged,
1076
+ avgClassificationMs: b.latencyCount === 0 ? null : Math.round(b.latencySum / b.latencyCount),
1077
+ })).sort((a, b) => (b.refusalRate ?? 0) - (a.refusalRate ?? 0)),
1078
+ };
1079
+ })();
1080
+ const highRefusalAgents = performanceRes.byAgent
1081
+ .filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
1082
+ .slice(0, 10);
1083
+ const topViolations = (await (async () => {
1084
+ const events = storage.queryRoutingEvents({ since_ts: since, obeyed: 0, limit: 1000 });
1085
+ const patterns = new Map();
1086
+ for (const e of events) {
1087
+ let taskType = 'unknown';
1088
+ try {
1089
+ const parsed = JSON.parse(e.intent_json ?? '{}');
1090
+ if (typeof parsed.taskType === 'string')
1091
+ taskType = parsed.taskType;
1092
+ }
1093
+ catch { /* ignore */ }
1094
+ const key = `${taskType}__${e.routed_to_name ?? '—'}`;
1095
+ const p = patterns.get(key) ?? { taskType, agent: e.routed_to_name ?? '—', total: 0, refusals: 0, refusalRate: 0, samples: [] };
1096
+ p.total++;
1097
+ p.refusals++;
1098
+ if (p.samples.length < 3)
1099
+ p.samples.push(e.prompt.slice(0, 180));
1100
+ patterns.set(key, p);
1101
+ }
1102
+ return Array.from(patterns.values()).map(p => ({
1103
+ ...p,
1104
+ refusalRate: p.total === 0 ? 0 : p.refusals / p.total,
1105
+ })).sort((a, b) => b.refusalRate - a.refusalRate).slice(0, 5);
1106
+ })());
1107
+ const prompt = [
1108
+ 'You are reviewing Claude Forge routing performance and should produce practical improvement suggestions.',
1109
+ 'Return ONLY valid JSON with keys: summary, priorities, suggestedChanges.',
1110
+ '',
1111
+ 'Context:',
1112
+ `- windowHours: ${windowHours}`,
1113
+ `- minAttempts: ${minAttempts}`,
1114
+ `- totalRouted: ${performanceRes.totalRouted}`,
1115
+ `- totalJudged: ${performanceRes.totalJudged}`,
1116
+ '',
1117
+ 'Performance by agent:',
1118
+ JSON.stringify(highRefusalAgents, null, 2),
1119
+ '',
1120
+ 'Top refusal patterns:',
1121
+ JSON.stringify(topViolations, null, 2),
1122
+ '',
1123
+ 'Routing rule recommendations:',
1124
+ JSON.stringify(ruleRecommendations.slice(0, 8), null, 2),
1125
+ '',
1126
+ 'Please provide:',
1127
+ '- summary: 2-3 sentences summarizing the main issues',
1128
+ '- priorities: array of { area, finding, impact, confidence }',
1129
+ '- suggestedChanges: array of { targetType: agent|skill|routing_rule, targetName, recommendation, rationale, expectedBenefit }',
1130
+ ].join('\n');
1131
+ const raw = await ai.complete(prompt, { maxTokens: 2500 });
1132
+ const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '');
1133
+ const match = cleaned.match(/\{[\s\S]*\}/);
1134
+ if (!match) {
1135
+ res.status(500).json({ error: 'AI did not return JSON', raw });
1136
+ return;
1137
+ }
1138
+ const parsed = JSON.parse(match[0]);
1139
+ res.json({
1140
+ windowHours,
1141
+ minAttempts,
1142
+ generatedAt: new Date().toISOString(),
1143
+ summary: parsed.summary ?? '',
1144
+ priorities: Array.isArray(parsed.priorities) ? parsed.priorities : [],
1145
+ suggestedChanges: Array.isArray(parsed.suggestedChanges) ? parsed.suggestedChanges : [],
1146
+ evidence: {
1147
+ highRefusalAgents,
1148
+ topViolations,
1149
+ ruleRecommendations: ruleRecommendations.slice(0, 8),
1150
+ },
1151
+ });
1152
+ }
1153
+ catch (err) {
1154
+ logger.warn(`[Web] Failed to generate AI optimization: ${err}`);
1155
+ res.status(500).json({ error: String(err) });
1156
+ }
1157
+ });
1158
+ // ── Patch APIs ────────────────────────────────────────────────────────
1159
+ // POST /api/patch/preview — generate structured patch preview
1160
+ this.app.post('/api/patch/preview', async (req, res) => {
1161
+ const { targetType, targetName, recommendation, rationale } = req.body ?? {};
1162
+ if (!targetType || !targetName || !recommendation) {
1163
+ res.status(400).json({ error: 'targetType, targetName, and recommendation are required' });
1164
+ return;
1165
+ }
1166
+ try {
1167
+ const config = new ConfigManager().get();
1168
+ const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
1169
+ if (!apiKey) {
1170
+ res.status(400).json({ error: 'AI API key not configured' });
1171
+ return;
1172
+ }
1173
+ const { filePath } = resolvePatchTarget(targetType, targetName);
1174
+ if (!fs.existsSync(filePath)) {
1175
+ res.status(404).json({ error: `Target file not found: ${filePath}` });
1176
+ return;
1177
+ }
1178
+ const currentContent = fs.readFileSync(filePath, 'utf-8');
1179
+ const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
1180
+ const prompt = `You are a code/config optimization assistant. Given the current file content and a recommended change, generate the updated content.
1181
+
1182
+ Current file (${targetType}/${targetName}):
1183
+ \`\`\`
1184
+ ${currentContent}
1185
+ \`\`\`
1186
+
1187
+ Recommendation: ${recommendation}
1188
+ ${rationale ? `Rationale: ${rationale}` : ''}
1189
+
1190
+ Return ONLY a JSON object with this exact structure (no markdown, no explanation):
1191
+ {
1192
+ "summary": "brief description of what changed",
1193
+ "afterContent": "the complete updated file content",
1194
+ "risk": "low|medium|high"
1195
+ }`;
1196
+ const response = await ai.complete(prompt, { maxTokens: 4096 });
1197
+ const parsed = JSON.parse(response.trim());
1198
+ res.json({
1199
+ targetType,
1200
+ targetName,
1201
+ filePath,
1202
+ before: currentContent,
1203
+ after: parsed.afterContent,
1204
+ summary: parsed.summary,
1205
+ risk: parsed.risk || 'medium',
1206
+ recommendation,
1207
+ rationale: rationale || null,
1208
+ });
1209
+ }
1210
+ catch (err) {
1211
+ logger.warn(`[Web] Patch preview failed: ${err}`);
1212
+ res.status(500).json({ error: String(err) });
1213
+ }
1214
+ });
1215
+ // POST /api/patch/apply — apply patch with backup
1216
+ this.app.post('/api/patch/apply', (req, res) => {
1217
+ const { targetType, targetName, afterContent } = req.body ?? {};
1218
+ if (!targetType || !targetName || typeof afterContent !== 'string') {
1219
+ res.status(400).json({ error: 'targetType, targetName, and afterContent are required' });
1220
+ return;
1221
+ }
1222
+ try {
1223
+ const { filePath, backupDir } = resolvePatchTarget(targetType, targetName);
1224
+ if (!fs.existsSync(filePath)) {
1225
+ res.status(404).json({ error: `Target file not found: ${filePath}` });
1226
+ return;
1227
+ }
1228
+ // Create backup
1229
+ if (!fs.existsSync(backupDir)) {
1230
+ fs.mkdirSync(backupDir, { recursive: true });
1231
+ }
1232
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1233
+ const backupName = `${targetName}_${timestamp}${path.extname(filePath)}`;
1234
+ const backupPath = path.join(backupDir, backupName);
1235
+ fs.copyFileSync(filePath, backupPath);
1236
+ // Apply patch
1237
+ fs.writeFileSync(filePath, afterContent, 'utf-8');
1238
+ logger.info(`[Web] Patch applied to ${targetType}/${targetName}, backup: ${backupPath}`);
1239
+ res.json({
1240
+ success: true,
1241
+ targetType,
1242
+ targetName,
1243
+ filePath,
1244
+ backupPath,
1245
+ timestamp,
1246
+ });
1247
+ }
1248
+ catch (err) {
1249
+ logger.warn(`[Web] Patch apply failed: ${err}`);
1250
+ res.status(500).json({ error: String(err) });
1251
+ }
1252
+ });
844
1253
  // ── AI Configuration APIs ─────────────────────────────────────────────
845
1254
  // GET /api/config/ai — read current AI config (mask apiKey)
846
1255
  this.app.get('/api/config/ai', (_req, res) => {
@@ -1178,6 +1587,429 @@ export class WebServer {
1178
1587
  res.status(500).json({ error: String(err) });
1179
1588
  }
1180
1589
  });
1590
+ // ── Version Management APIs ────────────────────────────────────────────────
1591
+ // GET /api/agents/:name/versions — list backup versions
1592
+ this.app.get('/api/agents/:name/versions', (req, res) => {
1593
+ try {
1594
+ const { name } = req.params;
1595
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1596
+ if (!fs.existsSync(backupDir)) {
1597
+ res.json({ versions: [] });
1598
+ return;
1599
+ }
1600
+ const files = fs.readdirSync(backupDir)
1601
+ .filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
1602
+ .map(f => {
1603
+ const filePath = path.join(backupDir, f);
1604
+ const stats = fs.statSync(filePath);
1605
+ const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
1606
+ return {
1607
+ filename: f,
1608
+ timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
1609
+ size: stats.size,
1610
+ mtime: stats.mtime.toISOString(),
1611
+ };
1612
+ })
1613
+ .sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
1614
+ res.json({ versions: files });
1615
+ }
1616
+ catch (err) {
1617
+ logger.warn(`[Web] Failed to list agent versions: ${err}`);
1618
+ res.status(500).json({ error: String(err) });
1619
+ }
1620
+ });
1621
+ // GET /api/agents/:name/versions/:timestamp — get specific version content
1622
+ this.app.get('/api/agents/:name/versions/:timestamp', (req, res) => {
1623
+ try {
1624
+ const { name, timestamp } = req.params;
1625
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1626
+ const filename = `${name}-${timestamp}.md`;
1627
+ const filePath = path.join(backupDir, filename);
1628
+ if (!fs.existsSync(filePath)) {
1629
+ res.status(404).json({ error: 'Version not found' });
1630
+ return;
1631
+ }
1632
+ const content = fs.readFileSync(filePath, 'utf-8');
1633
+ res.json({ content });
1634
+ }
1635
+ catch (err) {
1636
+ logger.warn(`[Web] Failed to get agent version: ${err}`);
1637
+ res.status(500).json({ error: String(err) });
1638
+ }
1639
+ });
1640
+ // POST /api/agents/:name/rollback — rollback to a specific version
1641
+ this.app.post('/api/agents/:name/rollback', (req, res) => {
1642
+ try {
1643
+ const { name } = req.params;
1644
+ const { timestamp } = req.body;
1645
+ if (!timestamp) {
1646
+ res.status(400).json({ error: 'Missing timestamp' });
1647
+ return;
1648
+ }
1649
+ const agentsDir = path.join(homedir(), '.claude', 'agents');
1650
+ const currentPath = path.join(agentsDir, `${name}.md`);
1651
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1652
+ const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
1653
+ if (!fs.existsSync(currentPath)) {
1654
+ res.status(404).json({ error: 'Agent not found' });
1655
+ return;
1656
+ }
1657
+ if (!fs.existsSync(versionPath)) {
1658
+ res.status(404).json({ error: 'Version not found' });
1659
+ return;
1660
+ }
1661
+ // Backup current version before rollback
1662
+ fs.mkdirSync(backupDir, { recursive: true });
1663
+ const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
1664
+ const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
1665
+ fs.copyFileSync(currentPath, rollbackBackupPath);
1666
+ // Restore version
1667
+ const versionContent = fs.readFileSync(versionPath, 'utf-8');
1668
+ fs.writeFileSync(currentPath, versionContent, 'utf-8');
1669
+ logger.info(`[Web] Rolled back agent ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
1670
+ res.json({ success: true, backup: rollbackBackupPath });
1671
+ }
1672
+ catch (err) {
1673
+ logger.warn(`[Web] Failed to rollback agent: ${err}`);
1674
+ res.status(500).json({ error: String(err) });
1675
+ }
1676
+ });
1677
+ // GET /api/skills/:name/versions — list backup versions
1678
+ this.app.get('/api/skills/:name/versions', (req, res) => {
1679
+ try {
1680
+ const { name } = req.params;
1681
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1682
+ if (!fs.existsSync(backupDir)) {
1683
+ res.json({ versions: [] });
1684
+ return;
1685
+ }
1686
+ const files = fs.readdirSync(backupDir)
1687
+ .filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
1688
+ .map(f => {
1689
+ const filePath = path.join(backupDir, f);
1690
+ const stats = fs.statSync(filePath);
1691
+ const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
1692
+ return {
1693
+ filename: f,
1694
+ timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
1695
+ size: stats.size,
1696
+ mtime: stats.mtime.toISOString(),
1697
+ };
1698
+ })
1699
+ .sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
1700
+ res.json({ versions: files });
1701
+ }
1702
+ catch (err) {
1703
+ logger.warn(`[Web] Failed to list skill versions: ${err}`);
1704
+ res.status(500).json({ error: String(err) });
1705
+ }
1706
+ });
1707
+ // GET /api/skills/:name/versions/:timestamp — get specific version content
1708
+ this.app.get('/api/skills/:name/versions/:timestamp', (req, res) => {
1709
+ try {
1710
+ const { name, timestamp } = req.params;
1711
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1712
+ const filename = `${name}-${timestamp}.md`;
1713
+ const filePath = path.join(backupDir, filename);
1714
+ if (!fs.existsSync(filePath)) {
1715
+ res.status(404).json({ error: 'Version not found' });
1716
+ return;
1717
+ }
1718
+ const content = fs.readFileSync(filePath, 'utf-8');
1719
+ res.json({ content });
1720
+ }
1721
+ catch (err) {
1722
+ logger.warn(`[Web] Failed to get skill version: ${err}`);
1723
+ res.status(500).json({ error: String(err) });
1724
+ }
1725
+ });
1726
+ // POST /api/skills/:name/rollback — rollback to a specific version
1727
+ this.app.post('/api/skills/:name/rollback', (req, res) => {
1728
+ try {
1729
+ const { name } = req.params;
1730
+ const { timestamp } = req.body;
1731
+ if (!timestamp) {
1732
+ res.status(400).json({ error: 'Missing timestamp' });
1733
+ return;
1734
+ }
1735
+ const skillsDir = path.join(homedir(), '.claude', 'skills');
1736
+ const currentPath = path.join(skillsDir, `${name}.md`);
1737
+ const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1738
+ const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
1739
+ if (!fs.existsSync(currentPath)) {
1740
+ res.status(404).json({ error: 'Skill not found' });
1741
+ return;
1742
+ }
1743
+ if (!fs.existsSync(versionPath)) {
1744
+ res.status(404).json({ error: 'Version not found' });
1745
+ return;
1746
+ }
1747
+ // Backup current version before rollback
1748
+ fs.mkdirSync(backupDir, { recursive: true });
1749
+ const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
1750
+ const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
1751
+ fs.copyFileSync(currentPath, rollbackBackupPath);
1752
+ // Restore version
1753
+ const versionContent = fs.readFileSync(versionPath, 'utf-8');
1754
+ fs.writeFileSync(currentPath, versionContent, 'utf-8');
1755
+ logger.info(`[Web] Rolled back skill ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
1756
+ res.json({ success: true, backup: rollbackBackupPath });
1757
+ }
1758
+ catch (err) {
1759
+ logger.warn(`[Web] Failed to rollback skill: ${err}`);
1760
+ res.status(500).json({ error: String(err) });
1761
+ }
1762
+ });
1763
+ // ── Execution Trace APIs ──────────────────────────────────────────────────
1764
+ // GET /api/execution-trace — list execution traces with routing info
1765
+ this.app.get('/api/execution-trace', (req, res) => {
1766
+ try {
1767
+ const limit = parseInt(req.query.limit) || 50;
1768
+ const agentFilter = req.query.agent;
1769
+ const obeyedFilter = req.query.obeyed;
1770
+ const db = storage.getDatabase();
1771
+ let sql = `
1772
+ SELECT
1773
+ re.id,
1774
+ re.session_id,
1775
+ re.project_path,
1776
+ re.ts,
1777
+ re.prompt,
1778
+ re.intent_json,
1779
+ re.routed_to_type,
1780
+ re.routed_to_name,
1781
+ re.obeyed,
1782
+ re.classification_ms,
1783
+ re.fallback_used,
1784
+ re.first_tool_name,
1785
+ re.first_tool_ts,
1786
+ re.completed_ts,
1787
+ re.total_execution_ms,
1788
+ re.completion_reason,
1789
+ re.downstream_task_chain,
1790
+ s.status as session_status,
1791
+ s.start_time,
1792
+ s.end_time,
1793
+ s.event_count
1794
+ FROM routing_events re
1795
+ LEFT JOIN sessions s ON re.session_id = s.session_id
1796
+ WHERE 1=1
1797
+ `;
1798
+ const params = [];
1799
+ if (agentFilter) {
1800
+ sql += ` AND re.routed_to_name = ?`;
1801
+ params.push(agentFilter);
1802
+ }
1803
+ if (obeyedFilter === 'true') {
1804
+ sql += ` AND re.obeyed = 1`;
1805
+ }
1806
+ else if (obeyedFilter === 'false') {
1807
+ sql += ` AND re.obeyed = 0`;
1808
+ }
1809
+ sql += ` ORDER BY re.ts DESC LIMIT ?`;
1810
+ params.push(limit);
1811
+ const rows = db.prepare(sql).all(...params);
1812
+ const traces = rows.map(row => ({
1813
+ id: row.id,
1814
+ sessionId: row.session_id,
1815
+ projectPath: row.project_path,
1816
+ timestamp: row.ts,
1817
+ prompt: row.prompt,
1818
+ intent: JSON.parse(row.intent_json || '{}'),
1819
+ routedToType: row.routed_to_type,
1820
+ routedToName: row.routed_to_name,
1821
+ obeyed: row.obeyed,
1822
+ classificationMs: row.classification_ms,
1823
+ fallbackUsed: row.fallback_used === 1,
1824
+ firstTool: row.first_tool_name,
1825
+ firstToolTs: row.first_tool_ts,
1826
+ completedTs: row.completed_ts,
1827
+ totalExecutionMs: row.total_execution_ms,
1828
+ completionReason: row.completion_reason,
1829
+ taskChain: row.downstream_task_chain ? JSON.parse(row.downstream_task_chain) : [],
1830
+ sessionStatus: row.session_status,
1831
+ sessionStart: row.start_time,
1832
+ sessionEnd: row.end_time,
1833
+ eventCount: row.event_count,
1834
+ }));
1835
+ res.json({ traces });
1836
+ }
1837
+ catch (err) {
1838
+ logger.warn(`[Web] Failed to get execution traces: ${err}`);
1839
+ res.status(500).json({ error: String(err) });
1840
+ }
1841
+ });
1842
+ // GET /api/execution-trace/:session_id — detailed trace for a session
1843
+ this.app.get('/api/execution-trace/:session_id', (req, res) => {
1844
+ try {
1845
+ const { session_id } = req.params;
1846
+ const db = storage.getDatabase();
1847
+ // Get routing event
1848
+ const routing = db.prepare(`
1849
+ SELECT * FROM routing_events WHERE session_id = ?
1850
+ `).get(session_id);
1851
+ if (!routing) {
1852
+ res.status(404).json({ error: 'Trace not found' });
1853
+ return;
1854
+ }
1855
+ // Get all events for this session
1856
+ const events = db.prepare(`
1857
+ SELECT event_id, timestamp, hook_type, tool_name, user_prompt
1858
+ FROM events
1859
+ WHERE session_id = ?
1860
+ ORDER BY timestamp ASC
1861
+ `).all(session_id);
1862
+ // Get injections
1863
+ const injections = db.prepare(`
1864
+ SELECT injection_type, content, timestamp
1865
+ FROM injections
1866
+ WHERE session_id = ?
1867
+ ORDER BY timestamp ASC
1868
+ `).all(session_id);
1869
+ res.json({
1870
+ routing: {
1871
+ prompt: routing.prompt,
1872
+ timestamp: routing.ts,
1873
+ intent: JSON.parse(routing.intent_json || '{}'),
1874
+ routedToType: routing.routed_to_type,
1875
+ routedToName: routing.routed_to_name,
1876
+ obeyed: routing.obeyed,
1877
+ classificationMs: routing.classification_ms,
1878
+ fallbackUsed: routing.fallback_used === 1,
1879
+ refusalReason: routing.refusal_reason,
1880
+ firstTool: routing.first_tool_name,
1881
+ firstToolTs: routing.first_tool_ts,
1882
+ completedTs: routing.completed_ts,
1883
+ totalExecutionMs: routing.total_execution_ms,
1884
+ completionReason: routing.completion_reason,
1885
+ taskChain: routing.downstream_task_chain ? JSON.parse(routing.downstream_task_chain) : [],
1886
+ },
1887
+ events,
1888
+ injections,
1889
+ });
1890
+ }
1891
+ catch (err) {
1892
+ logger.warn(`[Web] Failed to get trace details for ${req.params.session_id}: ${err}`);
1893
+ res.status(500).json({ error: String(err) });
1894
+ }
1895
+ });
1896
+ // GET /api/execution-trace/:session_id/status — current status snapshot
1897
+ this.app.get('/api/execution-trace/:session_id/status', (req, res) => {
1898
+ try {
1899
+ const { session_id } = req.params;
1900
+ const db = storage.getDatabase();
1901
+ const routing = db.prepare(`SELECT * FROM routing_events WHERE session_id = ? ORDER BY ts DESC LIMIT 1`).get(session_id);
1902
+ if (!routing) {
1903
+ res.status(404).json({ error: 'Trace not found' });
1904
+ return;
1905
+ }
1906
+ const latestToolEvent = db.prepare(`
1907
+ SELECT tool, success, error, timestamp
1908
+ FROM v2_tool_events
1909
+ WHERE session_id = ? AND timestamp >= ?
1910
+ ORDER BY timestamp DESC LIMIT 1
1911
+ `).get(session_id, routing.ts);
1912
+ const latestDecision = db.prepare(`
1913
+ SELECT level, reason, timestamp
1914
+ FROM v2_decisions
1915
+ WHERE session_id = ? AND timestamp >= ?
1916
+ ORDER BY timestamp DESC LIMIT 1
1917
+ `).get(session_id, routing.ts);
1918
+ let status = 'routing';
1919
+ if (latestDecision?.level === 'block' || latestToolEvent?.success === 0)
1920
+ status = 'failed';
1921
+ else if (routing.completed_ts)
1922
+ status = 'completed';
1923
+ else if (routing.first_tool_ts || latestToolEvent)
1924
+ status = 'executing';
1925
+ res.json({
1926
+ status,
1927
+ timestamp: routing.ts,
1928
+ firstTool: routing.first_tool_name ?? latestToolEvent?.tool ?? null,
1929
+ firstToolTs: routing.first_tool_ts ?? latestToolEvent?.timestamp ?? null,
1930
+ completedTs: routing.completed_ts ?? null,
1931
+ totalExecutionMs: routing.total_execution_ms ?? null,
1932
+ completionReason: routing.completion_reason ?? null,
1933
+ error: latestToolEvent?.success === 0 ? latestToolEvent.error : (latestDecision?.level === 'block' ? latestDecision.reason : null),
1934
+ });
1935
+ }
1936
+ catch (err) {
1937
+ logger.warn(`[Web] Failed to get trace status for ${req.params.session_id}: ${err}`);
1938
+ res.status(500).json({ error: String(err) });
1939
+ }
1940
+ });
1941
+ // SSE: execution trace live status stream
1942
+ this.app.get('/api/execution-trace/stream', (req, res) => {
1943
+ res.writeHead(200, {
1944
+ 'Content-Type': 'text/event-stream',
1945
+ 'Cache-Control': 'no-cache',
1946
+ Connection: 'keep-alive',
1947
+ });
1948
+ res.write('data: {"type":"connected"}\n\n');
1949
+ const filterSession = req.query.session;
1950
+ const readOnlyTools = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
1951
+ const writeStatus = (payload) => {
1952
+ if (filterSession && payload.sessionId !== filterSession)
1953
+ return;
1954
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
1955
+ };
1956
+ const onEvent = (event) => {
1957
+ const hookType = event.hook_type;
1958
+ const toolName = event.tool_name;
1959
+ const sessionId = event.session_id;
1960
+ const timestamp = event.timestamp;
1961
+ if (!sessionId || !hookType)
1962
+ return;
1963
+ if (hookType === 'UserPromptSubmit') {
1964
+ writeStatus({ type: 'execution-status', status: 'routing', sessionId, timestamp, prompt: event.user_prompt ?? null });
1965
+ return;
1966
+ }
1967
+ if (hookType === 'PreToolUse' && toolName && !readOnlyTools.has(toolName)) {
1968
+ writeStatus({ type: 'execution-status', status: 'executing', sessionId, timestamp, tool: toolName });
1969
+ return;
1970
+ }
1971
+ if (hookType === 'Stop') {
1972
+ writeStatus({ type: 'execution-status', status: 'completed', sessionId, timestamp });
1973
+ }
1974
+ };
1975
+ const onToolEvent = (event) => {
1976
+ const sessionId = event.session_id;
1977
+ if (!sessionId)
1978
+ return;
1979
+ if (event.success === false || event.success === 0) {
1980
+ writeStatus({
1981
+ type: 'execution-status',
1982
+ status: 'failed',
1983
+ sessionId,
1984
+ timestamp: event.timestamp ?? Date.now(),
1985
+ tool: event.tool ?? null,
1986
+ error: event.error ?? null,
1987
+ });
1988
+ }
1989
+ };
1990
+ const onDecision = (decision) => {
1991
+ const sessionId = decision.session_id;
1992
+ if (!sessionId)
1993
+ return;
1994
+ if (decision.level === 'block') {
1995
+ writeStatus({
1996
+ type: 'execution-status',
1997
+ status: 'failed',
1998
+ sessionId,
1999
+ timestamp: decision.timestamp ?? Date.now(),
2000
+ error: decision.reason ?? null,
2001
+ });
2002
+ }
2003
+ };
2004
+ storage.on('event', onEvent);
2005
+ storage.on('tool-event', onToolEvent);
2006
+ storage.on('decision', onDecision);
2007
+ req.on('close', () => {
2008
+ storage.removeListener('event', onEvent);
2009
+ storage.removeListener('tool-event', onToolEvent);
2010
+ storage.removeListener('decision', onDecision);
2011
+ });
2012
+ });
1181
2013
  // SSE: real-time event stream
1182
2014
  this.app.get('/api/events/stream', (req, res) => {
1183
2015
  res.writeHead(200, {