@winspan/claude-forge 8.19.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 +711 -0
  33. package/dist/web/server.js.map +1 -1
  34. package/dist/web/static/index.html +566 -1
  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,179 @@ 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
+ });
1181
1763
  // ── Execution Trace APIs ──────────────────────────────────────────────────
1182
1764
  // GET /api/execution-trace — list execution traces with routing info
1183
1765
  this.app.get('/api/execution-trace', (req, res) => {
@@ -1200,6 +1782,10 @@ export class WebServer {
1200
1782
  re.classification_ms,
1201
1783
  re.fallback_used,
1202
1784
  re.first_tool_name,
1785
+ re.first_tool_ts,
1786
+ re.completed_ts,
1787
+ re.total_execution_ms,
1788
+ re.completion_reason,
1203
1789
  re.downstream_task_chain,
1204
1790
  s.status as session_status,
1205
1791
  s.start_time,
@@ -1236,6 +1822,10 @@ export class WebServer {
1236
1822
  classificationMs: row.classification_ms,
1237
1823
  fallbackUsed: row.fallback_used === 1,
1238
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,
1239
1829
  taskChain: row.downstream_task_chain ? JSON.parse(row.downstream_task_chain) : [],
1240
1830
  sessionStatus: row.session_status,
1241
1831
  sessionStart: row.start_time,
@@ -1279,6 +1869,7 @@ export class WebServer {
1279
1869
  res.json({
1280
1870
  routing: {
1281
1871
  prompt: routing.prompt,
1872
+ timestamp: routing.ts,
1282
1873
  intent: JSON.parse(routing.intent_json || '{}'),
1283
1874
  routedToType: routing.routed_to_type,
1284
1875
  routedToName: routing.routed_to_name,
@@ -1288,6 +1879,9 @@ export class WebServer {
1288
1879
  refusalReason: routing.refusal_reason,
1289
1880
  firstTool: routing.first_tool_name,
1290
1881
  firstToolTs: routing.first_tool_ts,
1882
+ completedTs: routing.completed_ts,
1883
+ totalExecutionMs: routing.total_execution_ms,
1884
+ completionReason: routing.completion_reason,
1291
1885
  taskChain: routing.downstream_task_chain ? JSON.parse(routing.downstream_task_chain) : [],
1292
1886
  },
1293
1887
  events,
@@ -1299,6 +1893,123 @@ export class WebServer {
1299
1893
  res.status(500).json({ error: String(err) });
1300
1894
  }
1301
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
+ });
1302
2013
  // SSE: real-time event stream
1303
2014
  this.app.get('/api/events/stream', (req, res) => {
1304
2015
  res.writeHead(200, {