@winspan/claude-forge 8.30.0 → 8.33.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 (195) hide show
  1. package/README.md +4 -4
  2. package/dist/capability/execution-manager.d.ts +38 -1
  3. package/dist/capability/execution-manager.d.ts.map +1 -1
  4. package/dist/capability/execution-manager.js +93 -1
  5. package/dist/capability/execution-manager.js.map +1 -1
  6. package/dist/capability/executor/background-executor.d.ts +1 -0
  7. package/dist/capability/executor/background-executor.d.ts.map +1 -1
  8. package/dist/capability/executor/background-executor.js +27 -4
  9. package/dist/capability/executor/background-executor.js.map +1 -1
  10. package/dist/capability/executor/orchestrator.d.ts +15 -2
  11. package/dist/capability/executor/orchestrator.d.ts.map +1 -1
  12. package/dist/capability/executor/orchestrator.js +82 -3
  13. package/dist/capability/executor/orchestrator.js.map +1 -1
  14. package/dist/capability/executor/worker-auth-probe.d.ts.map +1 -1
  15. package/dist/capability/executor/worker-auth-probe.js +11 -2
  16. package/dist/capability/executor/worker-auth-probe.js.map +1 -1
  17. package/dist/capability/methodologies/bmad.yaml +17 -5
  18. package/dist/capability/methodologies/code-quality-audit.yaml +26 -0
  19. package/dist/capability/methodologies/harness-engineering.yaml +12 -6
  20. package/dist/capability/methodologies/test-coverage-scan.yaml +26 -0
  21. package/dist/capability/methodology-planner.d.ts +17 -1
  22. package/dist/capability/methodology-planner.d.ts.map +1 -1
  23. package/dist/capability/methodology-planner.js +125 -0
  24. package/dist/capability/methodology-planner.js.map +1 -1
  25. package/dist/capability/methodology-registry.d.ts.map +1 -1
  26. package/dist/capability/methodology-registry.js +21 -5
  27. package/dist/capability/methodology-registry.js.map +1 -1
  28. package/dist/capability/types.d.ts +2 -0
  29. package/dist/capability/types.d.ts.map +1 -1
  30. package/dist/core/ai/provider.d.ts +17 -9
  31. package/dist/core/ai/provider.d.ts.map +1 -1
  32. package/dist/core/ai/provider.js +130 -23
  33. package/dist/core/ai/provider.js.map +1 -1
  34. package/dist/core/ai/types.d.ts +26 -5
  35. package/dist/core/ai/types.d.ts.map +1 -1
  36. package/dist/core/storage/rows.d.ts +153 -0
  37. package/dist/core/storage/rows.d.ts.map +1 -0
  38. package/dist/core/storage/rows.js +14 -0
  39. package/dist/core/storage/rows.js.map +1 -0
  40. package/dist/core/storage/schema.sql +26 -2
  41. package/dist/core/storage/sqlite.d.ts +95 -7
  42. package/dist/core/storage/sqlite.d.ts.map +1 -1
  43. package/dist/core/storage/sqlite.js +409 -22
  44. package/dist/core/storage/sqlite.js.map +1 -1
  45. package/dist/core/utils/token-tracker.d.ts +40 -0
  46. package/dist/core/utils/token-tracker.d.ts.map +1 -0
  47. package/dist/core/utils/token-tracker.js +70 -0
  48. package/dist/core/utils/token-tracker.js.map +1 -0
  49. package/dist/daemon/handlers/post-tool-use.d.ts +1 -0
  50. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  51. package/dist/daemon/handlers/post-tool-use.js +7 -0
  52. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  53. package/dist/daemon/handlers/stop.d.ts +11 -0
  54. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  55. package/dist/daemon/handlers/stop.js +52 -0
  56. package/dist/daemon/handlers/stop.js.map +1 -1
  57. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  58. package/dist/daemon/handlers/user-prompt.js +63 -4
  59. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  60. package/dist/daemon/idle-detector.d.ts +35 -0
  61. package/dist/daemon/idle-detector.d.ts.map +1 -0
  62. package/dist/daemon/idle-detector.js +56 -0
  63. package/dist/daemon/idle-detector.js.map +1 -0
  64. package/dist/daemon/idle-trigger.d.ts +53 -0
  65. package/dist/daemon/idle-trigger.d.ts.map +1 -0
  66. package/dist/daemon/idle-trigger.js +153 -0
  67. package/dist/daemon/idle-trigger.js.map +1 -0
  68. package/dist/daemon/index.d.ts.map +1 -1
  69. package/dist/daemon/index.js +30 -2
  70. package/dist/daemon/index.js.map +1 -1
  71. package/dist/daemon/routing-observer.d.ts +2 -1
  72. package/dist/daemon/routing-observer.d.ts.map +1 -1
  73. package/dist/daemon/routing-observer.js +117 -39
  74. package/dist/daemon/routing-observer.js.map +1 -1
  75. package/dist/engine/agent-router.d.ts +6 -0
  76. package/dist/engine/agent-router.d.ts.map +1 -1
  77. package/dist/engine/agent-router.js +13 -1
  78. package/dist/engine/agent-router.js.map +1 -1
  79. package/dist/engine/conventions/routing.yaml +15 -0
  80. package/dist/engine/dsl/compiler.d.ts.map +1 -1
  81. package/dist/engine/dsl/compiler.js +85 -3
  82. package/dist/engine/dsl/compiler.js.map +1 -1
  83. package/dist/engine/recommender.d.ts.map +1 -1
  84. package/dist/engine/recommender.js +10 -1
  85. package/dist/engine/recommender.js.map +1 -1
  86. package/dist/intelligence/classifier.d.ts +6 -0
  87. package/dist/intelligence/classifier.d.ts.map +1 -1
  88. package/dist/intelligence/classifier.js +57 -0
  89. package/dist/intelligence/classifier.js.map +1 -1
  90. package/dist/skills/registry.d.ts +6 -0
  91. package/dist/skills/registry.d.ts.map +1 -1
  92. package/dist/skills/registry.js +49 -14
  93. package/dist/skills/registry.js.map +1 -1
  94. package/dist/skills/semantic-matcher.d.ts +1 -0
  95. package/dist/skills/semantic-matcher.d.ts.map +1 -1
  96. package/dist/skills/semantic-matcher.js +6 -1
  97. package/dist/skills/semantic-matcher.js.map +1 -1
  98. package/dist/web/auth-middleware.d.ts +22 -0
  99. package/dist/web/auth-middleware.d.ts.map +1 -0
  100. package/dist/web/auth-middleware.js +51 -0
  101. package/dist/web/auth-middleware.js.map +1 -0
  102. package/dist/web/routes/agents.d.ts +7 -0
  103. package/dist/web/routes/agents.d.ts.map +1 -0
  104. package/dist/web/routes/agents.js +192 -0
  105. package/dist/web/routes/agents.js.map +1 -0
  106. package/dist/web/routes/ai.d.ts +10 -0
  107. package/dist/web/routes/ai.d.ts.map +1 -0
  108. package/dist/web/routes/ai.js +197 -0
  109. package/dist/web/routes/ai.js.map +1 -0
  110. package/dist/web/routes/auth.d.ts +12 -0
  111. package/dist/web/routes/auth.d.ts.map +1 -0
  112. package/dist/web/routes/auth.js +20 -0
  113. package/dist/web/routes/auth.js.map +1 -0
  114. package/dist/web/routes/events.d.ts +11 -0
  115. package/dist/web/routes/events.d.ts.map +1 -0
  116. package/dist/web/routes/events.js +43 -0
  117. package/dist/web/routes/events.js.map +1 -0
  118. package/dist/web/routes/execution-trace.d.ts +13 -0
  119. package/dist/web/routes/execution-trace.d.ts.map +1 -0
  120. package/dist/web/routes/execution-trace.js +308 -0
  121. package/dist/web/routes/execution-trace.js.map +1 -0
  122. package/dist/web/routes/experiments.d.ts +15 -0
  123. package/dist/web/routes/experiments.d.ts.map +1 -0
  124. package/dist/web/routes/experiments.js +187 -0
  125. package/dist/web/routes/experiments.js.map +1 -0
  126. package/dist/web/routes/methodology.d.ts +12 -0
  127. package/dist/web/routes/methodology.d.ts.map +1 -0
  128. package/dist/web/routes/methodology.js +228 -0
  129. package/dist/web/routes/methodology.js.map +1 -0
  130. package/dist/web/routes/patch.d.ts +7 -0
  131. package/dist/web/routes/patch.d.ts.map +1 -0
  132. package/dist/web/routes/patch.js +106 -0
  133. package/dist/web/routes/patch.js.map +1 -0
  134. package/dist/web/routes/routing.d.ts +17 -0
  135. package/dist/web/routes/routing.d.ts.map +1 -0
  136. package/dist/web/routes/routing.js +582 -0
  137. package/dist/web/routes/routing.js.map +1 -0
  138. package/dist/web/routes/rules.d.ts +7 -0
  139. package/dist/web/routes/rules.d.ts.map +1 -0
  140. package/dist/web/routes/rules.js +105 -0
  141. package/dist/web/routes/rules.js.map +1 -0
  142. package/dist/web/routes/sessions.d.ts +10 -0
  143. package/dist/web/routes/sessions.d.ts.map +1 -0
  144. package/dist/web/routes/sessions.js +234 -0
  145. package/dist/web/routes/sessions.js.map +1 -0
  146. package/dist/web/routes/skills.d.ts +10 -0
  147. package/dist/web/routes/skills.d.ts.map +1 -0
  148. package/dist/web/routes/skills.js +272 -0
  149. package/dist/web/routes/skills.js.map +1 -0
  150. package/dist/web/routes/static.d.ts +19 -0
  151. package/dist/web/routes/static.d.ts.map +1 -0
  152. package/dist/web/routes/static.js +61 -0
  153. package/dist/web/routes/static.js.map +1 -0
  154. package/dist/web/routes/status.d.ts +7 -0
  155. package/dist/web/routes/status.d.ts.map +1 -0
  156. package/dist/web/routes/status.js +28 -0
  157. package/dist/web/routes/status.js.map +1 -0
  158. package/dist/web/routes/token-usage.d.ts +7 -0
  159. package/dist/web/routes/token-usage.d.ts.map +1 -0
  160. package/dist/web/routes/token-usage.js +33 -0
  161. package/dist/web/routes/token-usage.js.map +1 -0
  162. package/dist/web/routes/types.d.ts +40 -0
  163. package/dist/web/routes/types.d.ts.map +1 -0
  164. package/dist/web/routes/types.js +52 -0
  165. package/dist/web/routes/types.js.map +1 -0
  166. package/dist/web/server.d.ts +7 -4
  167. package/dist/web/server.d.ts.map +1 -1
  168. package/dist/web/server.js +60 -2330
  169. package/dist/web/server.js.map +1 -1
  170. package/dist/web/ssrf-guard.d.ts +35 -0
  171. package/dist/web/ssrf-guard.d.ts.map +1 -0
  172. package/dist/web/ssrf-guard.js +93 -0
  173. package/dist/web/ssrf-guard.js.map +1 -0
  174. package/dist/web/static/assets/{AIConfig-nZgwaowr.js → AIConfig-D-vrYoJ3.js} +2 -2
  175. package/dist/web/static/assets/{AIConfig-nZgwaowr.js.map → AIConfig-D-vrYoJ3.js.map} +1 -1
  176. package/dist/web/static/assets/{Agents-BZGXKWC7.js → Agents-DAGWYsJj.js} +2 -2
  177. package/dist/web/static/assets/{Agents-BZGXKWC7.js.map → Agents-DAGWYsJj.js.map} +1 -1
  178. package/dist/web/static/assets/{Events-CnA3f740.js → Events-BoQ8Fo5k.js} +2 -2
  179. package/dist/web/static/assets/{Events-CnA3f740.js.map → Events-BoQ8Fo5k.js.map} +1 -1
  180. package/dist/web/static/assets/{ExecutionTrace-ClPfFIQa.js → ExecutionTrace-sFZ_vHNf.js} +2 -2
  181. package/dist/web/static/assets/{ExecutionTrace-ClPfFIQa.js.map → ExecutionTrace-sFZ_vHNf.js.map} +1 -1
  182. package/dist/web/static/assets/Methodologies-C0-Keokj.js +5 -0
  183. package/dist/web/static/assets/Methodologies-C0-Keokj.js.map +1 -0
  184. package/dist/web/static/assets/{Sessions-DwWOKgnl.js → Sessions-Bjf-Mvwb.js} +2 -2
  185. package/dist/web/static/assets/{Sessions-DwWOKgnl.js.map → Sessions-Bjf-Mvwb.js.map} +1 -1
  186. package/dist/web/static/assets/{Skills-DhM6ALhr.js → Skills-CrLshkrJ.js} +2 -2
  187. package/dist/web/static/assets/{Skills-DhM6ALhr.js.map → Skills-CrLshkrJ.js.map} +1 -1
  188. package/dist/web/static/assets/{index-DUYj2ek1.js → index-D23sAOAt.js} +3 -3
  189. package/dist/web/static/assets/{index-DUYj2ek1.js.map → index-D23sAOAt.js.map} +1 -1
  190. package/dist/web/static/assets/index-Drpf7sLl.css +1 -0
  191. package/dist/web/static/index.html +2 -2
  192. package/package.json +3 -2
  193. package/dist/web/static/assets/Methodologies-CAXUXeox.js +0 -2
  194. package/dist/web/static/assets/Methodologies-CAXUXeox.js.map +0 -1
  195. package/dist/web/static/assets/index-CVWult53.css +0 -1
@@ -1,2350 +1,80 @@
1
1
  /**
2
- * Web API server — lightweight Express server for Forge dashboard
2
+ * Web API server — Express entry for the Forge dashboard.
3
3
  *
4
- * 3 pages: Dashboard, Events, Rules
4
+ * server.ts is intentionally thin: it wires middleware, composes the route
5
+ * modules under src/web/routes/, and owns lifecycle (start/stop). Each route
6
+ * module is responsible for its own domain; see src/web/routes/types.ts for
7
+ * the shared RouteContext.
5
8
  */
6
9
  import express from 'express';
7
- import fs from 'fs';
8
- import path from 'path';
9
- import { fileURLToPath } from 'url';
10
- import { homedir } from 'os';
11
- import yaml from 'js-yaml';
12
- import { WorkerAuthError } from '../capability/executor/worker-auth-probe.js';
13
- import { Recommender } from '../engine/recommender.js';
14
10
  import { logger } from '../core/utils/logger.js';
