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