15
11
  import { ErrorHandler } from '../core/utils/error-handler.js';
16
- import { ConfigManager } from '../core/config.js';
17
- import { ClaudeProvider } from '../core/ai/provider.js';
18
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ import { requireAuth } from './auth-middleware.js';
13
+ import { registerAuthRoutes } from './routes/auth.js';
14
+ import { registerStaticAssets, registerStaticFallback } from './routes/static.js';
15
+ import { registerStatusRoutes } from './routes/status.js';
16
+ import { registerEventsRoutes } from './routes/events.js';
17
+ import { registerSessionsRoutes } from './routes/sessions.js';
18
+ import { registerRulesRoutes } from './routes/rules.js';
19
+ import { registerRoutingRoutes } from './routes/routing.js';
20
+ import { registerExperimentsRoutes } from './routes/experiments.js';
21
+ import { registerMethodologyRoutes } from './routes/methodology.js';
22
+ import { registerTokenUsageRoutes } from './routes/token-usage.js';
23
+ import { registerExecutionTraceRoutes } from './routes/execution-trace.js';
24
+ import { registerAIRoutes } from './routes/ai.js';
25
+ import { registerPatchRoutes } from './routes/patch.js';
26
+ import { registerAgentsRoutes } from './routes/agents.js';
27
+ import { registerSkillsRoutes } from './routes/skills.js';
19
28
  export class WebServer {
20
29
  options;
21
30
  app;
22
31
  server = null;
23
- agents;
24
- router;
25
32
  constructor(options) {
26
33
  this.options = options;
27
34
  this.app = express();
28
35
  this.app.use(express.json());
29
- this.agents = options.agents;
30
- this.router = options.router;
31
36
  this.setupRoutes();
32
37
  }
33
38
  setupRoutes() {
34
- const { storage, ruleEngine } = this.options;
35
- const resolvePatchTarget = (targetType, targetName) => {
36
- if (targetType === 'agent') {
37
- return {
38
- filePath: path.join(homedir(), '.claude', 'agents', `${targetName}.md`),
39
- backupDir: path.join(homedir(), '.claude-forge', 'backups', 'agents'),
40
- };
41
- }
42
- if (targetType === 'skill') {
43
- return {
44
- filePath: path.join(homedir(), '.claude', 'skills', `${targetName}.md`),
45
- backupDir: path.join(homedir(), '.claude-forge', 'backups', 'skills'),
46
- };
47
- }
48
- if (targetType === 'routing_rule') {
49
- return {
50
- filePath: path.join(homedir(), '.claude-forge', 'routing.yaml'),
51
- backupDir: path.join(homedir(), '.claude-forge', 'backups', 'routing'),
52
- };
53
- }
54
- throw new Error(`Unsupported targetType: ${targetType}`);
39
+ // Auth gate: write operations (non-GET) always require Bearer token.
40
+ // GET read endpoints stay open for polling & read-only dashboards.
41
+ this.app.use('/api', (req, res, next) => {
42
+ if (req.method === 'GET')
43
+ return next();
44
+ return requireAuth(req, res, next);
45
+ });
46
+ const ctx = {
47
+ storage: this.options.storage,
48
+ ruleEngine: this.options.ruleEngine,
49
+ router: this.options.router,
50
+ agents: this.options.agents,
51
+ skillRegistry: this.options.skillRegistry,
52
+ executionManager: this.options.executionManager,
53
+ methodologyRegistry: this.options.methodologyRegistry,
54
+ methodologyPlanner: this.options.methodologyPlanner,
55
55
  };
56
- // Serve static files (support both dist/ and src/ layouts)
57
- const candidates = [
58
- path.join(__dirname, 'static'), // dist/web/static
59
- path.join(__dirname, '../../src/web/static'), // dev: src/web/static
60
- ];
61
- const staticDir = candidates.find(dir => fs.existsSync(dir));
62
- if (staticDir) {
63
- // Static files with content-hash in filename: long cache
64
- // index.html: no cache (always fetch latest)
65
- this.app.use(express.static(staticDir, {
66
- setHeaders: (res, filePath) => {
67
- if (filePath.endsWith('index.html')) {
68
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
69
- res.setHeader('Pragma', 'no-cache');
70
- res.setHeader('Expires', '0');
71
- }
72
- else if (filePath.includes('/assets/')) {
73
- // Vite assets are content-hashed, safe to cache forever
74
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
75
- }
76
- },
77
- }));
78
- this.app.get('/', (_req, res) => {
79
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
80
- res.sendFile('index.html', { root: staticDir });
81
- });
82
- logger.info(`[Web] Serving static files from ${staticDir}`);
83
- }
84
- else {
85
- logger.warn(`[Web] Static directory not found in: ${candidates.join(', ')}`);
86
- }
87
- // Dashboard: daemon status
88
- this.app.get('/api/status', (_req, res) => {
89
- res.json({
90
- pid: process.pid,
91
- uptime: process.uptime(),
92
- memory: process.memoryUsage(),
93
- eventCount: storage.countEvents({}),
94
- });
95
- });
96
- // Events: recent events
97
- this.app.get('/api/events', (req, res) => {
98
- const limit = parseInt(req.query.limit) || 50;
99
- const projectPath = req.query.project;
100
- const sessionId = req.query.session;
101
- const events = storage.queryEvents({ project_path: projectPath, session_id: sessionId, limit });
102
- res.json(events);
103
- });
104
- // Sessions: list with first prompt
105
- this.app.get('/api/sessions', (req, res) => {
106
- const limit = parseInt(req.query.limit) || 10;
107
- const projectPath = req.query.project;
108
- const sessions = storage.querySessions({ project_path: projectPath, limit });
109
- res.json(sessions);
110
- });
111
- // Injections: query injection history
112
- this.app.get('/api/injections', (req, res) => {
113
- const limit = parseInt(req.query.limit) || 50;
114
- const sessionId = req.query.session;
115
- const eventId = req.query.event;
116
- const handler = req.query.handler;
117
- const injections = storage.queryInjections({
118
- session_id: sessionId,
119
- event_id: eventId,
120
- source_handler: handler,
121
- limit
122
- });
123
- res.json(injections);
124
- });
125
- // Decisions: query governance decisions
126
- this.app.get('/api/decisions', (req, res) => {
127
- const limit = parseInt(req.query.limit) || 50;
128
- const sessionId = req.query.session;
129
- const decisions = storage.queryDecisions({ session_id: sessionId, limit });
130
- res.json(decisions);
131
- });
132
- // Session Detail: task-grouped session data
133
- this.app.get('/api/sessions/:sessionId/detail', (req, res) => {
134
- const sessionId = req.params.sessionId;
135
- const sessions = storage.querySessions({ limit: 1000 });
136
- const session = sessions.find(s => s.session_id === sessionId || s.session_id.startsWith(sessionId));
137
- if (!session) {
138
- res.status(404).json({ error: 'Session not found' });
139
- return;
140
- }
141
- const fullSessionId = session.session_id;
142
- const tasks = storage.queryTasks({ session_id: fullSessionId });
143
- const allEvents = storage.queryEvents({ session_id: fullSessionId, limit: 2000 });
144
- const allInjections = storage.queryInjections({ session_id: fullSessionId, limit: 200 });
145
- const allQualityIssues = storage.queryQualityIssues({ session_id: fullSessionId });
146
- const allDecisions = storage.queryDecisions({ session_id: fullSessionId });
147
- const buildTaskDetail = (events, injections) => {
148
- const prompts = events
149
- .filter(e => e.hook_type === 'UserPromptSubmit' && (e.user_prompt || e.tool_input?.user_prompt))
150
- .map(e => ({ timestamp: e.timestamp, content: e.user_prompt || e.tool_input?.user_prompt }));
151
- const toolUsage = {};
152
- events.forEach(e => { if (e.tool_name)
153
- toolUsage[e.tool_name] = (toolUsage[e.tool_name] || 0) + 1; });
154
- const filesChanged = [...new Set(events
155
- .filter(e => (e.tool_name === 'Edit' || e.tool_name === 'Write') && e.tool_input?.file_path)
156
- .map(e => e.tool_input.file_path))];
157
- const commits = events
158
- .filter(e => e.tool_name === 'Bash' && e.tool_input?.command?.includes('git commit'))
159
- .map(e => {
160
- const cmd = e.tool_input?.command || '';
161
- const match = cmd.match(/git commit.*-m\s+["']([^"']+)["']/);
162
- return { timestamp: e.timestamp, message: match ? match[1] : 'commit' };
163
- });
164
- return { prompts, events: events.slice(0, 200), injections, summary: { toolUsage, filesChanged, commits } };
165
- };
166
- if (tasks.length === 0) {
167
- const detail = buildTaskDetail(allEvents, allInjections);
168
- const virtualTask = {
169
- id: 'virtual',
170
- session_id: fullSessionId,
171
- title: session.first_prompt || '(未分类任务)',
172
- start_time: session.start_time,
173
- end_time: session.end_time,
174
- status: 'completed',
175
- event_count: allEvents.length,
176
- ...detail,
177
- qualityIssues: allQualityIssues,
178
- decisions: allDecisions,
179
- };
180
- res.json({ session, tasks: [virtualTask] });
181
- return;
182
- }
183
- const taskDetails = tasks.map(task => {
184
- const taskEventIds = new Set(storage.getTaskEventIds(task.id));
185
- const events = allEvents.filter(e => taskEventIds.has(e.event_id ?? ''));
186
- const injections = allInjections.filter(i => i.event_id && taskEventIds.has(i.event_id));
187
- const qualityIssues = allQualityIssues.filter(q => events.some(e => e.tool_input?.file_path === q.file_path));
188
- const decisions = allDecisions.filter(d => events.some(e => Math.abs(new Date(e.timestamp).getTime() - d.timestamp) < 5000));
189
- const detail = buildTaskDetail(events, injections);
190
- return { ...task, ...detail, qualityIssues, decisions };
191
- });
192
- res.json({ session, tasks: taskDetails });
193
- });
194
- // Session timeline: detailed event timeline for a session
195
- this.app.get('/api/sessions/:id/timeline', (req, res) => {
196
- const sessionIdPrefix = req.params.id;
197
- const limit = parseInt(req.query.limit) || 50;
198
- const offset = parseInt(req.query.offset) || 0;
199
- const sessions = storage.querySessions({ limit: 1000 });
200
- const session = sessions.find(s => s.session_id.startsWith(sessionIdPrefix));
201
- if (!session) {
202
- res.status(404).json({ error: 'Session not found' });
203
- return;
204
- }
205
- const fullSessionId = session.session_id;
206
- const events = storage.queryEvents({ session_id: fullSessionId, limit: 2000 });
207
- const injections = storage.queryInjections({ session_id: fullSessionId, limit: 200 });
208
- const qualityIssues = storage.queryQualityIssues({ session_id: fullSessionId, resolved: false });
209
- // Build timeline: merge events and injections, sort by timestamp
210
- const timeline = [];
211
- // User inputs
212
- events
213
- .filter(e => e.hook_type === 'UserPromptSubmit' && (e.user_prompt || e.tool_input?.user_prompt))
214
- .forEach(e => {
215
- timeline.push({
216
- timestamp: e.timestamp,
217
- type: 'user_input',
218
- data: { content: e.user_prompt || e.tool_input?.user_prompt },
219
- });
220
- });
221
- // Injections (truncate content for preview)
222
- injections.forEach(inj => {
223
- timeline.push({
224
- timestamp: inj.timestamp,
225
- type: 'injection',
226
- data: {
227
- source: inj.source_handler,
228
- injection_type: inj.injection_type,
229
- content: inj.content.slice(0, 500), // Truncate to 500 chars
230
- length: inj.content.length,
231
- truncated: inj.content.length > 500,
232
- },
233
- });
234
- });
235
- // Tool calls (truncate large fields)
236
- events
237
- .filter(e => e.tool_name && e.hook_type === 'PreToolUse')
238
- .forEach(e => {
239
- const toolInput = e.tool_input;
240
- const toolOutput = e.tool_output;
241
- // Truncate large fields
242
- const truncateField = (obj, maxLen = 200) => {
243
- if (!obj)
244
- return obj;
245
- if (typeof obj === 'string')
246
- return obj.length > maxLen ? obj.slice(0, maxLen) + '...' : obj;
247
- if (Array.isArray(obj))
248
- return obj.slice(0, 5);
249
- if (typeof obj === 'object') {
250
- const result = {};
251
- for (const [k, v] of Object.entries(obj)) {
252
- result[k] = truncateField(v, maxLen);
253
- }
254
- return result;
255
- }
256
- return obj;
257
- };
258
- timeline.push({
259
- timestamp: e.timestamp,
260
- type: 'tool_call',
261
- data: {
262
- tool: e.tool_name,
263
- input: truncateField(toolInput),
264
- output: truncateField(toolOutput),
265
- success: !toolOutput?.error,
266
- },
267
- });
268
- });
269
- // Quality checks (from PostToolUse events with quality gate results)
270
- events
271
- .filter(e => e.hook_type === 'PostToolUse' && e.tool_output?.quality_check)
272
- .forEach(e => {
273
- const qc = e.tool_output.quality_check;
274
- timeline.push({
275
- timestamp: e.timestamp,
276
- type: 'quality_check',
277
- data: qc,
278
- });
279
- });
280
- // Sort by timestamp
281
- timeline.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
282
- // Paginate
283
- const total = timeline.length;
284
- const paginatedTimeline = timeline.slice(offset, offset + limit);
285
- // Summary
286
- const commits = events
287
- .filter(e => e.tool_name === 'Bash' && e.tool_input?.command?.includes('git commit'))
288
- .map(e => {
289
- const cmd = e.tool_input?.command || '';
290
- const match = cmd.match(/git commit.*-m\s+["']([^"']+)["']/);
291
- return { message: match ? match[1] : 'commit' };
292
- });
293
- const filesChanged = [...new Set(events
294
- .filter(e => (e.tool_name === 'Edit' || e.tool_name === 'Write') && e.tool_input?.file_path)
295
- .map(e => e.tool_input.file_path))];
296
- res.json({
297
- session,
298
- timeline: paginatedTimeline,
299
- total,
300
- offset,
301
- limit,
302
- hasMore: offset + limit < total,
303
- summary: {
304
- commits,
305
- filesChanged,
306
- unresolvedIssues: qualityIssues,
307
- },
308
- });
309
- });
310
- // Rules: loaded conventions with statistics
311
- this.app.get('/api/rules', (_req, res) => {
312
- const conventions = ruleEngine.getConventions();
313
- const conventionsArray = Array.from(conventions.entries());
314
- // Add statistics for each convention
315
- const conventionsWithStats = conventionsArray.map(([conventionId, conv]) => {
316
- const decisions = storage.queryDecisions({ limit: 1000 });
317
- const ruleIds = [...(conv.forbidden || []), ...(conv.escalation || [])].map(r => r.id);
318
- const stats = {
319
- totalTriggers: decisions.filter(d => ruleIds.includes(d.rule_id || '')).length,
320
- blockCount: decisions.filter(d => ruleIds.includes(d.rule_id || '') && d.level === 'block').length,
321
- warnCount: decisions.filter(d => ruleIds.includes(d.rule_id || '') && d.level === 'warn').length,
322
- lastTriggered: decisions
323
- .filter(d => ruleIds.includes(d.rule_id || ''))
324
- .sort((a, b) => b.timestamp - a.timestamp)[0]?.timestamp || null,
325
- };
326
- return {
327
- id: conventionId,
328
- name: conv.name,
329
- version: conv.version,
330
- description: conv.description,
331
- stats,
332
- ruleCount: (conv.forbidden || []).length + (conv.escalation || []).length,
333
- };
334
- });
335
- res.json(conventionsWithStats);
336
- });
337
- // Rule detail: get specific rule with trigger history
338
- this.app.get('/api/rules/:conventionId', (req, res) => {
339
- const conventionId = req.params.conventionId;
340
- const conventions = ruleEngine.getConventions();
341
- const convention = conventions.get(conventionId);
342
- if (!convention) {
343
- res.status(404).json({ error: 'Convention not found' });
344
- return;
345
- }
346
- // Get all decisions for this convention's rules
347
- const ruleIds = [...(convention.forbidden || []), ...(convention.escalation || [])].map(r => r.id);
348
- const allDecisions = storage.queryDecisions({ limit: 500 });
349
- const decisions = allDecisions.filter(d => ruleIds.includes(d.rule_id || ''));
350
- // Group by rule
351
- const ruleStats = ruleIds.map(ruleId => {
352
- const ruleDecisions = decisions.filter(d => d.rule_id === ruleId);
353
- const rule = [...(convention.forbidden || []), ...(convention.escalation || [])].find(r => r.id === ruleId);
354
- return {
355
- ruleId,
356
- rule,
357
- triggerCount: ruleDecisions.length,
358
- blockCount: ruleDecisions.filter(d => d.level === 'block').length,
359
- warnCount: ruleDecisions.filter(d => d.level === 'warn').length,
360
- recentTriggers: ruleDecisions.slice(0, 10).map(d => ({
361
- timestamp: d.timestamp,
362
- level: d.level,
363
- reason: d.reason,
364
- })),
365
- };
366
- });
367
- res.json({
368
- convention,
369
- ruleStats,
370
- totalDecisions: decisions.length,
371
- });
372
- });
373
- // Statistics: overall system statistics
374
- this.app.get('/api/stats', (_req, res) => {
375
- const sessions = storage.querySessions({ limit: 1000 });
376
- const events = storage.queryEvents({ limit: 5000 });
377
- const decisions = storage.queryDecisions({ limit: 1000 });
378
- // Tool usage distribution
379
- const toolUsage = {};
380
- events.forEach(e => {
381
- if (e.tool_name) {
382
- toolUsage[e.tool_name] = (toolUsage[e.tool_name] || 0) + 1;
383
- }
384
- });
385
- // Decision level distribution
386
- const decisionLevels = {
387
- block: decisions.filter(d => d.level === 'block').length,
388
- warn: decisions.filter(d => d.level === 'warn').length,
389
- confirm: decisions.filter(d => d.level === 'confirm').length,
390
- allow: decisions.filter(d => d.level === 'allow').length,
391
- };
392
- // Daily activity (last 7 days, local dates to match DB-stored local ISO strings)
393
- const today = new Date();
394
- const dailyActivity = Array.from({ length: 7 }, (_, i) => {
395
- const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (6 - i));
396
- const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
397
- const dayEvents = events.filter(e => e.timestamp.slice(0, 10) === dateStr);
398
- return {
399
- date: dateStr,
400
- eventCount: dayEvents.length,
401
- sessionCount: new Set(dayEvents.map(e => e.session_id)).size,
402
- };
403
- });
404
- res.json({
405
- totalSessions: sessions.length,
406
- totalEvents: storage.countEvents({}),
407
- totalDecisions: decisions.length,
408
- toolUsage,
409
- decisionLevels,
410
- dailyActivity,
411
- });
412
- });
413
- // ── Agent Routing API ─────────────────────────────────────────────────
414
- // Overview stats for the Agent Routing page
415
- this.app.get('/api/routing/stats', (req, res) => {
416
- const windowHours = parseInt(req.query.window || '168'); // default 7d
417
- const since = Date.now() - windowHours * 3600 * 1000;
418
- const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
419
- const total = events.length;
420
- const byAgent = {};
421
- let forced = 0;
422
- let obeyedCount = 0;
423
- let refusedCount = 0;
424
- let unknownCount = 0;
425
- let fallbackUsedCount = 0;
426
- const latencies = [];
427
- const byVersion = {};
428
- for (const e of events) {
429
- if (e.is_forced)
430
- forced++;
431
- if (e.fallback_used)
432
- fallbackUsedCount++;
433
- if (typeof e.classification_ms === 'number')
434
- latencies.push(e.classification_ms);
435
- const key = e.routed_to_name ?? '—';
436
- const bucket = (byAgent[key] ||= { total: 0, obeyed: 0, refused: 0, unknown: 0 });
437
- bucket.total++;
438
- if (e.obeyed === 1) {
439
- bucket.obeyed++;
440
- obeyedCount++;
441
- }
442
- else if (e.obeyed === 0) {
443
- bucket.refused++;
444
- refusedCount++;
445
- }
446
- else {
447
- bucket.unknown++;
448
- unknownCount++;
449
- }
450
- const v = e.injection_version ?? '—';
451
- const vb = (byVersion[v] ||= { total: 0, obeyed: 0 });
452
- vb.total++;
453
- if (e.obeyed === 1)
454
- vb.obeyed++;
455
- }
456
- latencies.sort((a, b) => a - b);
457
- const p = (pct) => latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * pct))];
458
- res.json({
459
- windowHours,
460
- total,
461
- forced,
462
- obeyedCount,
463
- refusedCount,
464
- unknownCount,
465
- obedienceRate: forced === 0 ? null : obeyedCount / forced,
466
- refusalRate: forced === 0 ? null : refusedCount / forced,
467
- fallbackUsedCount,
468
- fallbackRate: total === 0 ? null : fallbackUsedCount / total,
469
- latency: {
470
- p50: p(0.5),
471
- p95: p(0.95),
472
- p99: p(0.99),
473
- count: latencies.length,
474
- },
475
- byAgent,
476
- byVersion,
477
- });
478
- });
479
- // Performance analysis for the Agent Routing page
480
- this.app.get('/api/routing/performance', (req, res) => {
481
- const windowHours = parseInt(req.query.window || '168');
482
- const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
483
- const since = Date.now() - windowHours * 3600 * 1000;
484
- const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
485
- const relevant = events.filter(e => e.routed_to_name);
486
- const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
487
- const obeyedCount = judged.filter(e => e.obeyed === 1).length;
488
- const refusedCount = judged.filter(e => e.obeyed === 0).length;
489
- const unknownCount = relevant.length - judged.length;
490
- const latencies = relevant
491
- .map(e => e.classification_ms)
492
- .filter((ms) => typeof ms === 'number');
493
- const executionLatencies = relevant
494
- .map(e => e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null))
495
- .filter((ms) => typeof ms === 'number');
496
- const avgClassificationMs = latencies.length === 0
497
- ? null
498
- : Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length);
499
- const avgExecutionMs = executionLatencies.length === 0
500
- ? null
501
- : Math.round(executionLatencies.reduce((sum, n) => sum + n, 0) / executionLatencies.length);
502
- const sortedExecution = [...executionLatencies].sort((a, b) => a - b);
503
- const p95ExecutionMs = sortedExecution.length === 0
504
- ? null
505
- : sortedExecution[Math.min(sortedExecution.length - 1, Math.floor(sortedExecution.length * 0.95))];
506
- const byAgentMap = new Map();
507
- const dailyMap = new Map();
508
- for (const e of relevant) {
509
- const agent = e.routed_to_name ?? '—';
510
- const agentBucket = byAgentMap.get(agent) ?? {
511
- agent,
512
- total: 0,
513
- judged: 0,
514
- obeyed: 0,
515
- refused: 0,
516
- unknown: 0,
517
- latencySum: 0,
518
- latencyCount: 0,
519
- executionSum: 0,
520
- executionCount: 0,
521
- };
522
- agentBucket.total++;
523
- if (e.obeyed === 1) {
524
- agentBucket.judged++;
525
- agentBucket.obeyed++;
526
- }
527
- else if (e.obeyed === 0) {
528
- agentBucket.judged++;
529
- agentBucket.refused++;
530
- }
531
- else {
532
- agentBucket.unknown++;
533
- }
534
- if (typeof e.classification_ms === 'number') {
535
- agentBucket.latencySum += e.classification_ms;
536
- agentBucket.latencyCount++;
537
- }
538
- const executionMs = e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null);
539
- if (typeof executionMs === 'number') {
540
- agentBucket.executionSum += executionMs;
541
- agentBucket.executionCount++;
542
- }
543
- byAgentMap.set(agent, agentBucket);
544
- const date = new Date(e.ts).toISOString().slice(0, 10);
545
- const dayBucket = dailyMap.get(date) ?? {
546
- date,
547
- total: 0,
548
- obeyed: 0,
549
- refused: 0,
550
- unknown: 0,
551
- latencySum: 0,
552
- latencyCount: 0,
553
- executionSum: 0,
554
- executionCount: 0,
555
- };
556
- dayBucket.total++;
557
- if (e.obeyed === 1)
558
- dayBucket.obeyed++;
559
- else if (e.obeyed === 0)
560
- dayBucket.refused++;
561
- else
562
- dayBucket.unknown++;
563
- if (typeof e.classification_ms === 'number') {
564
- dayBucket.latencySum += e.classification_ms;
565
- dayBucket.latencyCount++;
566
- }
567
- if (typeof executionMs === 'number') {
568
- dayBucket.executionSum += executionMs;
569
- dayBucket.executionCount++;
570
- }
571
- dailyMap.set(date, dayBucket);
572
- }
573
- const byAgent = Array.from(byAgentMap.values())
574
- .map(a => ({
575
- agent: a.agent,
576
- total: a.total,
577
- judged: a.judged,
578
- obeyed: a.obeyed,
579
- refused: a.refused,
580
- unknown: a.unknown,
581
- obedienceRate: a.judged === 0 ? null : a.obeyed / a.judged,
582
- refusalRate: a.judged === 0 ? null : a.refused / a.judged,
583
- avgClassificationMs: a.latencyCount === 0 ? null : Math.round(a.latencySum / a.latencyCount),
584
- avgExecutionMs: a.executionCount === 0 ? null : Math.round(a.executionSum / a.executionCount),
585
- }))
586
- .sort((a, b) => b.total - a.total);
587
- const dailyTrend = Array.from(dailyMap.values())
588
- .map(d => ({
589
- date: d.date,
590
- total: d.total,
591
- obeyed: d.obeyed,
592
- refused: d.refused,
593
- unknown: d.unknown,
594
- avgClassificationMs: d.latencyCount === 0 ? null : Math.round(d.latencySum / d.latencyCount),
595
- avgExecutionMs: d.executionCount === 0 ? null : Math.round(d.executionSum / d.executionCount),
596
- }))
597
- .sort((a, b) => a.date.localeCompare(b.date));
598
- const highRefusalAgents = byAgent
599
- .filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
600
- .sort((a, b) => {
601
- const rateDiff = (b.refusalRate ?? 0) - (a.refusalRate ?? 0);
602
- return rateDiff !== 0 ? rateDiff : b.judged - a.judged;
603
- })
604
- .slice(0, 10)
605
- .map(a => ({
606
- agent: a.agent,
607
- totalAttempts: a.judged,
608
- obeyed: a.obeyed,
609
- refused: a.refused,
610
- refusalRate: a.refusalRate,
611
- avgClassificationMs: a.avgClassificationMs,
612
- avgExecutionMs: a.avgExecutionMs,
613
- }));
614
- res.json({
615
- windowHours,
616
- minAttempts,
617
- summary: {
618
- totalRouted: relevant.length,
619
- totalJudged: judged.length,
620
- obeyed: obeyedCount,
621
- refused: refusedCount,
622
- unknown: unknownCount,
623
- obedienceRate: judged.length === 0 ? null : obeyedCount / judged.length,
624
- refusalRate: judged.length === 0 ? null : refusedCount / judged.length,
625
- avgClassificationMs,
626
- avgExecutionMs,
627
- p95ExecutionMs,
628
- },
629
- byAgent,
630
- dailyTrend,
631
- highRefusalAgents,
632
- });
633
- });
634
- // Recent routing events (timeline / detail)
635
- this.app.get('/api/routing/events', (req, res) => {
636
- const limit = parseInt(req.query.limit || '50');
637
- const sessionId = req.query.session;
638
- const projectPath = req.query.project;
639
- const agent = req.query.agent;
640
- const obeyedParam = req.query.obeyed;
641
- const filter = { limit };
642
- if (sessionId)
643
- filter.session_id = sessionId;
644
- if (projectPath)
645
- filter.project_path = projectPath;
646
- if (agent)
647
- filter.routed_to_name = agent;
648
- if (obeyedParam === 'null')
649
- filter.obeyed = null;
650
- else if (obeyedParam === '0')
651
- filter.obeyed = 0;
652
- else if (obeyedParam === '1')
653
- filter.obeyed = 1;
654
- const rows = storage.queryRoutingEvents(filter);
655
- res.json(rows);
656
- });
657
- // Methodology Executions: list all executions
658
- this.app.get('/api/methodology-executions', (req, res) => {
659
- const limit = parseInt(req.query.limit) || 50;
660
- const sessionId = req.query.session;
661
- const db = storage.getDatabase();
662
- const query = sessionId
663
- ? 'SELECT * FROM methodology_executions WHERE session_id = ? ORDER BY started_at DESC LIMIT ?'
664
- : 'SELECT * FROM methodology_executions ORDER BY started_at DESC LIMIT ?';
665
- const params = sessionId ? [sessionId, limit] : [limit];
666
- const executions = db.prepare(query).all(...params);
667
- res.json(executions);
668
- });
669
- // Methodology Execution Detail: get single execution with phases
670
- this.app.get('/api/methodology-executions/:id', (req, res) => {
671
- const executionId = parseInt(req.params.id);
672
- const db = storage.getDatabase();
673
- const execution = db.prepare('SELECT * FROM methodology_executions WHERE id = ?').get(executionId);
674
- if (!execution) {
675
- res.status(404).json({ error: 'Execution not found' });
676
- return;
677
- }
678
- const phases = db.prepare(`
679
- SELECT * FROM phase_executions
680
- WHERE methodology_execution_id = ?
681
- ORDER BY phase_index ASC
682
- `).all(executionId);
683
- res.json({
684
- ...execution,
685
- plan: JSON.parse(execution.plan_json),
686
- phases,
687
- });
688
- });
689
- // Methodology Execution: cancel a running execution
690
- this.app.post('/api/methodology-executions/:id/cancel', async (req, res) => {
691
- const executionId = parseInt(req.params.id);
692
- const db = storage.getDatabase();
693
- const execution = db.prepare('SELECT * FROM methodology_executions WHERE id = ?').get(executionId);
694
- if (!execution) {
695
- res.status(404).json({ error: 'Execution not found' });
696
- return;
697
- }
698
- if (execution.status !== 'running') {
699
- res.status(400).json({ error: `Cannot cancel execution in ${execution.status} state` });
700
- return;
701
- }
702
- // Prefer ExecutionManager — it kills the worker process for background mode
703
- if (this.options.executionManager) {
704
- try {
705
- await this.options.executionManager.cancel(executionId);
706
- }
707
- catch (err) {
708
- logger.warn(`[web] cancel via ExecutionManager failed: ${err}`);
709
- res.status(500).json({ error: `Cancel failed: ${err instanceof Error ? err.message : String(err)}` });
710
- return;
711
- }
712
- }
713
- else {
714
- // Fallback: DB-only cancel (pre-Phase-C behavior)
715
- db.prepare(`
716
- UPDATE methodology_executions
717
- SET status = 'cancelled', completed_at = ?
718
- WHERE id = ?
719
- `).run(Date.now(), executionId);
720
- }
721
- // Mark any still-running phase rows as cancelled
722
- db.prepare(`
723
- UPDATE phase_executions
724
- SET status = 'cancelled', completed_at = ?
725
- WHERE methodology_execution_id = ? AND status = 'running'
726
- `).run(Date.now(), executionId);
727
- res.json({ success: true, message: 'Execution cancelled' });
728
- });
729
- // Methodology Execution: start a new execution (Phase C)
730
- // Body: { session_id, methodology_id?, plan?, mode: 'foreground' | 'background', requirement? }
731
- this.app.post('/api/methodology-executions', async (req, res) => {
732
- const { executionManager, methodologyRegistry } = this.options;
733
- if (!executionManager || !methodologyRegistry) {
734
- res.status(503).json({ error: 'Execution manager not available' });
735
- return;
736
- }
737
- const { session_id, methodology_id, plan, mode } = req.body ?? {};
738
- if (!session_id) {
739
- res.status(400).json({ error: 'session_id is required' });
740
- return;
741
- }
742
- if (mode !== 'foreground' && mode !== 'background') {
743
- res.status(400).json({ error: 'mode must be foreground or background' });
744
- return;
745
- }
746
- let planObj = null;
747
- if (plan && typeof plan === 'object' && Array.isArray(plan.phases)) {
748
- planObj = plan;
749
- }
750
- else if (methodology_id) {
751
- const methodology = methodologyRegistry.get(methodology_id);
752
- if (!methodology) {
753
- res.status(404).json({ error: `methodology ${methodology_id} not found` });
754
- return;
755
- }
756
- const templates = methodology.phase_templates ?? {};
757
- const phaseIds = Object.keys(templates);
758
- if (phaseIds.length === 0) {
759
- res.status(400).json({ error: `methodology ${methodology_id} has no phase_templates` });
760
- return;
761
- }
762
- planObj = {
763
- methodology_id: methodology.id,
764
- rationale: `Started via API (default phases from ${methodology.id})`,
765
- phases: phaseIds.map(id => {
766
- const tpl = templates[id];
767
- return {
768
- id,
769
- agent: tpl.agent,
770
- prompt: tpl.description ?? tpl.prompt_template.slice(0, 400),
771
- rationale: 'default phase from methodology template',
772
- };
773
- }),
774
- };
775
- }
776
- else {
777
- res.status(400).json({ error: 'either methodology_id or plan must be provided' });
778
- return;
779
- }
780
- try {
781
- const id = executionManager.start({
782
- session_id,
783
- methodology_id: planObj.methodology_id,
784
- plan: planObj,
785
- mode,
786
- });
787
- res.status(201).json({ id, mode });
788
- }
789
- catch (err) {
790
- logger.warn(`[web] start execution failed: ${err}`);
791
- const message = err instanceof Error ? err.message : String(err);
792
- const isAuthError = err instanceof WorkerAuthError
793
- || (err instanceof Error && err.code === 'AUTH_REQUIRED')
794
- || message.includes('Background mode requires');
795
- if (isAuthError) {
796
- res.status(503).json({ error: message, code: 'AUTH_REQUIRED' });
797
- }
798
- else {
799
- res.status(500).json({ error: message });
800
- }
801
- }
802
- });
803
- // Methodology Execution: SSE stream of executor events (Phase C)
804
- this.app.get('/api/methodology-executions/events', (req, res) => {
805
- const { executionManager } = this.options;
806
- if (!executionManager) {
807
- res.status(503).json({ error: 'Execution manager not available' });
808
- return;
809
- }
810
- const filterId = req.query.execution_id ? parseInt(String(req.query.execution_id)) : null;
811
- res.setHeader('Content-Type', 'text/event-stream');
812
- res.setHeader('Cache-Control', 'no-cache');
813
- res.setHeader('Connection', 'keep-alive');
814
- res.flushHeaders();
815
- const unsub = executionManager.subscribe(ev => {
816
- if (filterId !== null && ev.execution_id !== filterId)
817
- return;
818
- try {
819
- res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
820
- }
821
- catch { /* client gone */ }
822
- });
823
- const heartbeat = setInterval(() => {
824
- try {
825
- res.write(`: heartbeat ${Date.now()}\n\n`);
826
- }
827
- catch { /* ignore */ }
828
- }, 25_000);
829
- req.on('close', () => {
830
- clearInterval(heartbeat);
831
- unsub();
832
- });
833
- });
834
- // Methodology Execution: delete an execution
835
- this.app.delete('/api/methodology-executions/:id', (req, res) => {
836
- const executionId = parseInt(req.params.id);
837
- const db = storage.getDatabase();
838
- db.prepare('DELETE FROM phase_executions WHERE methodology_execution_id = ?').run(executionId);
839
- const result = db.prepare('DELETE FROM methodology_executions WHERE id = ?').run(executionId);
840
- if (result.changes === 0) {
841
- res.status(404).json({ error: 'Execution not found' });
842
- return;
843
- }
844
- res.json({ success: true });
845
- });
846
- // Methodology: list all available methodologies
847
- this.app.get('/api/methodologies', (_req, res) => {
848
- try {
849
- const candidates = [
850
- path.join(__dirname, '../capability/methodologies'),
851
- path.join(__dirname, '../../src/capability/methodologies'),
852
- ];
853
- const dir = candidates.find(d => fs.existsSync(d));
854
- if (!dir) {
855
- res.json([]);
856
- return;
857
- }
858
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml'));
859
- const methodologies = files.map(f => {
860
- const content = fs.readFileSync(path.join(dir, f), 'utf-8');
861
- return yaml.load(content);
862
- });
863
- res.json(methodologies);
864
- }
865
- catch (err) {
866
- res.status(500).json({ error: err.message });
867
- }
868
- });
869
- // Refusal clustering (Plan A: SQL GROUP BY by taskType × agent)
870
- this.app.get('/api/routing/refusals', (req, res) => {
871
- const windowHours = parseInt(req.query.window || '168');
872
- const since = Date.now() - windowHours * 3600 * 1000;
873
- const events = storage.queryRoutingEvents({
874
- since_ts: since,
875
- obeyed: 0,
876
- limit: 1000,
877
- });
878
- // Group by (taskType, routed_to_name)
879
- const groups = new Map();
880
- for (const e of events) {
881
- let taskType = 'unknown';
882
- try {
883
- const parsed = JSON.parse(e.intent_json ?? '{}');
884
- if (typeof parsed.taskType === 'string')
885
- taskType = parsed.taskType;
886
- }
887
- catch { /* ignore */ }
888
- const key = `${taskType}__${e.routed_to_name ?? '—'}`;
889
- const g = groups.get(key) ?? {
890
- taskType,
891
- agent: e.routed_to_name ?? '—',
892
- count: 0,
893
- samples: [],
894
- };
895
- g.count++;
896
- if (g.samples.length < 5) {
897
- g.samples.push({
898
- prompt: e.prompt.slice(0, 200),
899
- refusal_reason: e.refusal_reason ?? null,
900
- ts: e.ts,
901
- });
902
- }
903
- groups.set(key, g);
904
- }
905
- const sorted = Array.from(groups.values()).sort((a, b) => b.count - a.count);
906
- res.json({ windowHours, groups: sorted });
907
- });
908
- // Routing violations analysis (Phase 3 Feature 3)
909
- this.app.get('/api/routing/violations', (req, res) => {
910
- const windowHours = parseInt(req.query.window || '168'); // default 7d
911
- const since = Date.now() - windowHours * 3600 * 1000;
912
- const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
913
- // Analyze violation patterns: consecutive refusals for same (taskType, agent)
914
- const patterns = new Map();
915
- for (const e of events) {
916
- if (!e.is_forced || e.obeyed === null)
917
- continue; // only analyze forced routes with judgement
918
- let taskType = 'unknown';
919
- try {
920
- const parsed = JSON.parse(e.intent_json ?? '{}');
921
- if (typeof parsed.taskType === 'string')
922
- taskType = parsed.taskType;
923
- }
924
- catch { /* ignore */ }
925
- const key = `${taskType}__${e.routed_to_name ?? '—'}`;
926
- const p = patterns.get(key) ?? {
927
- taskType,
928
- agent: e.routed_to_name ?? '—',
929
- totalAttempts: 0,
930
- refusals: 0,
931
- refusalRate: 0,
932
- recentRefusals: 0,
933
- severity: 'low',
934
- samples: [],
935
- };
936
- p.totalAttempts++;
937
- if (e.obeyed === 0) {
938
- p.refusals++;
939
- if (p.samples.length < 5) {
940
- p.samples.push({
941
- prompt: e.prompt.slice(0, 200),
942
- refusal_reason: e.refusal_reason ?? null,
943
- ts: e.ts,
944
- });
945
- }
946
- }
947
- patterns.set(key, p);
948
- }
949
- // Calculate metrics and severity
950
- const violations = Array.from(patterns.values())
951
- .map(p => {
952
- p.refusalRate = p.totalAttempts === 0 ? 0 : p.refusals / p.totalAttempts;
953
- // Calculate recent refusals (last 5 attempts for this pattern)
954
- const recentEvents = events
955
- .filter(e => {
956
- let taskType = 'unknown';
957
- try {
958
- const parsed = JSON.parse(e.intent_json ?? '{}');
959
- if (typeof parsed.taskType === 'string')
960
- taskType = parsed.taskType;
961
- }
962
- catch { /* ignore */ }
963
- return taskType === p.taskType && e.routed_to_name === p.agent;
964
- })
965
- .slice(0, 5);
966
- p.recentRefusals = recentEvents.filter(e => e.obeyed === 0).length;
967
- // Determine severity
968
- if (p.refusalRate >= 0.8 && p.totalAttempts >= 5)
969
- p.severity = 'critical';
970
- else if (p.refusalRate >= 0.6 && p.totalAttempts >= 3)
971
- p.severity = 'high';
972
- else if (p.refusalRate >= 0.4)
973
- p.severity = 'medium';
974
- else
975
- p.severity = 'low';
976
- return p;
977
- })
978
- .filter(p => p.refusals > 0) // only show patterns with at least 1 refusal
979
- .sort((a, b) => {
980
- // Sort by severity, then refusal rate
981
- const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
982
- if (severityOrder[a.severity] !== severityOrder[b.severity]) {
983
- return severityOrder[b.severity] - severityOrder[a.severity];
984
- }
985
- return b.refusalRate - a.refusalRate;
986
- });
987
- res.json({ windowHours, violations });
988
- });
989
- // Routing config editor API (Phase 3 Feature 2)
990
- this.app.get('/api/routing/config', (_req, res) => {
991
- const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
992
- const defaultPath = path.join(__dirname, 'engine', 'conventions', 'routing.yaml');
993
- let content = '';
994
- let source = 'none';
995
- if (fs.existsSync(userPath)) {
996
- content = fs.readFileSync(userPath, 'utf-8');
997
- source = 'user';
998
- }
999
- else if (fs.existsSync(defaultPath)) {
1000
- content = fs.readFileSync(defaultPath, 'utf-8');
1001
- source = 'default';
1002
- }
1003
- res.json({ content, source, userPath, defaultPath });
1004
- });
1005
- this.app.put('/api/routing/config', (req, res) => {
1006
- const { content } = req.body;
1007
- if (typeof content !== 'string') {
1008
- res.status(400).json({ error: 'content must be a string' });
1009
- return;
1010
- }
1011
- const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
1012
- const dir = path.dirname(userPath);
1013
- try {
1014
- // Validate YAML syntax before saving
1015
- yaml.load(content);
1016
- // Ensure directory exists
1017
- if (!fs.existsSync(dir)) {
1018
- fs.mkdirSync(dir, { recursive: true });
1019
- }
1020
- // Write to user override path
1021
- fs.writeFileSync(userPath, content, 'utf-8');
1022
- logger.info(`[Web] Routing config updated: ${userPath}`);
1023
- res.json({ success: true, path: userPath });
1024
- }
1025
- catch (err) {
1026
- logger.warn(`[Web] Failed to save routing config: ${err}`);
1027
- res.status(400).json({ error: String(err) });
1028
- }
1029
- });
1030
- // ── Phase 5: A/B Testing APIs ────────────────────────────────────────
1031
- const experimentsPath = path.join(homedir(), '.claude-forge', 'routing-experiments.yaml');
1032
- this.app.get('/api/routing/experiments/config', (_req, res) => {
1033
- let content = '';
1034
- let source = 'none';
1035
- if (fs.existsSync(experimentsPath)) {
1036
- content = fs.readFileSync(experimentsPath, 'utf-8');
1037
- source = 'user';
1038
- }
1039
- res.json({ content, source, path: experimentsPath });
1040
- });
1041
- this.app.put('/api/routing/experiments/config', async (req, res) => {
1042
- const { content } = req.body;
1043
- if (typeof content !== 'string') {
1044
- res.status(400).json({ error: 'content must be a string' });
1045
- return;
1046
- }
1047
- try {
1048
- const parsed = yaml.load(content, { schema: yaml.CORE_SCHEMA });
1049
- // Reuse validateConfig for structural enforcement (weights, groups, etc.).
1050
- const { validateConfig } = await import('../engine/experiment-router.js');
1051
- const cfg = validateConfig(parsed);
1052
- if (!cfg) {
1053
- res.status(400).json({ error: 'experiments YAML failed validation (see daemon logs)' });
1054
- return;
1055
- }
1056
- const dir = path.dirname(experimentsPath);
1057
- if (!fs.existsSync(dir))
1058
- fs.mkdirSync(dir, { recursive: true });
1059
- fs.writeFileSync(experimentsPath, content, 'utf-8');
1060
- logger.info(`[Web] Experiments config updated: ${experimentsPath}`);
1061
- res.json({ success: true, path: experimentsPath });
1062
- }
1063
- catch (err) {
1064
- logger.warn(`[Web] Failed to save experiments config: ${err}`);
1065
- res.status(400).json({ error: String(err) });
1066
- }
1067
- });
1068
- this.app.get('/api/routing/experiments/analysis', async (_req, res) => {
1069
- try {
1070
- if (!fs.existsSync(experimentsPath)) {
1071
- res.json({ enabled: false, experimentId: null, groups: [] });
1072
- return;
1073
- }
1074
- const raw = fs.readFileSync(experimentsPath, 'utf-8');
1075
- const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
1076
- const { validateConfig } = await import('../engine/experiment-router.js');
1077
- const cfg = validateConfig(parsed);
1078
- if (!cfg || !cfg.experiment) {
1079
- res.json({ enabled: cfg?.enabled ?? false, experimentId: null, groups: [] });
1080
- return;
1081
- }
1082
- const stats = storage.queryExperimentStats(cfg.experiment.id);
1083
- const groups = cfg.experiment.groups.map(g => {
1084
- const s = stats.find(row => row.group_id === g.id);
1085
- const total = s?.total ?? 0;
1086
- const obeyed = s?.obeyed ?? 0;
1087
- const refused = s?.refused ?? 0;
1088
- return {
1089
- id: g.id,
1090
- name: g.name,
1091
- weight: g.weight,
1092
- total,
1093
- obeyed,
1094
- refused,
1095
- unknown: s?.unknown ?? 0,
1096
- obeyedRate: total > 0 ? obeyed / total : null,
1097
- avgClassificationMs: s?.avg_classification_ms ?? null,
1098
- };
1099
- });
1100
- // Simple z-test between the two groups with the largest samples.
1101
- let zScore = null;
1102
- let sampleAdequate = false;
1103
- let suggestedWinner = null;
1104
- if (groups.length >= 2) {
1105
- const sorted = [...groups].sort((a, b) => b.total - a.total).slice(0, 2);
1106
- const [g1, g2] = sorted;
1107
- sampleAdequate = g1.total >= 50 && g2.total >= 50;
1108
- if (sampleAdequate) {
1109
- const p1 = g1.obeyed / g1.total;
1110
- const p2 = g2.obeyed / g2.total;
1111
- const pPool = (g1.obeyed + g2.obeyed) / (g1.total + g2.total);
1112
- const se = Math.sqrt(pPool * (1 - pPool) * (1 / g1.total + 1 / g2.total));
1113
- zScore = se > 0 ? (p1 - p2) / se : 0;
1114
- if (Math.abs(zScore) > 1.96) {
1115
- suggestedWinner = p1 > p2 ? g1.id : g2.id;
1116
- }
1117
- }
1118
- }
1119
- res.json({
1120
- enabled: cfg.enabled,
1121
- experimentId: cfg.experiment.id,
1122
- experimentName: cfg.experiment.name,
1123
- startedAt: cfg.experiment.startedAt,
1124
- endedAt: cfg.experiment.endedAt,
1125
- groups,
1126
- zScore,
1127
- sampleAdequate,
1128
- suggestedWinner,
1129
- });
1130
- }
1131
- catch (err) {
1132
- logger.warn(`[Web] Experiments analysis failed: ${err}`);
1133
- res.status(500).json({ error: String(err) });
1134
- }
1135
- });
1136
- this.app.post('/api/routing/experiments/promote', async (req, res) => {
1137
- const { groupId } = req.body;
1138
- if (typeof groupId !== 'string' || groupId.length === 0) {
1139
- res.status(400).json({ error: 'groupId is required' });
1140
- return;
1141
- }
1142
- try {
1143
- if (!fs.existsSync(experimentsPath)) {
1144
- res.status(400).json({ error: 'experiments config does not exist' });
1145
- return;
1146
- }
1147
- const raw = fs.readFileSync(experimentsPath, 'utf-8');
1148
- const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
1149
- const { validateConfig } = await import('../engine/experiment-router.js');
1150
- const cfg = validateConfig(parsed);
1151
- if (!cfg || !cfg.experiment) {
1152
- res.status(400).json({ error: 'experiments config has no active experiment' });
1153
- return;
1154
- }
1155
- const group = cfg.experiment.groups.find(g => g.id === groupId);
1156
- if (!group) {
1157
- res.status(404).json({ error: `group '${groupId}' not found` });
1158
- return;
1159
- }
1160
- // Step 1: backup existing routing.yaml if present
1161
- const routingPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
1162
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
1163
- let backupPath = null;
1164
- if (fs.existsSync(routingPath)) {
1165
- backupPath = `${routingPath}.bak-${ts}`;
1166
- fs.copyFileSync(routingPath, backupPath);
1167
- }
1168
- else {
1169
- const dir = path.dirname(routingPath);
1170
- if (!fs.existsSync(dir))
1171
- fs.mkdirSync(dir, { recursive: true });
1172
- }
1173
- // Step 2: write the winner's rules as the new routing.yaml
1174
- const newRouting = yaml.dump({ schemaVersion: '1.0', rules: group.rules });
1175
- fs.writeFileSync(routingPath, newRouting, 'utf-8');
1176
- // Step 3: mark experiment as ended (enabled=false, endedAt=now)
1177
- const endedAt = new Date().toISOString();
1178
- const updated = yaml.dump({
1179
- schemaVersion: '1.0',
1180
- enabled: false,
1181
- experiment: {
1182
- ...cfg.experiment,
1183
- endedAt,
1184
- },
1185
- });
1186
- fs.writeFileSync(experimentsPath, updated, 'utf-8');
1187
- logger.info(`[Web] Promoted group '${groupId}' from experiment '${cfg.experiment.id}'; backup: ${backupPath}`);
1188
- res.json({ promoted: groupId, routingPath, backupPath, endedAt });
1189
- }
1190
- catch (err) {
1191
- logger.warn(`[Web] Failed to promote experiment group: ${err}`);
1192
- res.status(500).json({ error: String(err) });
1193
- }
1194
- });
1195
- // ── Phase 5 Feature 2: Rule States (auto-disable) ────────────────────
1196
- this.app.get('/api/routing/rule-states', (req, res) => {
1197
- const disabledOnly = req.query.disabled === '1' || req.query.disabled === 'true';
1198
- const rows = storage.listRuleStates({ disabledOnly });
1199
- res.json({ ruleStates: rows });
1200
- });
1201
- this.app.put('/api/routing/rule-states', (req, res) => {
1202
- const { taskType, agent, disabled, reason } = req.body ?? {};
1203
- if (typeof taskType !== 'string' || taskType.length === 0) {
1204
- res.status(400).json({ error: 'taskType is required' });
1205
- return;
1206
- }
1207
- if (typeof agent !== 'string' || agent.length === 0) {
1208
- res.status(400).json({ error: 'agent is required' });
1209
- return;
1210
- }
1211
- if (typeof disabled !== 'boolean') {
1212
- res.status(400).json({ error: 'disabled must be boolean' });
1213
- return;
1214
- }
1215
- try {
1216
- storage.setRuleState({
1217
- taskType,
1218
- agent,
1219
- disabled,
1220
- reason: typeof reason === 'string' ? reason : null,
1221
- autoDisabled: false, // manual toggle
1222
- });
1223
- logger.info(`[Web] Rule state updated: ${taskType}__${agent} disabled=${disabled}`);
1224
- res.json({ success: true });
1225
- }
1226
- catch (err) {
1227
- logger.warn(`[Web] Failed to set rule state: ${err}`);
1228
- res.status(500).json({ error: String(err) });
1229
- }
1230
- });
1231
- // ── Phase 5 Feature 3: Rule Recommendations ──────────────────────────
1232
- this.app.get('/api/routing/recommendations', (req, res) => {
1233
- const { router, agents } = this.options;
1234
- if (!router || !agents) {
1235
- res.json({ recommendations: [], reason: 'router/agents not injected' });
1236
- return;
1237
- }
1238
- const windowDays = Math.max(1, Math.min(90, parseInt(req.query.days || '7')));
1239
- try {
1240
- const recommender = new Recommender(storage, router, agents, {
1241
- windowMs: windowDays * 24 * 3600 * 1000,
1242
- });
1243
- const recommendations = recommender.analyze();
1244
- res.json({ windowDays, recommendations });
1245
- }
1246
- catch (err) {
1247
- logger.warn(`[Web] Recommender failed: ${err}`);
1248
- res.status(500).json({ error: String(err) });
1249
- }
1250
- });
1251
- // AI-assisted optimization recommendations
1252
- this.app.get('/api/routing/ai-optimization', async (req, res) => {
1253
- const { router, agents } = this.options;
1254
- if (!router || !agents) {
1255
- res.status(400).json({ error: 'router/agents not injected' });
1256
- return;
1257
- }
1258
- const windowHours = Math.max(1, Math.min(720, parseInt(req.query.window || '168')));
1259
- const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
1260
- const since = Date.now() - windowHours * 3600 * 1000;
1261
- try {
1262
- const config = new ConfigManager().get();
1263
- const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
1264
- if (!apiKey) {
1265
- res.status(400).json({ error: 'AI API key not configured' });
1266
- return;
1267
- }
1268
- const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
1269
- const recommender = new Recommender(storage, router, agents, { windowMs: windowHours * 3600 * 1000 });
1270
- const ruleRecommendations = recommender.analyze();
1271
- const performanceRes = await (async () => {
1272
- const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
1273
- const relevant = events.filter(e => e.routed_to_name);
1274
- const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
1275
- const byAgent = new Map();
1276
- for (const e of relevant) {
1277
- const key = e.routed_to_name ?? '—';
1278
- const bucket = byAgent.get(key) ?? { total: 0, judged: 0, obeyed: 0, refused: 0, latencySum: 0, latencyCount: 0 };
1279
- bucket.total++;
1280
- if (e.obeyed === 1) {
1281
- bucket.judged++;
1282
- bucket.obeyed++;
1283
- }
1284
- else if (e.obeyed === 0) {
1285
- bucket.judged++;
1286
- bucket.refused++;
1287
- }
1288
- if (typeof e.classification_ms === 'number') {
1289
- bucket.latencySum += e.classification_ms;
1290
- bucket.latencyCount++;
1291
- }
1292
- byAgent.set(key, bucket);
1293
- }
1294
- return {
1295
- totalRouted: relevant.length,
1296
- totalJudged: judged.length,
1297
- byAgent: Array.from(byAgent.entries()).map(([agent, b]) => ({
1298
- agent,
1299
- total: b.total,
1300
- judged: b.judged,
1301
- obeyed: b.obeyed,
1302
- refused: b.refused,
1303
- refusalRate: b.judged === 0 ? null : b.refused / b.judged,
1304
- avgClassificationMs: b.latencyCount === 0 ? null : Math.round(b.latencySum / b.latencyCount),
1305
- })).sort((a, b) => (b.refusalRate ?? 0) - (a.refusalRate ?? 0)),
1306
- };
1307
- })();
1308
- const highRefusalAgents = performanceRes.byAgent
1309
- .filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
1310
- .slice(0, 10);
1311
- const topViolations = (await (async () => {
1312
- const events = storage.queryRoutingEvents({ since_ts: since, obeyed: 0, limit: 1000 });
1313
- const patterns = new Map();
1314
- for (const e of events) {
1315
- let taskType = 'unknown';
1316
- try {
1317
- const parsed = JSON.parse(e.intent_json ?? '{}');
1318
- if (typeof parsed.taskType === 'string')
1319
- taskType = parsed.taskType;
1320
- }
1321
- catch { /* ignore */ }
1322
- const key = `${taskType}__${e.routed_to_name ?? '—'}`;
1323
- const p = patterns.get(key) ?? { taskType, agent: e.routed_to_name ?? '—', total: 0, refusals: 0, refusalRate: 0, samples: [] };
1324
- p.total++;
1325
- p.refusals++;
1326
- if (p.samples.length < 3)
1327
- p.samples.push(e.prompt.slice(0, 180));
1328
- patterns.set(key, p);
1329
- }
1330
- return Array.from(patterns.values()).map(p => ({
1331
- ...p,
1332
- refusalRate: p.total === 0 ? 0 : p.refusals / p.total,
1333
- })).sort((a, b) => b.refusalRate - a.refusalRate).slice(0, 5);
1334
- })());
1335
- const prompt = [
1336
- 'You are reviewing Claude Forge routing performance and should produce practical improvement suggestions.',
1337
- 'Return ONLY valid JSON with keys: summary, priorities, suggestedChanges.',
1338
- '',
1339
- 'Context:',
1340
- `- windowHours: ${windowHours}`,
1341
- `- minAttempts: ${minAttempts}`,
1342
- `- totalRouted: ${performanceRes.totalRouted}`,
1343
- `- totalJudged: ${performanceRes.totalJudged}`,
1344
- '',
1345
- 'Performance by agent:',
1346
- JSON.stringify(highRefusalAgents, null, 2),
1347
- '',
1348
- 'Top refusal patterns:',
1349
- JSON.stringify(topViolations, null, 2),
1350
- '',
1351
- 'Routing rule recommendations:',
1352
- JSON.stringify(ruleRecommendations.slice(0, 8), null, 2),
1353
- '',
1354
- 'Please provide:',
1355
- '- summary: 2-3 sentences summarizing the main issues',
1356
- '- priorities: array of { area, finding, impact, confidence }',
1357
- '- suggestedChanges: array of { targetType: agent|skill|routing_rule, targetName, recommendation, rationale, expectedBenefit }',
1358
- ].join('\n');
1359
- const raw = await ai.complete(prompt, { maxTokens: 2500 });
1360
- const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '');
1361
- const match = cleaned.match(/\{[\s\S]*\}/);
1362
- if (!match) {
1363
- res.status(500).json({ error: 'AI did not return JSON', raw });
1364
- return;
1365
- }
1366
- const parsed = JSON.parse(match[0]);
1367
- res.json({
1368
- windowHours,
1369
- minAttempts,
1370
- generatedAt: new Date().toISOString(),
1371
- summary: parsed.summary ?? '',
1372
- priorities: Array.isArray(parsed.priorities) ? parsed.priorities : [],
1373
- suggestedChanges: Array.isArray(parsed.suggestedChanges) ? parsed.suggestedChanges : [],
1374
- evidence: {
1375
- highRefusalAgents,
1376
- topViolations,
1377
- ruleRecommendations: ruleRecommendations.slice(0, 8),
1378
- },
1379
- });
1380
- }
1381
- catch (err) {
1382
- logger.warn(`[Web] Failed to generate AI optimization: ${err}`);
1383
- res.status(500).json({ error: String(err) });
1384
- }
1385
- });
1386
- // ── Patch APIs ────────────────────────────────────────────────────────
1387
- // POST /api/patch/preview — generate structured patch preview
1388
- this.app.post('/api/patch/preview', async (req, res) => {
1389
- const { targetType, targetName, recommendation, rationale } = req.body ?? {};
1390
- if (!targetType || !targetName || !recommendation) {
1391
- res.status(400).json({ error: 'targetType, targetName, and recommendation are required' });
1392
- return;
1393
- }
1394
- try {
1395
- const config = new ConfigManager().get();
1396
- const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
1397
- if (!apiKey) {
1398
- res.status(400).json({ error: 'AI API key not configured' });
1399
- return;
1400
- }
1401
- const { filePath } = resolvePatchTarget(targetType, targetName);
1402
- if (!fs.existsSync(filePath)) {
1403
- res.status(404).json({ error: `Target file not found: ${filePath}` });
1404
- return;
1405
- }
1406
- const currentContent = fs.readFileSync(filePath, 'utf-8');
1407
- const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
1408
- const prompt = `You are a code/config optimization assistant. Given the current file content and a recommended change, generate the updated content.
1409
-
1410
- Current file (${targetType}/${targetName}):
1411
- \`\`\`
1412
- ${currentContent}
1413
- \`\`\`
1414
-
1415
- Recommendation: ${recommendation}
1416
- ${rationale ? `Rationale: ${rationale}` : ''}
1417
-
1418
- Return ONLY a JSON object with this exact structure (no markdown, no explanation):
1419
- {
1420
- "summary": "brief description of what changed",
1421
- "afterContent": "the complete updated file content",
1422
- "risk": "low|medium|high"
1423
- }`;
1424
- const response = await ai.complete(prompt, { maxTokens: 4096 });
1425
- const parsed = JSON.parse(response.trim());
1426
- res.json({
1427
- targetType,
1428
- targetName,
1429
- filePath,
1430
- before: currentContent,
1431
- after: parsed.afterContent,
1432
- summary: parsed.summary,
1433
- risk: parsed.risk || 'medium',
1434
- recommendation,
1435
- rationale: rationale || null,
1436
- });
1437
- }
1438
- catch (err) {
1439
- logger.warn(`[Web] Patch preview failed: ${err}`);
1440
- res.status(500).json({ error: String(err) });
1441
- }
1442
- });
1443
- // POST /api/patch/apply — apply patch with backup
1444
- this.app.post('/api/patch/apply', (req, res) => {
1445
- const { targetType, targetName, afterContent } = req.body ?? {};
1446
- if (!targetType || !targetName || typeof afterContent !== 'string') {
1447
- res.status(400).json({ error: 'targetType, targetName, and afterContent are required' });
1448
- return;
1449
- }
1450
- try {
1451
- const { filePath, backupDir } = resolvePatchTarget(targetType, targetName);
1452
- if (!fs.existsSync(filePath)) {
1453
- res.status(404).json({ error: `Target file not found: ${filePath}` });
1454
- return;
1455
- }
1456
- // Create backup
1457
- if (!fs.existsSync(backupDir)) {
1458
- fs.mkdirSync(backupDir, { recursive: true });
1459
- }
1460
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1461
- const backupName = `${targetName}_${timestamp}${path.extname(filePath)}`;
1462
- const backupPath = path.join(backupDir, backupName);
1463
- fs.copyFileSync(filePath, backupPath);
1464
- // Apply patch
1465
- fs.writeFileSync(filePath, afterContent, 'utf-8');
1466
- logger.info(`[Web] Patch applied to ${targetType}/${targetName}, backup: ${backupPath}`);
1467
- res.json({
1468
- success: true,
1469
- targetType,
1470
- targetName,
1471
- filePath,
1472
- backupPath,
1473
- timestamp,
1474
- });
1475
- }
1476
- catch (err) {
1477
- logger.warn(`[Web] Patch apply failed: ${err}`);
1478
- res.status(500).json({ error: String(err) });
1479
- }
1480
- });
1481
- // ── AI Configuration APIs ─────────────────────────────────────────────
1482
- // GET /api/config/ai — read current AI config (mask apiKey)
1483
- this.app.get('/api/config/ai', (_req, res) => {
1484
- const configManager = new ConfigManager();
1485
- const config = configManager.get();
1486
- const maskApiKey = (key) => {
1487
- if (!key || key.length < 10)
1488
- return '***';
1489
- return key.slice(0, 6) + '***' + key.slice(-4);
1490
- };
1491
- res.json({
1492
- api_key: maskApiKey(config.distill.api_key),
1493
- base_url: config.distill.base_url || '',
1494
- model: config.distill.model,
1495
- provider: config.distill.provider,
1496
- classifier_model: config.distill.classifier_model || '',
1497
- classifier_timeout: config.distill.classifier_timeout || 10000,
1498
- });
1499
- });
1500
- // PUT /api/config/ai — update AI config
1501
- this.app.put('/api/config/ai', (req, res) => {
1502
- const { api_key, base_url, model, provider, classifier_model, classifier_timeout } = req.body ?? {};
1503
- try {
1504
- const configManager = new ConfigManager();
1505
- const config = configManager.get();
1506
- // Update only provided fields
1507
- if (typeof api_key === 'string')
1508
- config.distill.api_key = api_key;
1509
- if (typeof base_url === 'string') {
1510
- // Normalize http → https for upstream gateways that force HTTPS redirect
1511
- // (e.g. iflytek one.iflytek.com returns 302 and drops Authorization header)
1512
- let url = base_url.trim();
1513
- if (url.startsWith('http://'))
1514
- url = 'https://' + url.slice('http://'.length);
1515
- config.distill.base_url = url;
1516
- }
1517
- if (typeof model === 'string')
1518
- config.distill.model = model;
1519
- if (typeof provider === 'string')
1520
- config.distill.provider = provider;
1521
- if (typeof classifier_model === 'string')
1522
- config.distill.classifier_model = classifier_model || undefined;
1523
- if (typeof classifier_timeout === 'number')
1524
- config.distill.classifier_timeout = classifier_timeout;
1525
- configManager.save();
1526
- logger.info('[Web] AI config updated');
1527
- res.json({ success: true });
1528
- }
1529
- catch (err) {
1530
- logger.warn(`[Web] Failed to update AI config: ${err}`);
1531
- res.status(500).json({ error: String(err) });
1532
- }
1533
- });
1534
- // GET /api/ai/models — proxy to upstream /v1/models
1535
- // Supports ?api_key= and ?base_url= to test unsaved config; falls back to config.yaml.
1536
- this.app.get('/api/ai/models', async (req, res) => {
1537
- try {
1538
- const configManager = new ConfigManager();
1539
- const config = configManager.get();
1540
- const queryKey = typeof req.query.api_key === 'string' ? req.query.api_key : '';
1541
- const queryUrl = typeof req.query.base_url === 'string' ? req.query.base_url : '';
1542
- const apiKey = queryKey || config.distill.api_key;
1543
- let baseUrl = queryUrl || config.distill.base_url || 'https://api.anthropic.com';
1544
- // Upstream gateways that force HTTPS drop headers on 302 — normalize proactively.
1545
- if (baseUrl.startsWith('http://'))
1546
- baseUrl = 'https://' + baseUrl.slice('http://'.length);
1547
- if (!apiKey) {
1548
- res.status(400).json({ error: 'API key not configured' });
1549
- return;
1550
- }
1551
- // Construct models endpoint URL
1552
- const modelsUrl = baseUrl.endsWith('/v1')
1553
- ? `${baseUrl}/models`
1554
- : `${baseUrl}/v1/models`;
1555
- const response = await fetch(modelsUrl, {
1556
- redirect: 'follow',
1557
- headers: {
1558
- 'Authorization': `Bearer ${apiKey}`,
1559
- 'x-api-key': apiKey,
1560
- },
1561
- });
1562
- if (!response.ok) {
1563
- const text = await response.text();
1564
- logger.warn(`[Web] Upstream /v1/models failed: ${response.status} ${text}`);
1565
- res.status(response.status).json({ error: text });
1566
- return;
1567
- }
1568
- const data = await response.json();
1569
- res.json(data);
1570
- }
1571
- catch (err) {
1572
- logger.warn(`[Web] Failed to fetch models: ${err}`);
1573
- res.status(500).json({ error: String(err) });
1574
- }
1575
- });
1576
- // POST /api/ai/test — test AI connection using provided or saved config
1577
- this.app.post('/api/ai/test', async (req, res) => {
1578
- try {
1579
- const configManager = new ConfigManager();
1580
- const config = configManager.get();
1581
- const { api_key, base_url, model } = req.body ?? {};
1582
- const apiKey = (typeof api_key === 'string' && api_key) || config.distill.api_key;
1583
- let baseUrl = (typeof base_url === 'string' && base_url) || config.distill.base_url || 'https://api.anthropic.com';
1584
- const useModel = (typeof model === 'string' && model) || config.distill.model;
1585
- // Normalize http → https
1586
- if (baseUrl.startsWith('http://'))
1587
- baseUrl = 'https://' + baseUrl.slice('http://'.length);
1588
- if (!apiKey) {
1589
- res.status(400).json({ error: 'API key not configured' });
1590
- return;
1591
- }
1592
- const messagesUrl = baseUrl.endsWith('/v1')
1593
- ? `${baseUrl}/messages`
1594
- : `${baseUrl}/v1/messages`;
1595
- const response = await fetch(messagesUrl, {
1596
- method: 'POST',
1597
- redirect: 'follow',
1598
- headers: {
1599
- 'Content-Type': 'application/json',
1600
- 'x-api-key': apiKey,
1601
- 'anthropic-version': '2023-06-01',
1602
- },
1603
- body: JSON.stringify({
1604
- model: useModel,
1605
- max_tokens: 10,
1606
- messages: [{ role: 'user', content: 'ping' }],
1607
- }),
1608
- });
1609
- if (!response.ok) {
1610
- const text = await response.text();
1611
- res.status(response.status).json({ error: text });
1612
- return;
1613
- }
1614
- const data = await response.json();
1615
- res.json({ success: true, model: data.model || useModel });
1616
- }
1617
- catch (err) {
1618
- logger.warn(`[Web] AI connection test failed: ${err}`);
1619
- res.status(500).json({ error: String(err) });
1620
- }
1621
- });
1622
- // ── Agent & Skill Management APIs ────────────────────────────────────────
1623
- // GET /api/agents — list all agents (official + user)
1624
- this.app.get('/api/agents', (_req, res) => {
1625
- try {
1626
- const agentsDir = path.join(homedir(), '.claude', 'agents');
1627
- const official = this.agents?.getAll() || [];
1628
- const userAgents = [];
1629
- if (fs.existsSync(agentsDir)) {
1630
- const files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
1631
- for (const file of files) {
1632
- const name = file.replace('.md', '');
1633
- if (official.some((a) => a.name === name))
1634
- continue; // Skip official agents
1635
- const content = fs.readFileSync(path.join(agentsDir, file), 'utf-8');
1636
- const match = content.match(/^---\n([\s\S]*?)\n---/);
1637
- if (match) {
1638
- const fm = match[1];
1639
- const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
1640
- const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
1641
- userAgents.push({
1642
- name,
1643
- description: descMatch?.[1] || '',
1644
- version: verMatch?.[1],
1645
- });
1646
- }
1647
- }
1648
- }
1649
- res.json({ official, user: userAgents });
1650
- }
1651
- catch (err) {
1652
- logger.warn(`[Web] Failed to list agents: ${err}`);
1653
- res.status(500).json({ error: String(err) });
1654
- }
1655
- });
1656
- // GET /api/agents/:name — get agent details
1657
- this.app.get('/api/agents/:name', (req, res) => {
1658
- try {
1659
- const { name } = req.params;
1660
- const agentsDir = path.join(homedir(), '.claude', 'agents');
1661
- const filePath = path.join(agentsDir, `${name}.md`);
1662
- if (!fs.existsSync(filePath)) {
1663
- res.status(404).json({ error: 'Agent not found' });
1664
- return;
1665
- }
1666
- const content = fs.readFileSync(filePath, 'utf-8');
1667
- const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1668
- if (!match) {
1669
- res.json({ name, content });
1670
- return;
1671
- }
1672
- const fm = match[1];
1673
- const body = match[2];
1674
- const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
1675
- const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
1676
- const toolsMatch = fm.match(/tools:\s*(.+)/);
1677
- res.json({
1678
- name,
1679
- description: descMatch?.[1] || '',
1680
- version: verMatch?.[1],
1681
- tools: toolsMatch?.[1],
1682
- content,
1683
- });
1684
- }
1685
- catch (err) {
1686
- logger.warn(`[Web] Failed to get agent ${req.params.name}: ${err}`);
1687
- res.status(500).json({ error: String(err) });
1688
- }
1689
- });
1690
- // PUT /api/agents/:name — update agent content
1691
- this.app.put('/api/agents/:name', (req, res) => {
1692
- try {
1693
- const { name } = req.params;
1694
- const { content } = req.body;
1695
- if (!content || typeof content !== 'string') {
1696
- res.status(400).json({ error: 'Missing content' });
1697
- return;
1698
- }
1699
- const agentsDir = path.join(homedir(), '.claude', 'agents');
1700
- const filePath = path.join(agentsDir, `${name}.md`);
1701
- if (!fs.existsSync(filePath)) {
1702
- res.status(404).json({ error: 'Agent not found' });
1703
- return;
1704
- }
1705
- // Backup before editing
1706
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1707
- fs.mkdirSync(backupDir, { recursive: true });
1708
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1709
- const backupPath = path.join(backupDir, `${name}-${timestamp}.md`);
1710
- fs.copyFileSync(filePath, backupPath);
1711
- // Write new content
1712
- fs.writeFileSync(filePath, content, 'utf-8');
1713
- logger.info(`[Web] Updated agent ${name} (backup: ${backupPath})`);
1714
- res.json({ success: true, backup: backupPath });
1715
- }
1716
- catch (err) {
1717
- logger.warn(`[Web] Failed to update agent ${req.params.name}: ${err}`);
1718
- res.status(500).json({ error: String(err) });
1719
- }
1720
- });
1721
- // GET /api/skills — list all skills (official + user)
1722
- this.app.get('/api/skills', (_req, res) => {
1723
- try {
1724
- const skillsDir = path.join(homedir(), '.claude', 'skills');
1725
- const skills = [];
1726
- if (fs.existsSync(skillsDir)) {
1727
- const files = fs.readdirSync(skillsDir).filter(f => f.endsWith('.md'));
1728
- for (const file of files) {
1729
- const name = file.replace('.md', '');
1730
- const content = fs.readFileSync(path.join(skillsDir, file), 'utf-8');
1731
- const match = content.match(/^---\n([\s\S]*?)\n---/);
1732
- if (match) {
1733
- const fm = match[1];
1734
- const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
1735
- const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
1736
- const sourceMatch = fm.match(/source:\s*["']?([^"'\n]+)["']?/);
1737
- skills.push({
1738
- name,
1739
- description: descMatch?.[1] || '',
1740
- version: verMatch?.[1],
1741
- source: sourceMatch?.[1] || 'user',
1742
- });
1743
- }
1744
- }
1745
- }
1746
- res.json({ skills });
1747
- }
1748
- catch (err) {
1749
- logger.warn(`[Web] Failed to list skills: ${err}`);
1750
- res.status(500).json({ error: String(err) });
1751
- }
1752
- });
1753
- // GET /api/skills/:name — get skill details
1754
- this.app.get('/api/skills/:name', (req, res) => {
1755
- try {
1756
- const { name } = req.params;
1757
- const skillsDir = path.join(homedir(), '.claude', 'skills');
1758
- const filePath = path.join(skillsDir, `${name}.md`);
1759
- if (!fs.existsSync(filePath)) {
1760
- res.status(404).json({ error: 'Skill not found' });
1761
- return;
1762
- }
1763
- const content = fs.readFileSync(filePath, 'utf-8');
1764
- const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1765
- if (!match) {
1766
- res.json({ name, content });
1767
- return;
1768
- }
1769
- const fm = match[1];
1770
- const body = match[2];
1771
- const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
1772
- const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
1773
- const sourceMatch = fm.match(/source:\s*["']?([^"'\n]+)["']?/);
1774
- res.json({
1775
- name,
1776
- description: descMatch?.[1] || '',
1777
- version: verMatch?.[1],
1778
- source: sourceMatch?.[1] || 'user',
1779
- content,
1780
- });
1781
- }
1782
- catch (err) {
1783
- logger.warn(`[Web] Failed to get skill ${req.params.name}: ${err}`);
1784
- res.status(500).json({ error: String(err) });
1785
- }
1786
- });
1787
- // PUT /api/skills/:name — update skill content
1788
- this.app.put('/api/skills/:name', (req, res) => {
1789
- try {
1790
- const { name } = req.params;
1791
- const { content } = req.body;
1792
- if (!content || typeof content !== 'string') {
1793
- res.status(400).json({ error: 'Missing content' });
1794
- return;
1795
- }
1796
- const skillsDir = path.join(homedir(), '.claude', 'skills');
1797
- const filePath = path.join(skillsDir, `${name}.md`);
1798
- if (!fs.existsSync(filePath)) {
1799
- res.status(404).json({ error: 'Skill not found' });
1800
- return;
1801
- }
1802
- // Backup before editing
1803
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1804
- fs.mkdirSync(backupDir, { recursive: true });
1805
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1806
- const backupPath = path.join(backupDir, `${name}-${timestamp}.md`);
1807
- fs.copyFileSync(filePath, backupPath);
1808
- // Write new content
1809
- fs.writeFileSync(filePath, content, 'utf-8');
1810
- logger.info(`[Web] Updated skill ${name} (backup: ${backupPath})`);
1811
- res.json({ success: true, backup: backupPath });
1812
- }
1813
- catch (err) {
1814
- logger.warn(`[Web] Failed to update skill ${req.params.name}: ${err}`);
1815
- res.status(500).json({ error: String(err) });
1816
- }
1817
- });
1818
- // ── Version Management APIs ────────────────────────────────────────────────
1819
- // GET /api/agents/:name/versions — list backup versions
1820
- this.app.get('/api/agents/:name/versions', (req, res) => {
1821
- try {
1822
- const { name } = req.params;
1823
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1824
- if (!fs.existsSync(backupDir)) {
1825
- res.json({ versions: [] });
1826
- return;
1827
- }
1828
- const files = fs.readdirSync(backupDir)
1829
- .filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
1830
- .map(f => {
1831
- const filePath = path.join(backupDir, f);
1832
- const stats = fs.statSync(filePath);
1833
- const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
1834
- return {
1835
- filename: f,
1836
- timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
1837
- size: stats.size,
1838
- mtime: stats.mtime.toISOString(),
1839
- };
1840
- })
1841
- .sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
1842
- res.json({ versions: files });
1843
- }
1844
- catch (err) {
1845
- logger.warn(`[Web] Failed to list agent versions: ${err}`);
1846
- res.status(500).json({ error: String(err) });
1847
- }
1848
- });
1849
- // GET /api/agents/:name/versions/:timestamp — get specific version content
1850
- this.app.get('/api/agents/:name/versions/:timestamp', (req, res) => {
1851
- try {
1852
- const { name, timestamp } = req.params;
1853
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1854
- const filename = `${name}-${timestamp}.md`;
1855
- const filePath = path.join(backupDir, filename);
1856
- if (!fs.existsSync(filePath)) {
1857
- res.status(404).json({ error: 'Version not found' });
1858
- return;
1859
- }
1860
- const content = fs.readFileSync(filePath, 'utf-8');
1861
- res.json({ content });
1862
- }
1863
- catch (err) {
1864
- logger.warn(`[Web] Failed to get agent version: ${err}`);
1865
- res.status(500).json({ error: String(err) });
1866
- }
1867
- });
1868
- // POST /api/agents/:name/rollback — rollback to a specific version
1869
- this.app.post('/api/agents/:name/rollback', (req, res) => {
1870
- try {
1871
- const { name } = req.params;
1872
- const { timestamp } = req.body;
1873
- if (!timestamp) {
1874
- res.status(400).json({ error: 'Missing timestamp' });
1875
- return;
1876
- }
1877
- const agentsDir = path.join(homedir(), '.claude', 'agents');
1878
- const currentPath = path.join(agentsDir, `${name}.md`);
1879
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
1880
- const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
1881
- if (!fs.existsSync(currentPath)) {
1882
- res.status(404).json({ error: 'Agent not found' });
1883
- return;
1884
- }
1885
- if (!fs.existsSync(versionPath)) {
1886
- res.status(404).json({ error: 'Version not found' });
1887
- return;
1888
- }
1889
- // Backup current version before rollback
1890
- fs.mkdirSync(backupDir, { recursive: true });
1891
- const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
1892
- const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
1893
- fs.copyFileSync(currentPath, rollbackBackupPath);
1894
- // Restore version
1895
- const versionContent = fs.readFileSync(versionPath, 'utf-8');
1896
- fs.writeFileSync(currentPath, versionContent, 'utf-8');
1897
- logger.info(`[Web] Rolled back agent ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
1898
- res.json({ success: true, backup: rollbackBackupPath });
1899
- }
1900
- catch (err) {
1901
- logger.warn(`[Web] Failed to rollback agent: ${err}`);
1902
- res.status(500).json({ error: String(err) });
1903
- }
1904
- });
1905
- // GET /api/skills/:name/versions — list backup versions
1906
- this.app.get('/api/skills/:name/versions', (req, res) => {
1907
- try {
1908
- const { name } = req.params;
1909
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1910
- if (!fs.existsSync(backupDir)) {
1911
- res.json({ versions: [] });
1912
- return;
1913
- }
1914
- const files = fs.readdirSync(backupDir)
1915
- .filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
1916
- .map(f => {
1917
- const filePath = path.join(backupDir, f);
1918
- const stats = fs.statSync(filePath);
1919
- const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
1920
- return {
1921
- filename: f,
1922
- timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
1923
- size: stats.size,
1924
- mtime: stats.mtime.toISOString(),
1925
- };
1926
- })
1927
- .sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
1928
- res.json({ versions: files });
1929
- }
1930
- catch (err) {
1931
- logger.warn(`[Web] Failed to list skill versions: ${err}`);
1932
- res.status(500).json({ error: String(err) });
1933
- }
1934
- });
1935
- // GET /api/skills/:name/versions/:timestamp — get specific version content
1936
- this.app.get('/api/skills/:name/versions/:timestamp', (req, res) => {
1937
- try {
1938
- const { name, timestamp } = req.params;
1939
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1940
- const filename = `${name}-${timestamp}.md`;
1941
- const filePath = path.join(backupDir, filename);
1942
- if (!fs.existsSync(filePath)) {
1943
- res.status(404).json({ error: 'Version not found' });
1944
- return;
1945
- }
1946
- const content = fs.readFileSync(filePath, 'utf-8');
1947
- res.json({ content });
1948
- }
1949
- catch (err) {
1950
- logger.warn(`[Web] Failed to get skill version: ${err}`);
1951
- res.status(500).json({ error: String(err) });
1952
- }
1953
- });
1954
- // POST /api/skills/:name/rollback — rollback to a specific version
1955
- this.app.post('/api/skills/:name/rollback', (req, res) => {
1956
- try {
1957
- const { name } = req.params;
1958
- const { timestamp } = req.body;
1959
- if (!timestamp) {
1960
- res.status(400).json({ error: 'Missing timestamp' });
1961
- return;
1962
- }
1963
- const skillsDir = path.join(homedir(), '.claude', 'skills');
1964
- const currentPath = path.join(skillsDir, `${name}.md`);
1965
- const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
1966
- const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
1967
- if (!fs.existsSync(currentPath)) {
1968
- res.status(404).json({ error: 'Skill not found' });
1969
- return;
1970
- }
1971
- if (!fs.existsSync(versionPath)) {
1972
- res.status(404).json({ error: 'Version not found' });
1973
- return;
1974
- }
1975
- // Backup current version before rollback
1976
- fs.mkdirSync(backupDir, { recursive: true });
1977
- const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
1978
- const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
1979
- fs.copyFileSync(currentPath, rollbackBackupPath);
1980
- // Restore version
1981
- const versionContent = fs.readFileSync(versionPath, 'utf-8');
1982
- fs.writeFileSync(currentPath, versionContent, 'utf-8');
1983
- logger.info(`[Web] Rolled back skill ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
1984
- res.json({ success: true, backup: rollbackBackupPath });
1985
- }
1986
- catch (err) {
1987
- logger.warn(`[Web] Failed to rollback skill: ${err}`);
1988
- res.status(500).json({ error: String(err) });
1989
- }
1990
- });
1991
- // ── Execution Trace APIs ──────────────────────────────────────────────────
1992
- // GET /api/execution-trace — list execution traces with routing info
1993
- this.app.get('/api/execution-trace', (req, res) => {
1994
- try {
1995
- const limit = parseInt(req.query.limit) || 50;
1996
- const agentFilter = req.query.agent;
1997
- const obeyedFilter = req.query.obeyed;
1998
- const db = storage.getDatabase();
1999
- let sql = `
2000
- SELECT
2001
- re.id,
2002
- re.session_id,
2003
- re.route_request_id,
2004
- re.project_path,
2005
- re.ts,
2006
- re.prompt,
2007
- re.intent_json,
2008
- re.routed_to_type,
2009
- re.routed_to_name,
2010
- re.obeyed,
2011
- re.classification_ms,
2012
- re.fallback_used,
2013
- re.first_tool_name,
2014
- re.first_tool_ts,
2015
- re.completed_ts,
2016
- re.total_execution_ms,
2017
- re.completion_reason,
2018
- re.downstream_task_chain,
2019
- s.status as session_status,
2020
- s.start_time,
2021
- s.end_time,
2022
- s.event_count
2023
- FROM routing_events re
2024
- LEFT JOIN sessions s ON re.session_id = s.session_id
2025
- WHERE 1=1
2026
- `;
2027
- const params = [];
2028
- if (agentFilter) {
2029
- sql += ` AND re.routed_to_name = ?`;
2030
- params.push(agentFilter);
2031
- }
2032
- if (obeyedFilter === 'true') {
2033
- sql += ` AND re.obeyed = 1`;
2034
- }
2035
- else if (obeyedFilter === 'false') {
2036
- sql += ` AND re.obeyed = 0`;
2037
- }
2038
- sql += ` ORDER BY re.ts DESC LIMIT ?`;
2039
- params.push(limit);
2040
- const rows = db.prepare(sql).all(...params);
2041
- const traces = rows.map(row => ({
2042
- id: row.id,
2043
- sessionId: row.session_id,
2044
- routeRequestId: row.route_request_id,
2045
- projectPath: row.project_path,
2046
- timestamp: row.ts,
2047
- prompt: row.prompt,
2048
- intent: JSON.parse(row.intent_json || '{}'),
2049
- routedToType: row.routed_to_type,
2050
- routedToName: row.routed_to_name,
2051
- obeyed: row.obeyed,
2052
- classificationMs: row.classification_ms,
2053
- fallbackUsed: row.fallback_used === 1,
2054
- firstTool: row.first_tool_name,
2055
- firstToolTs: row.first_tool_ts,
2056
- completedTs: row.completed_ts,
2057
- totalExecutionMs: row.total_execution_ms,
2058
- completionReason: row.completion_reason,
2059
- taskChain: row.downstream_task_chain ? JSON.parse(row.downstream_task_chain) : [],
2060
- sessionStatus: row.session_status,
2061
- sessionStart: row.start_time,
2062
- sessionEnd: row.end_time,
2063
- eventCount: row.event_count,
2064
- }));
2065
- res.json({ traces });
2066
- }
2067
- catch (err) {
2068
- logger.warn(`[Web] Failed to get execution traces: ${err}`);
2069
- res.status(500).json({ error: String(err) });
2070
- }
2071
- });
2072
- // GET /api/execution-trace/:id — detailed trace for a routing event
2073
- this.app.get('/api/execution-trace/:id', (req, res) => {
2074
- try {
2075
- const id = Number(req.params.id);
2076
- if (!Number.isFinite(id)) {
2077
- res.status(400).json({ error: 'Invalid trace id' });
2078
- return;
2079
- }
2080
- const db = storage.getDatabase();
2081
- // Get routing event by unique id
2082
- const routing = db.prepare(`
2083
- SELECT * FROM routing_events WHERE id = ?
2084
- `).get(id);
2085
- if (!routing) {
2086
- res.status(404).json({ error: 'Trace not found' });
2087
- return;
2088
- }
2089
- const sessionId = routing.session_id;
2090
- const routeRequestId = routing.route_request_id ?? null;
2091
- // Get all events for this session
2092
- const events = db.prepare(`
2093
- SELECT event_id, timestamp, hook_type, tool_name, user_prompt
2094
- FROM events
2095
- WHERE session_id = ?
2096
- ORDER BY timestamp ASC
2097
- `).all(sessionId);
2098
- // Get injections
2099
- const injections = db.prepare(`
2100
- SELECT injection_type, content, timestamp
2101
- FROM injections
2102
- WHERE session_id = ?
2103
- ORDER BY timestamp ASC
2104
- `).all(sessionId);
2105
- // Get Agent/Task tool events tied to this route_request_id (preferred)
2106
- const agentCallRows = routeRequestId
2107
- ? db.prepare(`
2108
- SELECT id, tool, args, success, error, timestamp
2109
- FROM v2_tool_events
2110
- WHERE route_request_id = ? AND tool IN ('Agent','Task')
2111
- ORDER BY timestamp ASC
2112
- `).all(routeRequestId)
2113
- : [];
2114
- // Completion signals: PostToolUse for Agent/Task within this session after routing.ts
2115
- const postUseRows = db.prepare(`
2116
- SELECT event_id, timestamp, tool_name
2117
- FROM events
2118
- WHERE session_id = ? AND hook_type = 'PostToolUse' AND tool_name IN ('Agent','Task')
2119
- AND timestamp >= ?
2120
- ORDER BY timestamp ASC
2121
- `).all(sessionId, new Date(routing.ts).toISOString());
2122
- const agentCalls = agentCallRows.map(r => {
2123
- let subagent = null;
2124
- try {
2125
- const parsed = JSON.parse(r.args || '{}');
2126
- if (typeof parsed.subagent_type === 'string')
2127
- subagent = parsed.subagent_type;
2128
- }
2129
- catch { /* ignore */ }
2130
- const completed = postUseRows.find(p => p.tool_name === r.tool && new Date(p.timestamp).getTime() >= r.timestamp);
2131
- return {
2132
- toolEventId: r.id,
2133
- tool: r.tool,
2134
- subagent,
2135
- startedTs: r.timestamp,
2136
- allowed: r.success === 1,
2137
- error: r.error,
2138
- completedTs: completed ? new Date(completed.timestamp).getTime() : null,
2139
- completedEventId: completed?.event_id ?? null,
2140
- status: !completed ? 'started' : 'completed',
2141
- };
2142
- });
2143
- res.json({
2144
- routing: {
2145
- id: routing.id,
2146
- sessionId,
2147
- routeRequestId,
2148
- prompt: routing.prompt,
2149
- timestamp: routing.ts,
2150
- intent: JSON.parse(routing.intent_json || '{}'),
2151
- routedToType: routing.routed_to_type,
2152
- routedToName: routing.routed_to_name,
2153
- obeyed: routing.obeyed,
2154
- classificationMs: routing.classification_ms,
2155
- fallbackUsed: routing.fallback_used === 1,
2156
- refusalReason: routing.refusal_reason,
2157
- firstTool: routing.first_tool_name,
2158
- firstToolTs: routing.first_tool_ts,
2159
- completedTs: routing.completed_ts,
2160
- totalExecutionMs: routing.total_execution_ms,
2161
- completionReason: routing.completion_reason,
2162
- taskChain: routing.downstream_task_chain ? JSON.parse(routing.downstream_task_chain) : [],
2163
- },
2164
- events,
2165
- injections,
2166
- agentCalls,
2167
- });
2168
- }
2169
- catch (err) {
2170
- logger.warn(`[Web] Failed to get trace details for ${req.params.id}: ${err}`);
2171
- res.status(500).json({ error: String(err) });
2172
- }
2173
- });
2174
- // GET /api/execution-trace/:session_id/status — current status snapshot
2175
- this.app.get('/api/execution-trace/:session_id/status', (req, res) => {
2176
- try {
2177
- const { session_id } = req.params;
2178
- const db = storage.getDatabase();
2179
- const routing = db.prepare(`SELECT * FROM routing_events WHERE session_id = ? ORDER BY ts DESC LIMIT 1`).get(session_id);
2180
- if (!routing) {
2181
- res.status(404).json({ error: 'Trace not found' });
2182
- return;
2183
- }
2184
- const latestToolEvent = db.prepare(`
2185
- SELECT tool, success, error, timestamp
2186
- FROM v2_tool_events
2187
- WHERE session_id = ? AND timestamp >= ?
2188
- ORDER BY timestamp DESC LIMIT 1
2189
- `).get(session_id, routing.ts);
2190
- const latestDecision = db.prepare(`
2191
- SELECT level, reason, timestamp
2192
- FROM v2_decisions
2193
- WHERE session_id = ? AND timestamp >= ?
2194
- ORDER BY timestamp DESC LIMIT 1
2195
- `).get(session_id, routing.ts);
2196
- let status = 'routing';
2197
- if (latestDecision?.level === 'block' || latestToolEvent?.success === 0)
2198
- status = 'failed';
2199
- else if (routing.completed_ts)
2200
- status = 'completed';
2201
- else if (routing.first_tool_ts || latestToolEvent)
2202
- status = 'executing';
2203
- res.json({
2204
- status,
2205
- timestamp: routing.ts,
2206
- firstTool: routing.first_tool_name ?? latestToolEvent?.tool ?? null,
2207
- firstToolTs: routing.first_tool_ts ?? latestToolEvent?.timestamp ?? null,
2208
- completedTs: routing.completed_ts ?? null,
2209
- totalExecutionMs: routing.total_execution_ms ?? null,
2210
- completionReason: routing.completion_reason ?? null,
2211
- error: latestToolEvent?.success === 0 ? latestToolEvent.error : (latestDecision?.level === 'block' ? latestDecision.reason : null),
2212
- });
2213
- }
2214
- catch (err) {
2215
- logger.warn(`[Web] Failed to get trace status for ${req.params.session_id}: ${err}`);
2216
- res.status(500).json({ error: String(err) });
2217
- }
2218
- });
2219
- // SSE: execution trace live status stream
2220
- this.app.get('/api/execution-trace/stream', (req, res) => {
2221
- res.writeHead(200, {
2222
- 'Content-Type': 'text/event-stream',
2223
- 'Cache-Control': 'no-cache',
2224
- Connection: 'keep-alive',
2225
- });
2226
- res.write('data: {"type":"connected"}\n\n');
2227
- const filterSession = req.query.session;
2228
- const readOnlyTools = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
2229
- const writeStatus = (payload) => {
2230
- if (filterSession && payload.sessionId !== filterSession)
2231
- return;
2232
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
2233
- };
2234
- const onEvent = (event) => {
2235
- const hookType = event.hook_type;
2236
- const toolName = event.tool_name;
2237
- const sessionId = event.session_id;
2238
- const timestamp = event.timestamp;
2239
- if (!sessionId || !hookType)
2240
- return;
2241
- if (hookType === 'UserPromptSubmit') {
2242
- writeStatus({ type: 'execution-status', status: 'routing', sessionId, timestamp, prompt: event.user_prompt ?? null });
2243
- return;
2244
- }
2245
- if (hookType === 'PreToolUse' && toolName && !readOnlyTools.has(toolName)) {
2246
- writeStatus({ type: 'execution-status', status: 'executing', sessionId, timestamp, tool: toolName });
2247
- return;
2248
- }
2249
- if (hookType === 'Stop') {
2250
- writeStatus({ type: 'execution-status', status: 'completed', sessionId, timestamp });
2251
- }
2252
- };
2253
- const onToolEvent = (event) => {
2254
- const sessionId = event.session_id;
2255
- if (!sessionId)
2256
- return;
2257
- if (event.success === false || event.success === 0) {
2258
- writeStatus({
2259
- type: 'execution-status',
2260
- status: 'failed',
2261
- sessionId,
2262
- timestamp: event.timestamp ?? Date.now(),
2263
- tool: event.tool ?? null,
2264
- error: event.error ?? null,
2265
- });
2266
- }
2267
- };
2268
- const onDecision = (decision) => {
2269
- const sessionId = decision.session_id;
2270
- if (!sessionId)
2271
- return;
2272
- if (decision.level === 'block') {
2273
- writeStatus({
2274
- type: 'execution-status',
2275
- status: 'failed',
2276
- sessionId,
2277
- timestamp: decision.timestamp ?? Date.now(),
2278
- error: decision.reason ?? null,
2279
- });
2280
- }
2281
- };
2282
- storage.on('event', onEvent);
2283
- storage.on('tool-event', onToolEvent);
2284
- storage.on('decision', onDecision);
2285
- req.on('close', () => {
2286
- storage.removeListener('event', onEvent);
2287
- storage.removeListener('tool-event', onToolEvent);
2288
- storage.removeListener('decision', onDecision);
2289
- });
2290
- });
2291
- // SSE: real-time event stream
2292
- this.app.get('/api/events/stream', (req, res) => {
2293
- res.writeHead(200, {
2294
- 'Content-Type': 'text/event-stream',
2295
- 'Cache-Control': 'no-cache',
2296
- Connection: 'keep-alive',
2297
- });
2298
- res.write('data: {"type":"connected"}\n\n');
2299
- const filterSession = req.query.session;
2300
- const filterProject = req.query.project;
2301
- const filterHook = req.query.hook;
2302
- const onEvent = (event) => {
2303
- if (filterSession && event.session_id !== filterSession)
2304
- return;
2305
- if (filterProject && event.project_path !== filterProject)
2306
- return;
2307
- if (filterHook && event.hook_type !== filterHook)
2308
- return;
2309
- res.write(`data: ${JSON.stringify(event)}\n\n`);
2310
- };
2311
- storage.on('event', onEvent);
2312
- req.on('close', () => {
2313
- storage.removeListener('event', onEvent);
2314
- });
2315
- });
2316
- // SSE: real-time governance decisions
2317
- this.app.get('/api/decisions/stream', (req, res) => {
2318
- res.writeHead(200, {
2319
- 'Content-Type': 'text/event-stream',
2320
- 'Cache-Control': 'no-cache',
2321
- Connection: 'keep-alive',
2322
- });
2323
- res.write('data: {"type":"connected"}\n\n');
2324
- const filterSession = req.query.session;
2325
- const onDecision = (decision) => {
2326
- if (filterSession && decision.session_id !== filterSession)
2327
- return;
2328
- res.write(`data: ${JSON.stringify(decision)}\n\n`);
2329
- };
2330
- storage.on('decision', onDecision);
2331
- req.on('close', () => {
2332
- storage.removeListener('decision', onDecision);
2333
- });
2334
- });
2335
- // SPA fallback: serve index.html for any non-API route
2336
- // (must be the LAST route to not interfere with API routes)
2337
- const spaCandidates = [
2338
- path.join(__dirname, 'static'),
2339
- path.join(__dirname, '../../src/web/static'),
2340
- ];
2341
- const spaStaticDir = spaCandidates.find(dir => fs.existsSync(dir));
2342
- if (spaStaticDir) {
2343
- this.app.get(/^(?!\/api).*$/, (_req, res) => {
2344
- res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
2345
- res.sendFile('index.html', { root: spaStaticDir });
2346
- });
2347
- }
56
+ // 1. Auth bootstrap (must be available before everything else)
57
+ registerAuthRoutes(this.app, ctx);
58
+ // 2. Static assets (serves UI; fallback registered last)
59
+ registerStaticAssets(this.app, ctx);
60
+ // 3. API routes, grouped by domain. Order within /api doesn't matter
61
+ // because Express matches exact paths — but execution-trace preserves
62
+ // the original (buggy) definition order intentionally.
63
+ registerStatusRoutes(this.app, ctx);
64
+ registerEventsRoutes(this.app, ctx);
65
+ registerSessionsRoutes(this.app, ctx);
66
+ registerRulesRoutes(this.app, ctx);
67
+ registerRoutingRoutes(this.app, ctx);
68
+ registerExperimentsRoutes(this.app, ctx);
69
+ registerMethodologyRoutes(this.app, ctx);
70
+ registerTokenUsageRoutes(this.app, ctx);
71
+ registerExecutionTraceRoutes(this.app, ctx);
72
+ registerAIRoutes(this.app, ctx);
73
+ registerPatchRoutes(this.app, ctx);
74
+ registerAgentsRoutes(this.app, ctx);
75
+ registerSkillsRoutes(this.app, ctx);
76
+ // 4. SPA catch-all — must be LAST so it can't shadow any API route.
77
+ registerStaticFallback(this.app, ctx);
2348
78
  }
2349
79
  async start() {
2350
80
  return new Promise((resolve, reject) => {
@@ -2362,7 +92,7 @@ Return ONLY a JSON object with this exact structure (no markdown, no explanation
2362
92
  }
2363
93
  async stop() {
2364
94
  if (this.server) {
2365
- return new Promise((resolve) => {
95
+ return new Promise(resolve => {
2366
96
  this.server.close(() => resolve());
2367
97
  });
2368
98
  }