dr-claw 1.1.2

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 (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. package/shared/modelConstants.js +139 -0
@@ -0,0 +1,2566 @@
1
+ /**
2
+ * TASKMASTER API ROUTES
3
+ * ====================
4
+ *
5
+ * This module provides API endpoints for TaskMaster integration including:
6
+ * - .pipeline folder detection in project directories
7
+ * - MCP server configuration detection
8
+ * - TaskMaster state and metadata management
9
+ */
10
+
11
+ import express from 'express';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { promises as fsPromises } from 'fs';
15
+ import { fileURLToPath } from 'url';
16
+ import { extractProjectDirectory } from '../projects.js';
17
+ import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
18
+ import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
19
+
20
+ const router = express.Router();
21
+ const PIPELINE_DIR = '.pipeline';
22
+ const LEGACY_TASKMASTER_DIR = '.taskmaster';
23
+ const DEFAULT_TASKS_TAG = 'master';
24
+ const DEFAULT_RESEARCH_BRIEF_FILENAME = 'research_brief.json';
25
+ const DEFAULT_MAX_TASKS = 30;
26
+ const STAGE_ORDER = ['survey', 'ideation', 'experiment', 'publication', 'promotion'];
27
+ const STAGE_LABELS = {
28
+ survey: 'Survey',
29
+ ideation: 'Ideation',
30
+ experiment: 'Experiment',
31
+ publication: 'Publication',
32
+ promotion: 'Promotion',
33
+ presentation: 'Promotion',
34
+ };
35
+ const STAGE_PROMPT_HINTS = {
36
+ survey: 'Establish the literature baseline, collect evidence, and map open gaps before committing to a direction.',
37
+ ideation: 'Clarify thesis, scope boundaries, and evidence framing before execution.',
38
+ experiment: 'Turn assumptions into an executable protocol with measurable validation criteria.',
39
+ publication: 'Convert outcomes into a coherent manuscript narrative with concrete submission artifacts.',
40
+ promotion: 'Transform research outcomes into homepage assets, visual slides, narration scripts, and demo videos.',
41
+ };
42
+ const DEFAULT_STAGE_SKILL_MAP = {
43
+ survey: {
44
+ base: ['inno-deep-research', 'academic-researcher', 'dataset-discovery'],
45
+ byTaskType: {
46
+ exploration: ['inno-deep-research', 'academic-researcher', 'dataset-discovery'],
47
+ analysis: ['inno-deep-research', 'academic-researcher'],
48
+ },
49
+ },
50
+ ideation: {
51
+ base: ['inno-pipeline-planner', 'inno-idea-generation', 'inno-prepare-resources'],
52
+ byTaskType: {
53
+ analysis: ['inno-idea-generation', 'academic-researcher'],
54
+ exploration: ['inno-idea-generation', 'inno-code-survey'],
55
+ },
56
+ },
57
+ experiment: {
58
+ base: ['inno-code-survey', 'inno-experiment-dev', 'inno-experiment-analysis'],
59
+ byTaskType: {
60
+ implementation: ['inno-experiment-dev'],
61
+ analysis: ['inno-experiment-analysis'],
62
+ exploration: ['inno-code-survey'],
63
+ },
64
+ },
65
+ publication: {
66
+ base: ['inno-paper-writing', 'inno-reference-audit', 'inno-rclone-to-overleaf'],
67
+ byTaskType: {
68
+ writing: ['inno-paper-writing'],
69
+ analysis: ['inno-reference-audit'],
70
+ },
71
+ },
72
+ promotion: {
73
+ base: ['making-academic-presentations'],
74
+ byTaskType: {
75
+ scripting: ['making-academic-presentations'],
76
+ rendering: ['making-academic-presentations'],
77
+ narration: ['making-academic-presentations'],
78
+ delivery: ['making-academic-presentations'],
79
+ },
80
+ },
81
+ };
82
+ const DEFAULT_BRIEF_SECTIONS = {
83
+ survey: {
84
+ literature_scope: '',
85
+ key_references: [],
86
+ synthesis_summary: '',
87
+ open_gaps: [],
88
+ },
89
+ ideation: {
90
+ research_goal: '',
91
+ problem_framing: '',
92
+ evidence_plan: '',
93
+ success_criteria: [],
94
+ },
95
+ experiment: {
96
+ hypothesis_or_validation_goal: '',
97
+ dataset_or_data_source: '',
98
+ method_or_protocol: '',
99
+ evaluation_plan: '',
100
+ },
101
+ publication: {
102
+ paper_outline: '',
103
+ figures_tables_plan: '',
104
+ artifact_plan: '',
105
+ submission_checklist: [],
106
+ },
107
+ promotion: {
108
+ slide_outline: '',
109
+ deck_style: '',
110
+ tts_config: '',
111
+ video_assembly_plan: '',
112
+ homepage_plan: '',
113
+ },
114
+ };
115
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
116
+ const SKILLS_DIR = path.resolve(__dirname, '..', '..', 'skills');
117
+ const STAGE_SKILL_MAP_PATH = path.join(SKILLS_DIR, 'stage-skill-map.json');
118
+ let cachedStageSkillMap = null;
119
+ let cachedStageSkillMapMtimeMs = null;
120
+ function normalizeStageSkillMap(rawMap = {}) {
121
+ const normalized = {};
122
+ STAGE_ORDER.forEach((stage) => {
123
+ const source = rawMap?.[stage]
124
+ || (stage === 'promotion' ? rawMap?.presentation : null)
125
+ || {};
126
+ normalized[stage] = {
127
+ base: Array.isArray(source.base) ? source.base.map((item) => String(item || '').trim()).filter(Boolean) : [],
128
+ byTaskType: source.byTaskType && typeof source.byTaskType === 'object'
129
+ ? Object.fromEntries(
130
+ Object.entries(source.byTaskType).map(([taskType, skills]) => [
131
+ String(taskType || '').trim(),
132
+ Array.isArray(skills) ? skills.map((item) => String(item || '').trim()).filter(Boolean) : [],
133
+ ]),
134
+ )
135
+ : {},
136
+ };
137
+ if (normalized[stage].base.length === 0) {
138
+ normalized[stage].base = DEFAULT_STAGE_SKILL_MAP[stage]?.base || [];
139
+ }
140
+ });
141
+ return normalized;
142
+ }
143
+
144
+ function getStageSkillMap() {
145
+ try {
146
+ const stats = fs.statSync(STAGE_SKILL_MAP_PATH);
147
+ if (
148
+ cachedStageSkillMap &&
149
+ typeof cachedStageSkillMapMtimeMs === 'number' &&
150
+ cachedStageSkillMapMtimeMs === stats.mtimeMs
151
+ ) {
152
+ return cachedStageSkillMap;
153
+ }
154
+
155
+ const content = fs.readFileSync(STAGE_SKILL_MAP_PATH, 'utf8');
156
+ const parsed = JSON.parse(content);
157
+ cachedStageSkillMap = normalizeStageSkillMap(parsed);
158
+ cachedStageSkillMapMtimeMs = stats.mtimeMs;
159
+ return cachedStageSkillMap;
160
+ } catch (error) {
161
+ if (!cachedStageSkillMap) {
162
+ cachedStageSkillMap = normalizeStageSkillMap(DEFAULT_STAGE_SKILL_MAP);
163
+ }
164
+ return cachedStageSkillMap;
165
+ }
166
+ }
167
+
168
+ function buildDefaultBriefPipeline(stageSkillMap) {
169
+ const map = stageSkillMap || getStageSkillMap();
170
+ return {
171
+ version: '1.1',
172
+ mode: 'idea',
173
+ stages: {
174
+ survey: {
175
+ required_elements: [
176
+ 'sections.survey.literature_scope',
177
+ 'sections.survey.synthesis_summary',
178
+ ],
179
+ optional_elements: [
180
+ 'sections.survey.key_references',
181
+ 'sections.survey.open_gaps',
182
+ ],
183
+ quality_gate: [
184
+ 'The literature scope is explicit and bounded',
185
+ 'The synthesis identifies concrete open gaps or unresolved tensions',
186
+ ],
187
+ task_blueprints: [
188
+ {
189
+ id: 'survey_collect_references',
190
+ title: 'Collect and triage the core literature set',
191
+ description: 'Assemble the most relevant references, group them by theme, and note inclusion boundaries.',
192
+ taskType: 'exploration',
193
+ },
194
+ {
195
+ id: 'survey_summarize_gaps',
196
+ title: 'Summarize trends, baselines, and open gaps',
197
+ description: 'Write a compact synthesis of what is known, what is contested, and where the project can contribute.',
198
+ taskType: 'analysis',
199
+ },
200
+ ],
201
+ recommended_skills: map.survey.base,
202
+ },
203
+ ideation: {
204
+ required_elements: [
205
+ 'sections.ideation.research_goal',
206
+ 'sections.ideation.problem_framing',
207
+ ],
208
+ optional_elements: [
209
+ 'sections.ideation.evidence_plan',
210
+ 'sections.ideation.success_criteria',
211
+ ],
212
+ quality_gate: [
213
+ 'At least one clear research direction is defined',
214
+ 'Problem framing and expected value are specific',
215
+ ],
216
+ task_blueprints: [
217
+ {
218
+ id: 'ideation_generate_candidates',
219
+ title: 'Generate and compare candidate research directions',
220
+ description: 'Produce multiple candidate directions and compare novelty, feasibility, and expected impact.',
221
+ taskType: 'exploration',
222
+ },
223
+ {
224
+ id: 'ideation_select_direction',
225
+ title: 'Select one direction with explicit rationale',
226
+ description: 'Pick one direction and document tradeoffs and scope boundaries.',
227
+ taskType: 'analysis',
228
+ },
229
+ ],
230
+ recommended_skills: map.ideation.base,
231
+ },
232
+ experiment: {
233
+ required_elements: [
234
+ 'sections.experiment.hypothesis_or_validation_goal',
235
+ 'sections.experiment.method_or_protocol',
236
+ 'sections.experiment.evaluation_plan',
237
+ ],
238
+ optional_elements: [
239
+ 'sections.experiment.dataset_or_data_source',
240
+ ],
241
+ quality_gate: [
242
+ 'Validation goal can be measured objectively',
243
+ 'Method and evaluation protocol are executable',
244
+ ],
245
+ task_blueprints: [
246
+ {
247
+ id: 'experiment_define_protocol',
248
+ title: 'Define executable experiment protocol',
249
+ description: 'Translate method and evaluation plan into executable steps and checkpoints.',
250
+ taskType: 'implementation',
251
+ },
252
+ {
253
+ id: 'experiment_run_analysis',
254
+ title: 'Run baseline analysis and record outcomes',
255
+ description: 'Execute baseline validation and summarize key findings and gaps.',
256
+ taskType: 'analysis',
257
+ },
258
+ ],
259
+ recommended_skills: map.experiment.base,
260
+ },
261
+ publication: {
262
+ required_elements: [
263
+ 'sections.publication.paper_outline',
264
+ 'sections.publication.submission_checklist',
265
+ ],
266
+ optional_elements: [
267
+ 'sections.publication.figures_tables_plan',
268
+ 'sections.publication.artifact_plan',
269
+ ],
270
+ quality_gate: [
271
+ 'Contribution narrative and structure are coherent',
272
+ 'Submission checklist and artifacts are complete',
273
+ ],
274
+ task_blueprints: [
275
+ {
276
+ id: 'publication_outline_to_draft',
277
+ title: 'Expand outline into draft sections',
278
+ description: 'Convert paper outline into structured draft sections with claim-evidence alignment.',
279
+ taskType: 'writing',
280
+ },
281
+ {
282
+ id: 'publication_finalize_artifacts',
283
+ title: 'Finalize figures, tables, and artifacts',
284
+ description: 'Prepare final visuals and reproducibility artifacts required for submission.',
285
+ taskType: 'writing',
286
+ },
287
+ ],
288
+ recommended_skills: map.publication.base,
289
+ },
290
+ promotion: {
291
+ required_elements: [
292
+ 'sections.promotion.slide_outline',
293
+ ],
294
+ optional_elements: [
295
+ 'sections.promotion.deck_style',
296
+ 'sections.promotion.tts_config',
297
+ 'sections.promotion.video_assembly_plan',
298
+ 'sections.promotion.homepage_plan',
299
+ ],
300
+ quality_gate: [
301
+ 'Slide outline and homepage plan cover key paper contributions',
302
+ 'Deck style defined for visual consistency',
303
+ ],
304
+ task_blueprints: [
305
+ {
306
+ id: 'promotion_draft_outline',
307
+ title: 'Draft slide outline and narration scripts',
308
+ description: 'Create per-slide content plan with talking points based on paper contributions.',
309
+ taskType: 'scripting',
310
+ },
311
+ {
312
+ id: 'promotion_prepare_homepage',
313
+ title: 'Prepare research homepage content and assets',
314
+ description: 'Organize homepage sections, key visuals, and links for project promotion.',
315
+ taskType: 'delivery',
316
+ },
317
+ {
318
+ id: 'promotion_generate_slides',
319
+ title: 'Generate slide images from outline and paper figures',
320
+ description: 'Use nanobanana to render slide images, preferring /edit on existing HQ paper figures.',
321
+ taskType: 'rendering',
322
+ },
323
+ {
324
+ id: 'promotion_generate_narration',
325
+ title: 'Generate TTS audio for slide narration',
326
+ description: 'Generate one audio file per slide using edge-tts (default), Kokoro (offline), or ElevenLabs (premium).',
327
+ taskType: 'narration',
328
+ },
329
+ ],
330
+ recommended_skills: map.promotion?.base || map.presentation?.base || ['making-academic-presentations'],
331
+ },
332
+ },
333
+ };
334
+ }
335
+ const TEMPLATES_DIR = path.resolve(__dirname, '..', 'taskmaster-templates');
336
+ const DEFAULT_PIPELINE_CONFIG = {
337
+ version: '1.0',
338
+ provider: 'dr-claw-web',
339
+ initializedAt: new Date().toISOString(),
340
+ };
341
+ let cachedTemplates = null;
342
+
343
+ function getPipelinePaths(projectPath) {
344
+ const pipelineRoot = path.join(projectPath, PIPELINE_DIR);
345
+ return {
346
+ root: pipelineRoot,
347
+ tasksDir: path.join(pipelineRoot, 'tasks'),
348
+ tasksFile: path.join(pipelineRoot, 'tasks', 'tasks.json'),
349
+ docsDir: path.join(pipelineRoot, 'docs'),
350
+ configFile: path.join(pipelineRoot, 'config.json'),
351
+ legacyRoot: path.join(projectPath, LEGACY_TASKMASTER_DIR),
352
+ };
353
+ }
354
+
355
+ async function pathExists(filePath) {
356
+ try {
357
+ await fsPromises.access(filePath);
358
+ return true;
359
+ } catch {
360
+ return false;
361
+ }
362
+ }
363
+
364
+ async function migrateLegacyTaskmasterIfNeeded(projectPath) {
365
+ const paths = getPipelinePaths(projectPath);
366
+ const hasPipeline = await pathExists(paths.root);
367
+ const hasLegacy = await pathExists(paths.legacyRoot);
368
+ if (hasPipeline || !hasLegacy) {
369
+ return;
370
+ }
371
+
372
+ await fsPromises.cp(paths.legacyRoot, paths.root, { recursive: true, force: false });
373
+ }
374
+
375
+ async function ensurePipelineInitialized(projectPath) {
376
+ const paths = getPipelinePaths(projectPath);
377
+ await migrateLegacyTaskmasterIfNeeded(projectPath);
378
+ await fsPromises.mkdir(paths.tasksDir, { recursive: true });
379
+ await fsPromises.mkdir(paths.docsDir, { recursive: true });
380
+
381
+ if (!(await pathExists(paths.configFile))) {
382
+ await fsPromises.writeFile(paths.configFile, `${JSON.stringify(DEFAULT_PIPELINE_CONFIG, null, 2)}\n`, 'utf8');
383
+ }
384
+
385
+ if (!(await pathExists(paths.tasksFile))) {
386
+ const initial = { [DEFAULT_TASKS_TAG]: { tasks: [] } };
387
+ await fsPromises.writeFile(paths.tasksFile, `${JSON.stringify(initial, null, 2)}\n`, 'utf8');
388
+ }
389
+
390
+ return paths;
391
+ }
392
+
393
+ function extractTasksFromData(tasksData) {
394
+ let currentTag = DEFAULT_TASKS_TAG;
395
+ let tasks = [];
396
+
397
+ if (Array.isArray(tasksData)) {
398
+ tasks = tasksData;
399
+ } else if (tasksData?.tasks) {
400
+ tasks = tasksData.tasks;
401
+ } else if (tasksData && typeof tasksData === 'object') {
402
+ if (tasksData[currentTag]?.tasks) {
403
+ tasks = tasksData[currentTag].tasks;
404
+ } else if (tasksData.master?.tasks) {
405
+ tasks = tasksData.master.tasks;
406
+ currentTag = 'master';
407
+ } else {
408
+ const firstTag = Object.keys(tasksData).find((key) => Array.isArray(tasksData[key]?.tasks));
409
+ if (firstTag) {
410
+ currentTag = firstTag;
411
+ tasks = tasksData[firstTag].tasks;
412
+ }
413
+ }
414
+ }
415
+
416
+ return { tasks: Array.isArray(tasks) ? tasks : [], currentTag };
417
+ }
418
+
419
+ function normalizeTask(task) {
420
+ const now = new Date().toISOString();
421
+ const stage = normalizeStageName(task.stage);
422
+ return {
423
+ id: task.id,
424
+ title: task.title || 'Untitled Task',
425
+ description: task.description || '',
426
+ status: normalizeTaskStatus(task.status),
427
+ priority: task.priority || 'medium',
428
+ dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
429
+ createdAt: task.createdAt || task.created || now,
430
+ updatedAt: task.updatedAt || task.updated || now,
431
+ details: task.details || '',
432
+ testStrategy: task.testStrategy || task.test_strategy || '',
433
+ subtasks: Array.isArray(task.subtasks) ? task.subtasks : [],
434
+ stage: stage || undefined,
435
+ taskType: task.taskType || 'implementation',
436
+ inputsNeeded: Array.isArray(task.inputsNeeded) ? task.inputsNeeded.filter(Boolean) : [],
437
+ suggestedSkills: Array.isArray(task.suggestedSkills) ? task.suggestedSkills.filter(Boolean) : [],
438
+ sourceBlueprintId: task.sourceBlueprintId || '',
439
+ nextActionPrompt: typeof task.nextActionPrompt === 'string' ? task.nextActionPrompt : '',
440
+ };
441
+ }
442
+
443
+ function normalizeTaskStatus(status) {
444
+ const raw = String(status || '').trim().toLowerCase();
445
+ if (!raw) return 'pending';
446
+ if (raw === 'completed' || raw === 'complete') return 'done';
447
+ if (raw === 'in_progress' || raw === 'inprogress') return 'in-progress';
448
+ if (raw === 'todo' || raw === 'open') return 'pending';
449
+ return raw;
450
+ }
451
+
452
+ async function readTasksFile(tasksFilePath) {
453
+ const content = await fsPromises.readFile(tasksFilePath, 'utf8');
454
+ const parsed = JSON.parse(content);
455
+ const { tasks, currentTag } = extractTasksFromData(parsed);
456
+ return {
457
+ raw: parsed,
458
+ currentTag,
459
+ tasks: tasks.map(normalizeTask),
460
+ };
461
+ }
462
+
463
+ async function writeTasksFile(tasksFilePath, tasks, currentTag = DEFAULT_TASKS_TAG) {
464
+ const payload = { [currentTag]: { tasks } };
465
+ await fsPromises.writeFile(tasksFilePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
466
+ }
467
+
468
+ function generateTaskId(tasks) {
469
+ const numericIds = tasks
470
+ .map((task) => Number(task.id))
471
+ .filter((value) => Number.isFinite(value));
472
+ if (numericIds.length === 0) {
473
+ return 1;
474
+ }
475
+ return Math.max(...numericIds) + 1;
476
+ }
477
+
478
+ /**
479
+ * Reassign all task IDs sequentially (1, 2, 3, ...) following global stage order.
480
+ * Preserves array order within each stage group and remaps dependency references.
481
+ */
482
+ function reindexTasks(tasks) {
483
+ const staged = {};
484
+ STAGE_ORDER.forEach((s) => { staged[s] = []; });
485
+ const unassigned = [];
486
+
487
+ for (const task of tasks) {
488
+ const s = normalizeStageName(task.stage);
489
+ if (s && staged[s]) {
490
+ staged[s].push(task);
491
+ } else {
492
+ unassigned.push(task);
493
+ }
494
+ }
495
+
496
+ const ordered = [];
497
+ STAGE_ORDER.forEach((s) => { ordered.push(...staged[s]); });
498
+ ordered.push(...unassigned);
499
+
500
+ const idMap = {};
501
+ ordered.forEach((task, idx) => {
502
+ idMap[String(task.id)] = idx + 1;
503
+ });
504
+
505
+ return ordered.map((task, idx) => ({
506
+ ...task,
507
+ id: idx + 1,
508
+ dependencies: Array.isArray(task.dependencies)
509
+ ? task.dependencies
510
+ .map((dep) => idMap[String(dep)])
511
+ .filter(Boolean)
512
+ : [],
513
+ updatedAt: new Date().toISOString(),
514
+ }));
515
+ }
516
+
517
+ function splitPromptToTitle(prompt) {
518
+ const cleaned = String(prompt || '').replace(/\s+/g, ' ').trim();
519
+ if (!cleaned) {
520
+ return 'Untitled Task';
521
+ }
522
+ return cleaned.length > 120 ? `${cleaned.slice(0, 117)}...` : cleaned;
523
+ }
524
+
525
+ function assignPath(target, dottedPath, value) {
526
+ const keys = String(dottedPath || '').split('.').filter(Boolean);
527
+ if (keys.length === 0) return;
528
+ let cursor = target;
529
+ for (let i = 0; i < keys.length - 1; i += 1) {
530
+ const key = keys[i];
531
+ if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) {
532
+ cursor[key] = {};
533
+ }
534
+ cursor = cursor[key];
535
+ }
536
+ cursor[keys[keys.length - 1]] = value;
537
+ }
538
+
539
+ function toTaskCandidate(raw) {
540
+ const value = String(raw || '').replace(/\s+/g, ' ').trim();
541
+ if (!value) return null;
542
+ return value.length > 180 ? `${value.slice(0, 177)}...` : value;
543
+ }
544
+
545
+ function isPlaceholderLikeValue(value = '') {
546
+ const normalized = String(value).trim().toLowerCase();
547
+ if (!normalized) return true;
548
+ const blocked = new Set([
549
+ 'none', 'null', 'n/a', 'na', 'todo', 'tbd', 'unknown', 'not sure', '-',
550
+ '[]', '{}', 'placeholder',
551
+ ]);
552
+ return blocked.has(normalized);
553
+ }
554
+
555
+ function buildFallbackTaskCandidates(briefData = {}) {
556
+ const title = String(briefData?.meta?.title || '').trim();
557
+ const target = title ? ` for "${title}"` : '';
558
+ return [
559
+ `Finalize Survey section${target}: define literature scope, summarize prior work, and record open gaps.`,
560
+ `Finalize Ideation section${target}: clarify research goal, framing, and evidence plan.`,
561
+ `Define Experiment section${target}: specify validation goal, protocol, and evaluation plan.`,
562
+ `Prepare Publication section${target}: draft paper outline, figures/tables plan, and submission checklist.`,
563
+ `Create Promotion section${target}: draft homepage plan, slide outline, deck style, TTS config, and video assembly plan.`,
564
+ ];
565
+ }
566
+
567
+ function buildPipelineSkeletonCandidates(briefData = {}) {
568
+ const title = String(briefData?.meta?.title || '').trim();
569
+ const target = title ? ` (${title})` : '';
570
+ return [
571
+ `Survey: define literature scope and search boundary${target}.`,
572
+ `Survey: synthesize key baselines, trends, and open gaps${target}.`,
573
+ `Ideation: clarify problem framing and research goal${target}.`,
574
+ `Ideation: collect key evidence and references${target}.`,
575
+ `Ideation: define measurable success criteria${target}.`,
576
+ `Experiment: define validation hypothesis and evaluation criteria${target}.`,
577
+ `Experiment: prepare data source and method/protocol plan${target}.`,
578
+ `Experiment: execute baseline validation and analyze results${target}.`,
579
+ `Publication: draft paper outline and contribution boundaries${target}.`,
580
+ `Publication: prepare figures/tables and artifact appendix${target}.`,
581
+ `Publication: complete submission checklist and final review${target}.`,
582
+ `Promotion: prepare project homepage structure and assets${target}.`,
583
+ `Promotion: draft slide outline and narration scripts${target}.`,
584
+ `Promotion: generate slide images from paper figures${target}.`,
585
+ `Promotion: generate TTS audio and assemble demo video${target}.`,
586
+ ];
587
+ }
588
+
589
+ function parseBriefJsonToTaskCandidates(briefData = {}) {
590
+ const candidates = [];
591
+ const sectionOrder = ['ideation', 'experiment', 'publication', 'promotion', 'presentation'];
592
+ const sectionData = briefData?.sections && typeof briefData.sections === 'object'
593
+ ? briefData.sections
594
+ : {};
595
+
596
+ sectionOrder.forEach((sectionName) => {
597
+ const fields = sectionData[sectionName];
598
+ if (!fields || typeof fields !== 'object') return;
599
+ Object.values(fields).forEach((value) => {
600
+ if (Array.isArray(value)) {
601
+ value.forEach((item) => {
602
+ const normalized = toTaskCandidate(item);
603
+ if (normalized) candidates.push(normalized);
604
+ });
605
+ return;
606
+ }
607
+ const normalized = toTaskCandidate(value);
608
+ if (normalized && !isPlaceholderLikeValue(normalized)) candidates.push(normalized);
609
+ });
610
+ });
611
+
612
+ const dynamic = [...new Set(candidates)];
613
+ const skeleton = buildPipelineSkeletonCandidates(briefData);
614
+ const fallback = dynamic.length > 0 ? [] : buildFallbackTaskCandidates(briefData);
615
+ return [...new Set([...skeleton, ...dynamic, ...fallback])];
616
+ }
617
+
618
+ function inferStageFromCandidate(text = '') {
619
+ const value = String(text || '').toLowerCase();
620
+ if (value.includes('ideation')) return 'ideation';
621
+ if (value.includes('experiment') || value.includes('validation') || value.includes('baseline')) return 'experiment';
622
+ if (value.includes('publication') || value.includes('paper') || value.includes('submission')) return 'publication';
623
+ if (
624
+ value.includes('promotion')
625
+ || value.includes('presentation')
626
+ || value.includes('slide')
627
+ || value.includes('deck')
628
+ || value.includes('demo video')
629
+ || value.includes('homepage')
630
+ ) return 'promotion';
631
+ return null;
632
+ }
633
+
634
+ function normalizeStageName(stage) {
635
+ const value = String(stage || '').trim().toLowerCase();
636
+ if (value === 'presentation') return 'promotion';
637
+ if (value === 'research') return 'survey';
638
+ if (value === 'survey' || value === 'ideation' || value === 'experiment' || value === 'publication' || value === 'promotion') {
639
+ return value;
640
+ }
641
+ return null;
642
+ }
643
+
644
+ function titleFromBlueprintId(sourceBlueprintId = '', stage = '') {
645
+ const cleaned = String(sourceBlueprintId || '').replace(/[_-]+/g, ' ').trim();
646
+ const title = cleaned
647
+ ? cleaned.replace(/\b\w/g, (ch) => ch.toUpperCase())
648
+ : `Execute ${STAGE_LABELS[stage] || 'Pipeline'} task`;
649
+ return title.length > 120 ? `${title.slice(0, 117)}...` : title;
650
+ }
651
+
652
+ function getValueByPath(target, dottedPath) {
653
+ if (!target || typeof target !== 'object') return undefined;
654
+ const keys = String(dottedPath || '').split('.').filter(Boolean);
655
+ if (keys.length === 0) return undefined;
656
+ let cursor = target;
657
+ for (const key of keys) {
658
+ if (cursor === null || cursor === undefined || typeof cursor !== 'object') {
659
+ return undefined;
660
+ }
661
+ cursor = cursor[key];
662
+ }
663
+ return cursor;
664
+ }
665
+
666
+ function hasMeaningfulValue(value) {
667
+ if (value === null || value === undefined) return false;
668
+ if (typeof value === 'string') return !isPlaceholderLikeValue(value);
669
+ if (Array.isArray(value)) return value.some((item) => hasMeaningfulValue(item));
670
+ if (typeof value === 'object') return Object.keys(value).length > 0;
671
+ return true;
672
+ }
673
+
674
+ function computeMissingElements(briefData = {}, requiredElements = []) {
675
+ if (!Array.isArray(requiredElements)) return [];
676
+ return requiredElements
677
+ .map((pathName) => String(pathName || '').trim())
678
+ .filter(Boolean)
679
+ .filter((pathName) => !hasMeaningfulValue(getValueByPath(briefData, pathName)));
680
+ }
681
+
682
+ function ensureArray(value) {
683
+ if (!value) return [];
684
+ if (Array.isArray(value)) return value;
685
+ return [value];
686
+ }
687
+
688
+ function dedupeStringList(values = []) {
689
+ return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))];
690
+ }
691
+
692
+ function formatInputValue(value) {
693
+ if (value === null || value === undefined) return '';
694
+ if (typeof value === 'string') {
695
+ return value.trim();
696
+ }
697
+ if (Array.isArray(value)) {
698
+ return value
699
+ .map((item) => String(item ?? '').trim())
700
+ .filter(Boolean)
701
+ .join('\n');
702
+ }
703
+ try {
704
+ return JSON.stringify(value, null, 2);
705
+ } catch {
706
+ return String(value);
707
+ }
708
+ }
709
+
710
+ function toTitleCase(text = '') {
711
+ return String(text || '')
712
+ .split(/\s+/)
713
+ .filter(Boolean)
714
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
715
+ .join(' ');
716
+ }
717
+
718
+ function formatFieldDisplayName(pathName = '') {
719
+ const cleaned = String(pathName || '').trim();
720
+ if (!cleaned) return 'Required Field';
721
+ const parts = cleaned.split('.').filter(Boolean);
722
+ const fieldKey = parts[parts.length - 1] || cleaned;
723
+ const stageKey = parts[1] && STAGE_LABELS[parts[1]] ? STAGE_LABELS[parts[1]] : '';
724
+ const fieldLabel = toTitleCase(fieldKey.replace(/[_-]+/g, ' '));
725
+ return stageKey ? `${stageKey} ${fieldLabel}` : fieldLabel;
726
+ }
727
+
728
+ function resolveTaskSkills(stage, taskType, stageConfiguredSkills = [], blueprintSkills = []) {
729
+ const stageMap = getStageSkillMap()[stage] || {};
730
+ const fromStageBase = ensureArray(stageMap.base);
731
+ const fromTaskType = ensureArray(stageMap.byTaskType?.[taskType]);
732
+ return dedupeStringList([
733
+ ...ensureArray(stageConfiguredSkills),
734
+ ...fromStageBase,
735
+ ...fromTaskType,
736
+ ...ensureArray(blueprintSkills),
737
+ ]);
738
+ }
739
+
740
+ function buildTaskNextActionPrompt(task = {}, stageConfig = {}, stage = '', briefData = {}) {
741
+ const lines = [
742
+ `Task: ${task.title || 'Untitled Task'}`,
743
+ `Stage: ${STAGE_LABELS[stage] || stage || 'Unknown'}`,
744
+ ];
745
+
746
+ const requiredInputs = dedupeStringList(Array.isArray(task.inputsNeeded) ? task.inputsNeeded : []);
747
+ const missingInputs = [];
748
+ const providedInputs = [];
749
+ requiredInputs.forEach((pathName) => {
750
+ const value = getValueByPath(briefData, pathName);
751
+ if (hasMeaningfulValue(value)) {
752
+ providedInputs.push({ pathName, value: formatInputValue(value) });
753
+ } else {
754
+ missingInputs.push(pathName);
755
+ }
756
+ });
757
+
758
+ if (missingInputs.length > 0) {
759
+ lines.push(`Missing inputs: ${missingInputs.join(', ')}`);
760
+ }
761
+ if (providedInputs.length === 1) {
762
+ lines.push(`User inputs: "${providedInputs[0].value}"`);
763
+ } else if (providedInputs.length > 1) {
764
+ lines.push('User inputs:');
765
+ providedInputs.forEach((entry) => {
766
+ lines.push(`- ${entry.pathName}: "${entry.value}"`);
767
+ });
768
+ }
769
+
770
+ const suggestedSkills = Array.isArray(task.suggestedSkills) ? task.suggestedSkills : [];
771
+ if (suggestedSkills.length > 0) {
772
+ lines.push(`Suggested skills: ${suggestedSkills.join(', ')}`);
773
+ }
774
+
775
+ const qualityGate = Array.isArray(stageConfig.quality_gate) ? stageConfig.quality_gate : [];
776
+ if (qualityGate.length > 0 && task.taskType === 'analysis') {
777
+ lines.push(`Quality gate checklist: ${qualityGate.join(' | ')}`);
778
+ }
779
+
780
+ if (STAGE_PROMPT_HINTS[stage]) {
781
+ lines.push(`Stage guidance: ${STAGE_PROMPT_HINTS[stage]}`);
782
+ }
783
+
784
+ if (providedInputs.length > 0) {
785
+ lines.push('Please produce a concrete next step plan and execution output. If user inputs are provided, polish and make them concrete, then write updates back to .pipeline/docs/research_brief.json.');
786
+ } else {
787
+ lines.push('Please produce a concrete next step plan and execution output. If key inputs are missing, propose precise placeholders and write updates back to .pipeline/docs/research_brief.json.');
788
+ }
789
+ return lines.join('\n');
790
+ }
791
+
792
+ function instantiatePipelineTasksFromBrief(briefData = {}, numTasks = DEFAULT_MAX_TASKS) {
793
+ const pipelineStages = briefData?.pipeline?.stages && typeof briefData.pipeline.stages === 'object'
794
+ ? briefData.pipeline.stages
795
+ : null;
796
+ if (!pipelineStages) return null;
797
+
798
+ const now = new Date().toISOString();
799
+ const generated = [];
800
+ const maxTasks = Number.isFinite(Number(numTasks)) && Number(numTasks) > 0 ? Number(numTasks) : DEFAULT_MAX_TASKS;
801
+
802
+ // Respect pipeline.startStage — only generate tasks for stages >= startStage
803
+ const startStage = normalizeStageName(briefData?.pipeline?.startStage) || 'survey';
804
+ const startIdx = STAGE_ORDER.indexOf(startStage);
805
+ const activeStages = startIdx > 0 ? STAGE_ORDER.slice(startIdx) : STAGE_ORDER;
806
+
807
+ for (const stage of activeStages) {
808
+ const stageConfig = pipelineStages?.[stage]
809
+ || (stage === 'promotion' ? pipelineStages?.presentation : null);
810
+ if (!stageConfig || typeof stageConfig !== 'object') continue;
811
+
812
+ const stageSkills = resolveTaskSkills(stage, '', stageConfig.recommended_skills, []);
813
+ const stageRequiredElements = dedupeStringList(ensureArray(stageConfig.required_elements));
814
+ const missingElements = computeMissingElements(briefData, stageConfig.required_elements);
815
+ const stageBlueprints = ensureArray(stageConfig.task_blueprints);
816
+
817
+ stageBlueprints.forEach((blueprintNode, index) => {
818
+ const blueprint = typeof blueprintNode === 'string'
819
+ ? { id: blueprintNode }
820
+ : (blueprintNode && typeof blueprintNode === 'object' ? blueprintNode : { id: `${stage}_task_${index + 1}` });
821
+ const sourceBlueprintId = String(blueprint.id || `${stage}_task_${index + 1}`);
822
+ const task = normalizeTask({
823
+ id: generated.length + 1,
824
+ title: blueprint.title || titleFromBlueprintId(sourceBlueprintId, stage),
825
+ description: blueprint.description || `Execute ${STAGE_LABELS[stage] || stage} task from pipeline blueprint.`,
826
+ status: 'pending',
827
+ priority: blueprint.priority || 'medium',
828
+ dependencies: Array.isArray(blueprint.dependencies) ? blueprint.dependencies : [],
829
+ createdAt: now,
830
+ updatedAt: now,
831
+ stage,
832
+ taskType: blueprint.taskType || 'implementation',
833
+ inputsNeeded: dedupeStringList([
834
+ ...ensureArray(blueprint.inputsNeeded),
835
+ ...stageRequiredElements,
836
+ ]),
837
+ suggestedSkills: resolveTaskSkills(stage, blueprint.taskType || 'implementation', stageSkills, blueprint.recommended_skills),
838
+ sourceBlueprintId,
839
+ nextActionPrompt: blueprint.nextActionPrompt || '',
840
+ });
841
+ task.nextActionPrompt = task.nextActionPrompt || buildTaskNextActionPrompt(task, stageConfig, stage, briefData);
842
+ generated.push(task);
843
+ });
844
+
845
+ stageRequiredElements.forEach((requiredPath) => {
846
+ const value = getValueByPath(briefData, requiredPath);
847
+ const hasValue = hasMeaningfulValue(value);
848
+ const fieldDisplayName = formatFieldDisplayName(requiredPath);
849
+ const task = normalizeTask({
850
+ id: generated.length + 1,
851
+ title: hasValue ? `Refine ${fieldDisplayName}` : `Define ${fieldDisplayName}`,
852
+ description: hasValue
853
+ ? `The required field "${requiredPath}" exists but may still be vague. Refine it into concrete, testable language.`
854
+ : `The required field "${requiredPath}" is missing or unclear. Clarify it before stage completion.`,
855
+ status: 'pending',
856
+ priority: hasValue ? 'medium' : 'high',
857
+ dependencies: [],
858
+ createdAt: now,
859
+ updatedAt: now,
860
+ stage,
861
+ taskType: hasValue ? 'analysis' : 'exploration',
862
+ inputsNeeded: [requiredPath],
863
+ suggestedSkills: resolveTaskSkills(stage, hasValue ? 'analysis' : 'exploration', stageSkills, []),
864
+ sourceBlueprintId: hasValue ? `${stage}.refine.${requiredPath}` : `${stage}.missing.${requiredPath}`,
865
+ nextActionPrompt: '',
866
+ });
867
+ task.nextActionPrompt = buildTaskNextActionPrompt(task, stageConfig, stage, briefData);
868
+ generated.push(task);
869
+ });
870
+
871
+ const qualityGate = Array.isArray(stageConfig.quality_gate)
872
+ ? stageConfig.quality_gate.map((item) => String(item || '').trim()).filter(Boolean)
873
+ : [];
874
+ if (qualityGate.length > 0) {
875
+ const task = normalizeTask({
876
+ id: generated.length + 1,
877
+ title: `Review ${STAGE_LABELS[stage] || stage} quality gate before moving forward`,
878
+ description: `Complete and verify ${STAGE_LABELS[stage] || stage} quality gate criteria.`,
879
+ status: 'pending',
880
+ priority: 'medium',
881
+ dependencies: [],
882
+ createdAt: now,
883
+ updatedAt: now,
884
+ stage,
885
+ taskType: 'analysis',
886
+ inputsNeeded: [],
887
+ suggestedSkills: resolveTaskSkills(stage, 'analysis', stageSkills, []),
888
+ sourceBlueprintId: `${stage}.quality_gate`,
889
+ nextActionPrompt: '',
890
+ });
891
+ task.nextActionPrompt = buildTaskNextActionPrompt(task, stageConfig, stage, briefData);
892
+ generated.push(task);
893
+ }
894
+ }
895
+
896
+ const normalized = generated.slice(0, maxTasks).map((task, index) => normalizeTask({
897
+ ...task,
898
+ id: index + 1,
899
+ }));
900
+
901
+ return normalized.length > 0 ? normalized : null;
902
+ }
903
+
904
+ function computeNextGuidance(tasks = []) {
905
+ const allTasks = Array.isArray(tasks) ? tasks : [];
906
+ const doneIds = new Set(
907
+ allTasks
908
+ .filter((task) => String(task.status || '').toLowerCase() === 'done')
909
+ .map((task) => String(task.id)),
910
+ );
911
+
912
+ const inProgress = allTasks.find((task) => String(task.status || '').toLowerCase() === 'in-progress') || null;
913
+ if (inProgress) {
914
+ return {
915
+ nextTask: inProgress,
916
+ whyNext: 'This task is already in progress and should be finished first.',
917
+ requiredInputs: Array.isArray(inProgress.inputsNeeded) ? inProgress.inputsNeeded : [],
918
+ suggestedSkills: Array.isArray(inProgress.suggestedSkills) ? inProgress.suggestedSkills : [],
919
+ nextActionPrompt: inProgress.nextActionPrompt || '',
920
+ };
921
+ }
922
+
923
+ const pendingTasks = allTasks.filter((task) => String(task.status || '').toLowerCase() === 'pending');
924
+ if (pendingTasks.length === 0) {
925
+ return {
926
+ nextTask: null,
927
+ whyNext: 'No pending or in-progress tasks available.',
928
+ requiredInputs: [],
929
+ suggestedSkills: [],
930
+ nextActionPrompt: '',
931
+ };
932
+ }
933
+
934
+ const readyTask = pendingTasks.find((task) => {
935
+ const deps = Array.isArray(task.dependencies) ? task.dependencies.map((dep) => String(dep)) : [];
936
+ return deps.every((dep) => doneIds.has(dep));
937
+ }) || pendingTasks[0];
938
+
939
+ const blockedDependencies = Array.isArray(readyTask.dependencies)
940
+ ? readyTask.dependencies.filter((dep) => !doneIds.has(String(dep)))
941
+ : [];
942
+ const whyNext = blockedDependencies.length === 0
943
+ ? 'This is the first actionable pending task based on dependency order.'
944
+ : `This pending task is recommended, but it still references unresolved dependencies: ${blockedDependencies.join(', ')}`;
945
+
946
+ return {
947
+ nextTask: readyTask,
948
+ whyNext,
949
+ requiredInputs: Array.isArray(readyTask.inputsNeeded) ? readyTask.inputsNeeded : [],
950
+ suggestedSkills: Array.isArray(readyTask.suggestedSkills) ? readyTask.suggestedSkills : [],
951
+ nextActionPrompt: readyTask.nextActionPrompt || '',
952
+ };
953
+ }
954
+
955
+ function computeTaskmasterStatus(taskMasterResult, mcpResult) {
956
+ let status = 'not-configured';
957
+ if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
958
+ if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
959
+ status = 'fully-configured';
960
+ } else {
961
+ status = 'taskmaster-only';
962
+ }
963
+ } else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
964
+ status = 'mcp-only';
965
+ }
966
+ return status;
967
+ }
968
+
969
+ function buildTaskmasterSummaryPayload({
970
+ projectName,
971
+ projectPath,
972
+ status,
973
+ tasks = [],
974
+ nextTask = null,
975
+ guidance = null,
976
+ updatedAt = new Date().toISOString(),
977
+ }) {
978
+ const tasksByStatus = tasks.reduce((acc, task) => {
979
+ const taskStatus = normalizeTaskStatus(task.status);
980
+ acc[taskStatus] = (acc[taskStatus] || 0) + 1;
981
+ return acc;
982
+ }, {
983
+ pending: 0,
984
+ 'in-progress': 0,
985
+ done: 0,
986
+ review: 0,
987
+ deferred: 0,
988
+ cancelled: 0,
989
+ blocked: 0,
990
+ });
991
+
992
+ const total = tasks.length;
993
+ const completed = tasksByStatus.done || 0;
994
+
995
+ return {
996
+ project: projectName,
997
+ status,
998
+ project_path: projectPath,
999
+ counts: {
1000
+ total,
1001
+ completed,
1002
+ in_progress: tasksByStatus['in-progress'] || 0,
1003
+ pending: tasksByStatus.pending || 0,
1004
+ blocked: tasksByStatus.blocked || 0,
1005
+ review: tasksByStatus.review || 0,
1006
+ deferred: tasksByStatus.deferred || 0,
1007
+ cancelled: tasksByStatus.cancelled || 0,
1008
+ completion_rate: total > 0 ? Math.round((completed / total) * 1000) / 10 : 0,
1009
+ },
1010
+ next_task: nextTask,
1011
+ guidance,
1012
+ updated_at: updatedAt,
1013
+ };
1014
+ }
1015
+
1016
+ function dedupeGeneratedTasks(existingTasks = [], generatedTasks = []) {
1017
+ const signature = (task) => `${String(task.title || '').trim().toLowerCase()}|${String(task.description || '').trim().toLowerCase()}`;
1018
+ const existingSignatures = new Set(existingTasks.map(signature));
1019
+ return generatedTasks.filter((task) => {
1020
+ const key = signature(task);
1021
+ if (existingSignatures.has(key)) return false;
1022
+ existingSignatures.add(key);
1023
+ return true;
1024
+ });
1025
+ }
1026
+
1027
+ /**
1028
+ * Check if TaskMaster CLI is installed globally
1029
+ * @returns {Promise<Object>} Installation status result
1030
+ */
1031
+ async function checkTaskMasterInstallation() {
1032
+ return {
1033
+ isInstalled: true,
1034
+ installPath: PIPELINE_DIR,
1035
+ version: 'web-native',
1036
+ reason: null,
1037
+ };
1038
+ }
1039
+
1040
+ /**
1041
+ * Detect .pipeline folder presence in a given project directory
1042
+ * @param {string} projectPath - Absolute path to project directory
1043
+ * @returns {Promise<Object>} Detection result with status and metadata
1044
+ */
1045
+ async function detectTaskMasterFolder(projectPath) {
1046
+ try {
1047
+ await migrateLegacyTaskmasterIfNeeded(projectPath);
1048
+ const taskMasterPath = getPipelinePaths(projectPath).root;
1049
+
1050
+ // Check if .pipeline directory exists
1051
+ try {
1052
+ const stats = await fsPromises.stat(taskMasterPath);
1053
+ if (!stats.isDirectory()) {
1054
+ return {
1055
+ hasTaskmaster: false,
1056
+ reason: '.pipeline exists but is not a directory'
1057
+ };
1058
+ }
1059
+ } catch (error) {
1060
+ if (error.code === 'ENOENT') {
1061
+ return {
1062
+ hasTaskmaster: false,
1063
+ reason: '.pipeline directory not found'
1064
+ };
1065
+ }
1066
+ throw error;
1067
+ }
1068
+
1069
+ // Check for key TaskMaster files
1070
+ const keyFiles = [
1071
+ 'tasks/tasks.json',
1072
+ 'docs/research_brief.json',
1073
+ 'config.json'
1074
+ ];
1075
+
1076
+ const fileStatus = {};
1077
+ let hasEssentialFiles = true;
1078
+
1079
+ for (const file of keyFiles) {
1080
+ const filePath = path.join(taskMasterPath, file);
1081
+ try {
1082
+ await fsPromises.access(filePath, fs.constants.R_OK);
1083
+ fileStatus[file] = true;
1084
+ } catch (error) {
1085
+ fileStatus[file] = false;
1086
+ if (file === 'tasks/tasks.json') {
1087
+ hasEssentialFiles = false;
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ // Parse tasks.json if it exists for metadata
1093
+ let taskMetadata = null;
1094
+ if (fileStatus['tasks/tasks.json']) {
1095
+ try {
1096
+ const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
1097
+ const tasksContent = await fsPromises.readFile(tasksPath, 'utf8');
1098
+ const tasksData = JSON.parse(tasksContent);
1099
+ const { tasks } = extractTasksFromData(tasksData);
1100
+
1101
+ // Calculate task statistics
1102
+ const stats = tasks.reduce((acc, task) => {
1103
+ const taskStatus = normalizeTaskStatus(task.status);
1104
+ acc.total++;
1105
+ acc[taskStatus] = (acc[taskStatus] || 0) + 1;
1106
+
1107
+ // Count subtasks
1108
+ if (task.subtasks) {
1109
+ task.subtasks.forEach(subtask => {
1110
+ const subtaskStatus = normalizeTaskStatus(subtask.status);
1111
+ acc.subtotalTasks++;
1112
+ acc.subtasks = acc.subtasks || {};
1113
+ acc.subtasks[subtaskStatus] = (acc.subtasks[subtaskStatus] || 0) + 1;
1114
+ });
1115
+ }
1116
+
1117
+ return acc;
1118
+ }, {
1119
+ total: 0,
1120
+ subtotalTasks: 0,
1121
+ pending: 0,
1122
+ 'in-progress': 0,
1123
+ done: 0,
1124
+ review: 0,
1125
+ deferred: 0,
1126
+ cancelled: 0,
1127
+ subtasks: {}
1128
+ });
1129
+
1130
+ taskMetadata = {
1131
+ taskCount: stats.total,
1132
+ subtaskCount: stats.subtotalTasks,
1133
+ completed: stats.done || 0,
1134
+ pending: stats.pending || 0,
1135
+ inProgress: stats['in-progress'] || 0,
1136
+ review: stats.review || 0,
1137
+ completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
1138
+ lastModified: (await fsPromises.stat(tasksPath)).mtime.toISOString()
1139
+ };
1140
+ } catch (parseError) {
1141
+ console.warn('Failed to parse tasks.json:', parseError.message);
1142
+ taskMetadata = { error: 'Failed to parse tasks.json' };
1143
+ }
1144
+ }
1145
+
1146
+ const hasResearchBrief = fileStatus['docs/research_brief.json'] === true;
1147
+
1148
+ return {
1149
+ hasTaskmaster: true,
1150
+ hasEssentialFiles,
1151
+ hasResearchBrief,
1152
+ files: fileStatus,
1153
+ metadata: taskMetadata,
1154
+ path: taskMasterPath
1155
+ };
1156
+
1157
+ } catch (error) {
1158
+ console.error('Error detecting TaskMaster folder:', error);
1159
+ return {
1160
+ hasTaskmaster: false,
1161
+ reason: `Error checking directory: ${error.message}`
1162
+ };
1163
+ }
1164
+ }
1165
+
1166
+ // MCP detection is now handled by the centralized utility
1167
+
1168
+ // API Routes
1169
+
1170
+ /**
1171
+ * GET /api/taskmaster/installation-status
1172
+ * Check if TaskMaster CLI is installed on the system
1173
+ */
1174
+ router.get('/installation-status', async (req, res) => {
1175
+ try {
1176
+ const installationStatus = await checkTaskMasterInstallation();
1177
+
1178
+ // Also check for MCP server configuration
1179
+ const mcpStatus = await detectTaskMasterMCPServer();
1180
+
1181
+ res.json({
1182
+ success: true,
1183
+ installation: installationStatus,
1184
+ mcpServer: mcpStatus,
1185
+ isReady: installationStatus.isInstalled && mcpStatus.hasMCPServer
1186
+ });
1187
+ } catch (error) {
1188
+ console.error('Error checking TaskMaster installation:', error);
1189
+ res.status(500).json({
1190
+ success: false,
1191
+ error: 'Failed to check TaskMaster installation status',
1192
+ installation: {
1193
+ isInstalled: false,
1194
+ reason: `Server error: ${error.message}`
1195
+ },
1196
+ mcpServer: {
1197
+ hasMCPServer: false,
1198
+ reason: `Server error: ${error.message}`
1199
+ },
1200
+ isReady: false
1201
+ });
1202
+ }
1203
+ });
1204
+
1205
+ /**
1206
+ * GET /api/taskmaster/detect/:projectName
1207
+ * Detect TaskMaster configuration for a specific project
1208
+ */
1209
+ router.get('/detect/:projectName', async (req, res) => {
1210
+ try {
1211
+ const { projectName } = req.params;
1212
+
1213
+ // Use the existing extractProjectDirectory function to get actual project path
1214
+ let projectPath;
1215
+ try {
1216
+ projectPath = await extractProjectDirectory(projectName);
1217
+ } catch (error) {
1218
+ console.error('Error extracting project directory:', error);
1219
+ return res.status(404).json({
1220
+ error: 'Project path not found',
1221
+ projectName,
1222
+ message: error.message
1223
+ });
1224
+ }
1225
+
1226
+ // Verify the project path exists
1227
+ try {
1228
+ await fsPromises.access(projectPath, fs.constants.R_OK);
1229
+ } catch (error) {
1230
+ return res.status(404).json({
1231
+ error: 'Project path not accessible',
1232
+ projectPath,
1233
+ projectName,
1234
+ message: error.message
1235
+ });
1236
+ }
1237
+
1238
+ // Run detection in parallel
1239
+ const [taskMasterResult, mcpResult] = await Promise.all([
1240
+ detectTaskMasterFolder(projectPath),
1241
+ detectTaskMasterMCPServer()
1242
+ ]);
1243
+
1244
+ const status = computeTaskmasterStatus(taskMasterResult, mcpResult);
1245
+
1246
+ const responseData = {
1247
+ projectName,
1248
+ projectPath,
1249
+ status,
1250
+ taskmaster: taskMasterResult,
1251
+ mcp: mcpResult,
1252
+ timestamp: new Date().toISOString()
1253
+ };
1254
+
1255
+ res.json(responseData);
1256
+
1257
+ } catch (error) {
1258
+ console.error('TaskMaster detection error:', error);
1259
+ res.status(500).json({
1260
+ error: 'Failed to detect TaskMaster configuration',
1261
+ message: error.message
1262
+ });
1263
+ }
1264
+ });
1265
+
1266
+ /**
1267
+ * GET /api/taskmaster/detect-all
1268
+ * Detect TaskMaster configuration for all known projects
1269
+ * This endpoint works with the existing projects system
1270
+ */
1271
+ router.get('/detect-all', async (req, res) => {
1272
+ try {
1273
+ // Import getProjects from the projects module
1274
+ const { getProjects } = await import('../projects.js');
1275
+ const projects = await getProjects();
1276
+
1277
+ // Run detection for all projects in parallel
1278
+ const detectionPromises = projects.map(async (project) => {
1279
+ try {
1280
+ // Use the project's fullPath if available, otherwise extract the directory
1281
+ let projectPath;
1282
+ if (project.fullPath) {
1283
+ projectPath = project.fullPath;
1284
+ } else {
1285
+ try {
1286
+ projectPath = await extractProjectDirectory(project.name);
1287
+ } catch (error) {
1288
+ throw new Error(`Failed to extract project directory: ${error.message}`);
1289
+ }
1290
+ }
1291
+
1292
+ const [taskMasterResult, mcpResult] = await Promise.all([
1293
+ detectTaskMasterFolder(projectPath),
1294
+ detectTaskMasterMCPServer()
1295
+ ]);
1296
+
1297
+ const status = computeTaskmasterStatus(taskMasterResult, mcpResult);
1298
+
1299
+ return {
1300
+ projectName: project.name,
1301
+ displayName: project.displayName,
1302
+ projectPath,
1303
+ status,
1304
+ taskmaster: taskMasterResult,
1305
+ mcp: mcpResult
1306
+ };
1307
+ } catch (error) {
1308
+ return {
1309
+ projectName: project.name,
1310
+ displayName: project.displayName,
1311
+ status: 'error',
1312
+ error: error.message
1313
+ };
1314
+ }
1315
+ });
1316
+
1317
+ const results = await Promise.all(detectionPromises);
1318
+
1319
+ res.json({
1320
+ projects: results,
1321
+ summary: {
1322
+ total: results.length,
1323
+ fullyConfigured: results.filter(p => p.status === 'fully-configured').length,
1324
+ taskmasterOnly: results.filter(p => p.status === 'taskmaster-only').length,
1325
+ mcpOnly: results.filter(p => p.status === 'mcp-only').length,
1326
+ notConfigured: results.filter(p => p.status === 'not-configured').length,
1327
+ errors: results.filter(p => p.status === 'error').length
1328
+ },
1329
+ timestamp: new Date().toISOString()
1330
+ });
1331
+
1332
+ } catch (error) {
1333
+ console.error('Bulk TaskMaster detection error:', error);
1334
+ res.status(500).json({
1335
+ error: 'Failed to detect TaskMaster configuration for projects',
1336
+ message: error.message
1337
+ });
1338
+ }
1339
+ });
1340
+
1341
+ /**
1342
+ * POST /api/taskmaster/initialize/:projectName
1343
+ * Initialize TaskMaster in a project (placeholder for future CLI integration)
1344
+ */
1345
+ router.post('/initialize/:projectName', async (req, res) => {
1346
+ try {
1347
+ const { projectName } = req.params;
1348
+ let projectPath;
1349
+ try {
1350
+ projectPath = await extractProjectDirectory(projectName);
1351
+ } catch (error) {
1352
+ return res.status(404).json({
1353
+ error: 'Project not found',
1354
+ message: `Project "${projectName}" does not exist`
1355
+ });
1356
+ }
1357
+
1358
+ const paths = await ensurePipelineInitialized(projectPath);
1359
+ res.json({
1360
+ projectName,
1361
+ projectPath,
1362
+ pipelinePath: paths.root,
1363
+ message: 'Pipeline initialized successfully',
1364
+ timestamp: new Date().toISOString()
1365
+ });
1366
+
1367
+ } catch (error) {
1368
+ console.error('TaskMaster initialization error:', error);
1369
+ res.status(500).json({
1370
+ error: 'Failed to initialize TaskMaster',
1371
+ message: error.message
1372
+ });
1373
+ }
1374
+ });
1375
+
1376
+ /**
1377
+ * GET /api/taskmaster/next/:projectName
1378
+ * Get the next recommended task from local pipeline tasks
1379
+ */
1380
+ router.get('/next/:projectName', async (req, res) => {
1381
+ try {
1382
+ const { projectName } = req.params;
1383
+
1384
+ // Get project path
1385
+ let projectPath;
1386
+ try {
1387
+ projectPath = await extractProjectDirectory(projectName);
1388
+ } catch (error) {
1389
+ return res.status(404).json({
1390
+ error: 'Project not found',
1391
+ message: `Project "${projectName}" does not exist`
1392
+ });
1393
+ }
1394
+
1395
+ const paths = await ensurePipelineInitialized(projectPath);
1396
+ const { tasks } = await readTasksFile(paths.tasksFile);
1397
+ const nextTask = tasks.find((task) => task.status === 'in-progress')
1398
+ || tasks.find((task) => task.status === 'pending')
1399
+ || null;
1400
+
1401
+ res.json({
1402
+ projectName,
1403
+ projectPath,
1404
+ nextTask,
1405
+ timestamp: new Date().toISOString(),
1406
+ });
1407
+
1408
+ } catch (error) {
1409
+ console.error('TaskMaster next task error:', error);
1410
+ res.status(500).json({
1411
+ error: 'Failed to get next task',
1412
+ message: error.message
1413
+ });
1414
+ }
1415
+ });
1416
+
1417
+ /**
1418
+ * GET /api/taskmaster/next-guidance/:projectName
1419
+ * Get next actionable task with guidance metadata for Chat handoff
1420
+ */
1421
+ router.get('/next-guidance/:projectName', async (req, res) => {
1422
+ try {
1423
+ const { projectName } = req.params;
1424
+ let projectPath;
1425
+ try {
1426
+ projectPath = await extractProjectDirectory(projectName);
1427
+ } catch (error) {
1428
+ return res.status(404).json({
1429
+ error: 'Project not found',
1430
+ message: `Project "${projectName}" does not exist`,
1431
+ });
1432
+ }
1433
+
1434
+ const paths = await ensurePipelineInitialized(projectPath);
1435
+ const { tasks } = await readTasksFile(paths.tasksFile);
1436
+ const guidance = computeNextGuidance(tasks);
1437
+
1438
+ res.json({
1439
+ projectName,
1440
+ projectPath,
1441
+ nextTask: guidance.nextTask,
1442
+ guidance: {
1443
+ whyNext: guidance.whyNext,
1444
+ requiredInputs: guidance.requiredInputs,
1445
+ suggestedSkills: guidance.suggestedSkills,
1446
+ nextActionPrompt: guidance.nextActionPrompt,
1447
+ },
1448
+ timestamp: new Date().toISOString(),
1449
+ });
1450
+ } catch (error) {
1451
+ console.error('TaskMaster next-guidance error:', error);
1452
+ res.status(500).json({
1453
+ error: 'Failed to get next guidance',
1454
+ message: error.message,
1455
+ });
1456
+ }
1457
+ });
1458
+
1459
+ /**
1460
+ * GET /api/taskmaster/summary/:projectName
1461
+ * Build a compact TaskMaster summary for CLI / OpenClaw reporting
1462
+ */
1463
+ router.get('/summary/:projectName', async (req, res) => {
1464
+ try {
1465
+ const { projectName } = req.params;
1466
+
1467
+ let projectPath;
1468
+ try {
1469
+ projectPath = await extractProjectDirectory(projectName);
1470
+ } catch (error) {
1471
+ return res.status(404).json({
1472
+ error: 'Project not found',
1473
+ message: `Project "${projectName}" does not exist`,
1474
+ });
1475
+ }
1476
+
1477
+ const paths = await ensurePipelineInitialized(projectPath);
1478
+ const [taskMasterResult, mcpResult, tasksResult] = await Promise.all([
1479
+ detectTaskMasterFolder(projectPath),
1480
+ detectTaskMasterMCPServer(),
1481
+ readTasksFile(paths.tasksFile),
1482
+ ]);
1483
+
1484
+ const tasks = tasksResult.tasks || [];
1485
+ const guidanceResult = computeNextGuidance(tasks);
1486
+ const status = computeTaskmasterStatus(taskMasterResult, mcpResult);
1487
+
1488
+ res.json(buildTaskmasterSummaryPayload({
1489
+ projectName,
1490
+ projectPath,
1491
+ status,
1492
+ tasks,
1493
+ nextTask: guidanceResult.nextTask,
1494
+ guidance: {
1495
+ whyNext: guidanceResult.whyNext,
1496
+ requiredInputs: guidanceResult.requiredInputs,
1497
+ suggestedSkills: guidanceResult.suggestedSkills,
1498
+ nextActionPrompt: guidanceResult.nextActionPrompt,
1499
+ },
1500
+ updatedAt: new Date().toISOString(),
1501
+ }));
1502
+ } catch (error) {
1503
+ console.error('TaskMaster summary error:', error);
1504
+ res.status(500).json({
1505
+ error: 'Failed to build TaskMaster summary',
1506
+ message: error.message,
1507
+ });
1508
+ }
1509
+ });
1510
+
1511
+ /**
1512
+ * GET /api/taskmaster/tasks/:projectName
1513
+ * Load actual tasks from .pipeline/tasks/tasks.json
1514
+ */
1515
+ router.get('/tasks/:projectName', async (req, res) => {
1516
+ try {
1517
+ const { projectName } = req.params;
1518
+
1519
+ // Get project path
1520
+ let projectPath;
1521
+ try {
1522
+ projectPath = await extractProjectDirectory(projectName);
1523
+ } catch (error) {
1524
+ return res.status(404).json({
1525
+ error: 'Project not found',
1526
+ message: `Project "${projectName}" does not exist`
1527
+ });
1528
+ }
1529
+
1530
+ const paths = await ensurePipelineInitialized(projectPath);
1531
+ const tasksFilePath = paths.tasksFile;
1532
+
1533
+ // Check if tasks file exists
1534
+ try {
1535
+ await fsPromises.access(tasksFilePath);
1536
+ } catch (error) {
1537
+ return res.json({
1538
+ projectName,
1539
+ tasks: [],
1540
+ message: 'No tasks.json file found'
1541
+ });
1542
+ }
1543
+
1544
+ // Read and parse tasks file
1545
+ try {
1546
+ const { tasks: transformedTasks, currentTag } = await readTasksFile(tasksFilePath);
1547
+
1548
+ res.json({
1549
+ projectName,
1550
+ projectPath,
1551
+ tasks: transformedTasks,
1552
+ currentTag,
1553
+ totalTasks: transformedTasks.length,
1554
+ tasksByStatus: {
1555
+ pending: transformedTasks.filter(t => t.status === 'pending').length,
1556
+ 'in-progress': transformedTasks.filter(t => t.status === 'in-progress').length,
1557
+ done: transformedTasks.filter(t => t.status === 'done').length,
1558
+ review: transformedTasks.filter(t => t.status === 'review').length,
1559
+ deferred: transformedTasks.filter(t => t.status === 'deferred').length,
1560
+ cancelled: transformedTasks.filter(t => t.status === 'cancelled').length
1561
+ },
1562
+ timestamp: new Date().toISOString()
1563
+ });
1564
+
1565
+ } catch (parseError) {
1566
+ console.error('Failed to parse tasks.json:', parseError);
1567
+ return res.status(500).json({
1568
+ error: 'Failed to parse tasks file',
1569
+ message: parseError.message
1570
+ });
1571
+ }
1572
+
1573
+ } catch (error) {
1574
+ console.error('TaskMaster tasks loading error:', error);
1575
+ res.status(500).json({
1576
+ error: 'Failed to load TaskMaster tasks',
1577
+ message: error.message
1578
+ });
1579
+ }
1580
+ });
1581
+
1582
+ /**
1583
+ * GET /api/taskmaster/artifacts/:projectName
1584
+ * Summarize recent project artifacts for mobile/reporting workflows.
1585
+ */
1586
+ router.get('/artifacts/:projectName', async (req, res) => {
1587
+ try {
1588
+ const { projectName } = req.params;
1589
+
1590
+ let projectPath;
1591
+ try {
1592
+ projectPath = await extractProjectDirectory(projectName);
1593
+ } catch (error) {
1594
+ return res.status(404).json({
1595
+ error: 'Project not found',
1596
+ message: `Project "${projectName}" does not exist`
1597
+ });
1598
+ }
1599
+
1600
+ const artifacts = await summarizeProjectArtifacts(projectPath);
1601
+ const latest = artifacts.length > 0 ? artifacts[0] : null;
1602
+
1603
+ res.json({
1604
+ projectName,
1605
+ projectPath,
1606
+ artifacts,
1607
+ latestArtifact: latest,
1608
+ totalArtifacts: artifacts.length,
1609
+ timestamp: new Date().toISOString(),
1610
+ });
1611
+ } catch (error) {
1612
+ console.error('TaskMaster artifact summary error:', error);
1613
+ res.status(500).json({
1614
+ error: 'Failed to summarize project artifacts',
1615
+ message: error.message,
1616
+ });
1617
+ }
1618
+ });
1619
+
1620
+ /**
1621
+ * GET /api/taskmaster/prd/:projectName
1622
+ * List all PRD files in the project's .pipeline/docs directory
1623
+ */
1624
+ router.get('/prd/:projectName', async (req, res) => {
1625
+ try {
1626
+ const { projectName } = req.params;
1627
+
1628
+ // Get project path
1629
+ let projectPath;
1630
+ try {
1631
+ projectPath = await extractProjectDirectory(projectName);
1632
+ } catch (error) {
1633
+ return res.status(404).json({
1634
+ error: 'Project not found',
1635
+ message: `Project "${projectName}" does not exist`
1636
+ });
1637
+ }
1638
+
1639
+ const paths = await ensurePipelineInitialized(projectPath);
1640
+ const docsPath = paths.docsDir;
1641
+
1642
+ // Check if docs directory exists
1643
+ try {
1644
+ await fsPromises.access(docsPath, fs.constants.R_OK);
1645
+ } catch (error) {
1646
+ return res.json({
1647
+ projectName,
1648
+ prdFiles: [],
1649
+ message: 'No .pipeline/docs directory found'
1650
+ });
1651
+ }
1652
+
1653
+ // Read directory and filter for PRD files
1654
+ try {
1655
+ const files = await fsPromises.readdir(docsPath);
1656
+ const prdFiles = [];
1657
+
1658
+ for (const file of files) {
1659
+ const filePath = path.join(docsPath, file);
1660
+ const stats = await fsPromises.stat(filePath);
1661
+
1662
+ if (stats.isFile() && (file.endsWith('.txt') || file.endsWith('.md') || file.endsWith('.json'))) {
1663
+ prdFiles.push({
1664
+ name: file,
1665
+ path: path.relative(projectPath, filePath),
1666
+ size: stats.size,
1667
+ modified: stats.mtime.toISOString(),
1668
+ created: stats.birthtime.toISOString()
1669
+ });
1670
+ }
1671
+ }
1672
+
1673
+ res.json({
1674
+ projectName,
1675
+ projectPath,
1676
+ prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)),
1677
+ timestamp: new Date().toISOString()
1678
+ });
1679
+
1680
+ } catch (readError) {
1681
+ console.error('Error reading docs directory:', readError);
1682
+ return res.status(500).json({
1683
+ error: 'Failed to read PRD files',
1684
+ message: readError.message
1685
+ });
1686
+ }
1687
+
1688
+ } catch (error) {
1689
+ console.error('PRD list error:', error);
1690
+ res.status(500).json({
1691
+ error: 'Failed to list PRD files',
1692
+ message: error.message
1693
+ });
1694
+ }
1695
+ });
1696
+
1697
+ /**
1698
+ * POST /api/taskmaster/prd/:projectName
1699
+ * Create or update a PRD file in the project's .pipeline/docs directory
1700
+ */
1701
+ router.post('/prd/:projectName', async (req, res) => {
1702
+ try {
1703
+ const { projectName } = req.params;
1704
+ const { fileName, content } = req.body;
1705
+
1706
+ if (!fileName || !content) {
1707
+ return res.status(400).json({
1708
+ error: 'Missing required fields',
1709
+ message: 'fileName and content are required'
1710
+ });
1711
+ }
1712
+
1713
+ // Validate filename
1714
+ if (!fileName.match(/^[\w\-. ]+\.(txt|md|json)$/)) {
1715
+ return res.status(400).json({
1716
+ error: 'Invalid filename',
1717
+ message: 'Filename must end with .txt, .md, or .json and contain only alphanumeric characters, spaces, dots, and dashes'
1718
+ });
1719
+ }
1720
+
1721
+ // Get project path
1722
+ let projectPath;
1723
+ try {
1724
+ projectPath = await extractProjectDirectory(projectName);
1725
+ } catch (error) {
1726
+ return res.status(404).json({
1727
+ error: 'Project not found',
1728
+ message: `Project "${projectName}" does not exist`
1729
+ });
1730
+ }
1731
+
1732
+ const paths = await ensurePipelineInitialized(projectPath);
1733
+ const docsPath = paths.docsDir;
1734
+ const filePath = path.join(docsPath, fileName);
1735
+
1736
+ // Ensure docs directory exists
1737
+ try {
1738
+ await fsPromises.mkdir(docsPath, { recursive: true });
1739
+ } catch (error) {
1740
+ console.error('Failed to create docs directory:', error);
1741
+ return res.status(500).json({
1742
+ error: 'Failed to create directory',
1743
+ message: error.message
1744
+ });
1745
+ }
1746
+
1747
+ // Write the PRD file
1748
+ try {
1749
+ await fsPromises.writeFile(filePath, content, 'utf8');
1750
+
1751
+ // Get file stats
1752
+ const stats = await fsPromises.stat(filePath);
1753
+
1754
+ res.json({
1755
+ projectName,
1756
+ projectPath,
1757
+ fileName,
1758
+ filePath: path.relative(projectPath, filePath),
1759
+ size: stats.size,
1760
+ created: stats.birthtime.toISOString(),
1761
+ modified: stats.mtime.toISOString(),
1762
+ message: 'PRD file saved successfully',
1763
+ timestamp: new Date().toISOString()
1764
+ });
1765
+
1766
+ } catch (writeError) {
1767
+ console.error('Failed to write PRD file:', writeError);
1768
+ return res.status(500).json({
1769
+ error: 'Failed to write PRD file',
1770
+ message: writeError.message
1771
+ });
1772
+ }
1773
+
1774
+ } catch (error) {
1775
+ console.error('PRD create/update error:', error);
1776
+ res.status(500).json({
1777
+ error: 'Failed to create/update PRD file',
1778
+ message: error.message
1779
+ });
1780
+ }
1781
+ });
1782
+
1783
+ /**
1784
+ * GET /api/taskmaster/prd/:projectName/:fileName
1785
+ * Get content of a specific PRD file
1786
+ */
1787
+ router.get('/prd/:projectName/:fileName', async (req, res) => {
1788
+ try {
1789
+ const { projectName, fileName } = req.params;
1790
+
1791
+ // Get project path
1792
+ let projectPath;
1793
+ try {
1794
+ projectPath = await extractProjectDirectory(projectName);
1795
+ } catch (error) {
1796
+ return res.status(404).json({
1797
+ error: 'Project not found',
1798
+ message: `Project "${projectName}" does not exist`
1799
+ });
1800
+ }
1801
+
1802
+ const paths = await ensurePipelineInitialized(projectPath);
1803
+ const filePath = path.join(paths.docsDir, fileName);
1804
+
1805
+ // Check if file exists
1806
+ try {
1807
+ await fsPromises.access(filePath, fs.constants.R_OK);
1808
+ } catch (error) {
1809
+ return res.status(404).json({
1810
+ error: 'PRD file not found',
1811
+ message: `File "${fileName}" does not exist`
1812
+ });
1813
+ }
1814
+
1815
+ // Read file content
1816
+ try {
1817
+ const content = await fsPromises.readFile(filePath, 'utf8');
1818
+ const stats = await fsPromises.stat(filePath);
1819
+
1820
+ res.json({
1821
+ projectName,
1822
+ projectPath,
1823
+ fileName,
1824
+ filePath: path.relative(projectPath, filePath),
1825
+ content,
1826
+ size: stats.size,
1827
+ created: stats.birthtime.toISOString(),
1828
+ modified: stats.mtime.toISOString(),
1829
+ timestamp: new Date().toISOString()
1830
+ });
1831
+
1832
+ } catch (readError) {
1833
+ console.error('Failed to read PRD file:', readError);
1834
+ return res.status(500).json({
1835
+ error: 'Failed to read PRD file',
1836
+ message: readError.message
1837
+ });
1838
+ }
1839
+
1840
+ } catch (error) {
1841
+ console.error('PRD read error:', error);
1842
+ res.status(500).json({
1843
+ error: 'Failed to read PRD file',
1844
+ message: error.message
1845
+ });
1846
+ }
1847
+ });
1848
+
1849
+ /**
1850
+ * DELETE /api/taskmaster/prd/:projectName/:fileName
1851
+ * Delete a specific PRD file
1852
+ */
1853
+ router.delete('/prd/:projectName/:fileName', async (req, res) => {
1854
+ try {
1855
+ const { projectName, fileName } = req.params;
1856
+
1857
+ // Get project path
1858
+ let projectPath;
1859
+ try {
1860
+ projectPath = await extractProjectDirectory(projectName);
1861
+ } catch (error) {
1862
+ return res.status(404).json({
1863
+ error: 'Project not found',
1864
+ message: `Project "${projectName}" does not exist`
1865
+ });
1866
+ }
1867
+
1868
+ const paths = await ensurePipelineInitialized(projectPath);
1869
+ const filePath = path.join(paths.docsDir, fileName);
1870
+
1871
+ // Check if file exists
1872
+ try {
1873
+ await fsPromises.access(filePath, fs.constants.F_OK);
1874
+ } catch (error) {
1875
+ return res.status(404).json({
1876
+ error: 'PRD file not found',
1877
+ message: `File "${fileName}" does not exist`
1878
+ });
1879
+ }
1880
+
1881
+ // Delete the file
1882
+ try {
1883
+ await fsPromises.unlink(filePath);
1884
+
1885
+ res.json({
1886
+ projectName,
1887
+ projectPath,
1888
+ fileName,
1889
+ message: 'PRD file deleted successfully',
1890
+ timestamp: new Date().toISOString()
1891
+ });
1892
+
1893
+ } catch (deleteError) {
1894
+ console.error('Failed to delete PRD file:', deleteError);
1895
+ return res.status(500).json({
1896
+ error: 'Failed to delete PRD file',
1897
+ message: deleteError.message
1898
+ });
1899
+ }
1900
+
1901
+ } catch (error) {
1902
+ console.error('PRD delete error:', error);
1903
+ res.status(500).json({
1904
+ error: 'Failed to delete PRD file',
1905
+ message: error.message
1906
+ });
1907
+ }
1908
+ });
1909
+
1910
+ /**
1911
+ * POST /api/taskmaster/init/:projectName
1912
+ * Initialize TaskMaster in a project
1913
+ */
1914
+ router.post('/init/:projectName', async (req, res) => {
1915
+ try {
1916
+ const { projectName } = req.params;
1917
+
1918
+ // Get project path
1919
+ let projectPath;
1920
+ try {
1921
+ projectPath = await extractProjectDirectory(projectName);
1922
+ } catch (error) {
1923
+ return res.status(404).json({
1924
+ error: 'Project not found',
1925
+ message: `Project "${projectName}" does not exist`
1926
+ });
1927
+ }
1928
+
1929
+ const paths = await ensurePipelineInitialized(projectPath);
1930
+ if (req.app.locals.wss) {
1931
+ broadcastTaskMasterProjectUpdate(
1932
+ req.app.locals.wss,
1933
+ projectName,
1934
+ { hasTaskmaster: true, status: 'initialized', path: paths.root }
1935
+ );
1936
+ }
1937
+
1938
+ res.json({
1939
+ projectName,
1940
+ projectPath,
1941
+ pipelinePath: paths.root,
1942
+ message: 'Pipeline initialized successfully',
1943
+ timestamp: new Date().toISOString(),
1944
+ });
1945
+
1946
+ } catch (error) {
1947
+ console.error('TaskMaster init error:', error);
1948
+ res.status(500).json({
1949
+ error: 'Failed to initialize TaskMaster',
1950
+ message: error.message
1951
+ });
1952
+ }
1953
+ });
1954
+
1955
+ /**
1956
+ * POST /api/taskmaster/add-task/:projectName
1957
+ * Add a new task to the project
1958
+ */
1959
+ router.post('/add-task/:projectName', async (req, res) => {
1960
+ try {
1961
+ const { projectName } = req.params;
1962
+ const { prompt, title, description, priority = 'high', dependencies, stage, insertAfterId } = req.body;
1963
+
1964
+ if (!prompt && (!title || !description)) {
1965
+ return res.status(400).json({
1966
+ error: 'Missing required parameters',
1967
+ message: 'Either "prompt" or both "title" and "description" are required'
1968
+ });
1969
+ }
1970
+
1971
+ // Get project path
1972
+ let projectPath;
1973
+ try {
1974
+ projectPath = await extractProjectDirectory(projectName);
1975
+ } catch (error) {
1976
+ return res.status(404).json({
1977
+ error: 'Project not found',
1978
+ message: `Project "${projectName}" does not exist`
1979
+ });
1980
+ }
1981
+
1982
+ const paths = await ensurePipelineInitialized(projectPath);
1983
+ const { tasks, currentTag } = await readTasksFile(paths.tasksFile);
1984
+ const taskId = generateTaskId(tasks);
1985
+ const now = new Date().toISOString();
1986
+
1987
+ const dependencyList = Array.isArray(dependencies)
1988
+ ? dependencies
1989
+ : typeof dependencies === 'string' && dependencies.trim().length > 0
1990
+ ? dependencies.split(',').map((item) => item.trim()).filter(Boolean)
1991
+ : [];
1992
+
1993
+ const newTask = normalizeTask({
1994
+ id: taskId,
1995
+ title: title || splitPromptToTitle(prompt),
1996
+ description: description || (prompt ? String(prompt).trim() : ''),
1997
+ priority,
1998
+ status: 'pending',
1999
+ dependencies: dependencyList,
2000
+ ...(stage ? { stage } : {}),
2001
+ createdAt: now,
2002
+ updatedAt: now,
2003
+ });
2004
+
2005
+ // Determine insertion position
2006
+ let nextTasks;
2007
+ if (insertAfterId !== undefined) {
2008
+ if (insertAfterId === null || insertAfterId === 0) {
2009
+ // Insert at beginning of the target stage
2010
+ const targetStage = normalizeStageName(stage);
2011
+ const firstInStageIdx = tasks.findIndex(
2012
+ (t) => normalizeStageName(t.stage) === targetStage
2013
+ );
2014
+ if (firstInStageIdx === -1) {
2015
+ nextTasks = [...tasks, newTask];
2016
+ } else {
2017
+ nextTasks = [...tasks.slice(0, firstInStageIdx), newTask, ...tasks.slice(firstInStageIdx)];
2018
+ }
2019
+ } else {
2020
+ // Insert after the specified task
2021
+ const afterIdx = tasks.findIndex((t) => String(t.id) === String(insertAfterId));
2022
+ if (afterIdx === -1) {
2023
+ nextTasks = [...tasks, newTask];
2024
+ } else {
2025
+ nextTasks = [...tasks.slice(0, afterIdx + 1), newTask, ...tasks.slice(afterIdx + 1)];
2026
+ }
2027
+ }
2028
+ } else {
2029
+ nextTasks = [...tasks, newTask];
2030
+ }
2031
+
2032
+ // Reindex all tasks so IDs are sequential
2033
+ const reindexed = reindexTasks(nextTasks);
2034
+ await writeTasksFile(paths.tasksFile, reindexed, currentTag);
2035
+
2036
+ // Find the inserted task by its createdAt timestamp
2037
+ const insertedTask = reindexed.find((t) => t.createdAt === now && t.title === newTask.title);
2038
+
2039
+ if (req.app.locals.wss) {
2040
+ broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);
2041
+ }
2042
+
2043
+ res.json({
2044
+ projectName,
2045
+ projectPath,
2046
+ message: 'Task added successfully',
2047
+ task: insertedTask || newTask,
2048
+ timestamp: now,
2049
+ });
2050
+
2051
+ } catch (error) {
2052
+ console.error('Add task error:', error);
2053
+ res.status(500).json({
2054
+ error: 'Failed to add task',
2055
+ message: error.message
2056
+ });
2057
+ }
2058
+ });
2059
+
2060
+ /**
2061
+ * DELETE /api/taskmaster/delete-task/:projectName/:taskId
2062
+ * Permanently remove a task from the project
2063
+ */
2064
+ router.delete('/delete-task/:projectName/:taskId', async (req, res) => {
2065
+ try {
2066
+ const { projectName, taskId } = req.params;
2067
+
2068
+ let projectPath;
2069
+ try {
2070
+ projectPath = await extractProjectDirectory(projectName);
2071
+ } catch (error) {
2072
+ return res.status(404).json({
2073
+ error: 'Project not found',
2074
+ message: `Project "${projectName}" does not exist`
2075
+ });
2076
+ }
2077
+
2078
+ const paths = await ensurePipelineInitialized(projectPath);
2079
+ const { tasks, currentTag } = await readTasksFile(paths.tasksFile);
2080
+
2081
+ const targetId = String(taskId);
2082
+ const taskIndex = tasks.findIndex((t) => String(t.id) === targetId);
2083
+ if (taskIndex === -1) {
2084
+ return res.status(404).json({
2085
+ error: 'Task not found',
2086
+ message: `Task with ID "${taskId}" does not exist`
2087
+ });
2088
+ }
2089
+
2090
+ const deletedTask = tasks[taskIndex];
2091
+ const nextTasks = tasks.filter((_, i) => i !== taskIndex);
2092
+
2093
+ // Remove deleted task ID from dependency arrays, then reindex
2094
+ nextTasks.forEach((t) => {
2095
+ if (Array.isArray(t.dependencies)) {
2096
+ t.dependencies = t.dependencies.filter(
2097
+ (dep) => String(dep) !== targetId
2098
+ );
2099
+ }
2100
+ });
2101
+
2102
+ const reindexed = reindexTasks(nextTasks);
2103
+ await writeTasksFile(paths.tasksFile, reindexed, currentTag);
2104
+
2105
+ if (req.app.locals.wss) {
2106
+ broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);
2107
+ }
2108
+
2109
+ res.json({
2110
+ projectName,
2111
+ projectPath,
2112
+ message: 'Task deleted successfully',
2113
+ deletedTask,
2114
+ timestamp: new Date().toISOString(),
2115
+ });
2116
+ } catch (error) {
2117
+ console.error('Delete task error:', error);
2118
+ res.status(500).json({
2119
+ error: 'Failed to delete task',
2120
+ message: error.message
2121
+ });
2122
+ }
2123
+ });
2124
+
2125
+ /**
2126
+ * PUT /api/taskmaster/update-task/:projectName/:taskId
2127
+ * Update a specific task using TaskMaster CLI
2128
+ */
2129
+ router.put('/update-task/:projectName/:taskId', async (req, res) => {
2130
+ try {
2131
+ const { projectName, taskId } = req.params;
2132
+ const { title, description, status, priority, details, testStrategy, dependencies } = req.body;
2133
+
2134
+ // Get project path
2135
+ let projectPath;
2136
+ try {
2137
+ projectPath = await extractProjectDirectory(projectName);
2138
+ } catch (error) {
2139
+ return res.status(404).json({
2140
+ error: 'Project not found',
2141
+ message: `Project "${projectName}" does not exist`
2142
+ });
2143
+ }
2144
+
2145
+ const paths = await ensurePipelineInitialized(projectPath);
2146
+ const { tasks, currentTag } = await readTasksFile(paths.tasksFile);
2147
+ const now = new Date().toISOString();
2148
+ const targetId = String(taskId);
2149
+ const taskIndex = tasks.findIndex((task) => String(task.id) === targetId);
2150
+
2151
+ if (taskIndex === -1) {
2152
+ return res.status(404).json({
2153
+ error: 'Task not found',
2154
+ message: `Task "${taskId}" does not exist`,
2155
+ });
2156
+ }
2157
+
2158
+ const existingTask = tasks[taskIndex];
2159
+ const updatedTask = {
2160
+ ...existingTask,
2161
+ ...(title !== undefined ? { title } : {}),
2162
+ ...(description !== undefined ? { description } : {}),
2163
+ ...(status !== undefined ? { status } : {}),
2164
+ ...(priority !== undefined ? { priority } : {}),
2165
+ ...(details !== undefined ? { details } : {}),
2166
+ ...(testStrategy !== undefined ? { testStrategy } : {}),
2167
+ ...(dependencies !== undefined ? { dependencies: Array.isArray(dependencies) ? dependencies : [] } : {}),
2168
+ updatedAt: now,
2169
+ };
2170
+
2171
+ const nextTasks = [...tasks];
2172
+ nextTasks[taskIndex] = normalizeTask(updatedTask);
2173
+ await writeTasksFile(paths.tasksFile, nextTasks, currentTag);
2174
+
2175
+ if (req.app.locals.wss) {
2176
+ broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);
2177
+ }
2178
+
2179
+ res.json({
2180
+ projectName,
2181
+ projectPath,
2182
+ taskId,
2183
+ message: 'Task updated successfully',
2184
+ task: nextTasks[taskIndex],
2185
+ timestamp: now,
2186
+ });
2187
+
2188
+ } catch (error) {
2189
+ console.error('Update task error:', error);
2190
+ res.status(500).json({
2191
+ error: 'Failed to update task',
2192
+ message: error.message
2193
+ });
2194
+ }
2195
+ });
2196
+
2197
+ /**
2198
+ * POST /api/taskmaster/parse-prd/:projectName
2199
+ * Parse a Research Brief JSON file to generate tasks
2200
+ */
2201
+ router.post('/parse-prd/:projectName', async (req, res) => {
2202
+ try {
2203
+ const { projectName } = req.params;
2204
+ const { fileName = DEFAULT_RESEARCH_BRIEF_FILENAME, numTasks, append = false } = req.body;
2205
+
2206
+ // Get project path
2207
+ let projectPath;
2208
+ try {
2209
+ projectPath = await extractProjectDirectory(projectName);
2210
+ } catch (error) {
2211
+ return res.status(404).json({
2212
+ error: 'Project not found',
2213
+ message: `Project "${projectName}" does not exist`
2214
+ });
2215
+ }
2216
+
2217
+ const paths = await ensurePipelineInitialized(projectPath);
2218
+ const briefPath = path.join(paths.docsDir, fileName);
2219
+
2220
+ // Check if brief JSON file exists
2221
+ try {
2222
+ await fsPromises.access(briefPath, fs.constants.F_OK);
2223
+ } catch (error) {
2224
+ return res.status(404).json({
2225
+ error: 'Research Brief file not found',
2226
+ message: `File "${fileName}" does not exist in ${PIPELINE_DIR}/docs/`
2227
+ });
2228
+ }
2229
+
2230
+ if (!fileName.endsWith('.json')) {
2231
+ return res.status(400).json({
2232
+ error: 'Invalid brief format',
2233
+ message: 'Research Brief must be a .json file',
2234
+ });
2235
+ }
2236
+
2237
+ let briefData;
2238
+ try {
2239
+ const briefContent = await fsPromises.readFile(briefPath, 'utf8');
2240
+ briefData = JSON.parse(briefContent);
2241
+ } catch (parseError) {
2242
+ return res.status(400).json({
2243
+ error: 'Invalid Research Brief JSON',
2244
+ message: parseError.message,
2245
+ });
2246
+ }
2247
+
2248
+ const maxTasks = Number.isFinite(Number(numTasks)) && Number(numTasks) > 0 ? Number(numTasks) : DEFAULT_MAX_TASKS;
2249
+
2250
+ const { tasks: existingTasks, currentTag } = await readTasksFile(paths.tasksFile);
2251
+ const now = new Date().toISOString();
2252
+
2253
+ const pipelineGenerated = instantiatePipelineTasksFromBrief(briefData, maxTasks);
2254
+ let generatedTasks = [];
2255
+
2256
+ if (pipelineGenerated && pipelineGenerated.length > 0) {
2257
+ let nextId = generateTaskId(existingTasks);
2258
+ generatedTasks = pipelineGenerated.map((task) => normalizeTask({
2259
+ ...task,
2260
+ id: nextId++,
2261
+ createdAt: task.createdAt || now,
2262
+ updatedAt: now,
2263
+ }));
2264
+ } else {
2265
+ const candidates = parseBriefJsonToTaskCandidates(briefData);
2266
+ let nextId = generateTaskId(existingTasks);
2267
+ generatedTasks = candidates.slice(0, maxTasks).map((candidate) => normalizeTask({
2268
+ id: nextId++,
2269
+ title: splitPromptToTitle(candidate),
2270
+ description: candidate,
2271
+ status: 'pending',
2272
+ priority: 'medium',
2273
+ dependencies: [],
2274
+ createdAt: now,
2275
+ updatedAt: now,
2276
+ stage: inferStageFromCandidate(candidate),
2277
+ taskType: 'exploration',
2278
+ suggestedSkills: ensureArray(getStageSkillMap()[inferStageFromCandidate(candidate)]?.base),
2279
+ sourceBlueprintId: `legacy.candidate.${nextId - 1}`,
2280
+ nextActionPrompt: [
2281
+ `Task: ${splitPromptToTitle(candidate)}`,
2282
+ `Description: ${candidate}`,
2283
+ 'Please turn this into a concrete actionable plan and provide first-step outputs.',
2284
+ ].join('\n'),
2285
+ }));
2286
+ }
2287
+
2288
+ const nextTasks = append
2289
+ ? [...existingTasks, ...dedupeGeneratedTasks(existingTasks, generatedTasks)]
2290
+ : generatedTasks;
2291
+ await writeTasksFile(paths.tasksFile, nextTasks, currentTag);
2292
+
2293
+ if (req.app.locals.wss) {
2294
+ broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);
2295
+ }
2296
+
2297
+ res.json({
2298
+ projectName,
2299
+ projectPath,
2300
+ briefFile: fileName,
2301
+ generationMode: pipelineGenerated ? 'pipeline-blueprint' : 'legacy-fallback',
2302
+ message: pipelineGenerated
2303
+ ? 'Research Brief pipeline instantiated successfully'
2304
+ : 'Research Brief parsed and tasks generated successfully',
2305
+ generatedCount: generatedTasks.length,
2306
+ totalTasks: nextTasks.length,
2307
+ timestamp: now,
2308
+ });
2309
+
2310
+ } catch (error) {
2311
+ console.error('Parse PRD error:', error);
2312
+ res.status(500).json({
2313
+ error: 'Failed to parse Research Brief',
2314
+ message: error.message
2315
+ });
2316
+ }
2317
+ });
2318
+
2319
+ /**
2320
+ * GET /api/taskmaster/prd-templates
2321
+ * Get available PRD templates
2322
+ */
2323
+ router.get('/prd-templates', async (req, res) => {
2324
+ try {
2325
+ const templates = await getAvailableTemplates();
2326
+ res.json({
2327
+ templates,
2328
+ timestamp: new Date().toISOString()
2329
+ });
2330
+
2331
+ } catch (error) {
2332
+ console.error('PRD templates error:', error);
2333
+ res.status(500).json({
2334
+ error: 'Failed to get PRD templates',
2335
+ message: error.message
2336
+ });
2337
+ }
2338
+ });
2339
+
2340
+ function normalizeLoadedTemplate(template = {}) {
2341
+ return {
2342
+ ...template,
2343
+ category: template.category || template.domain || 'general',
2344
+ format: template.format || 'research-brief-json',
2345
+ fileName: template.fileName || DEFAULT_RESEARCH_BRIEF_FILENAME,
2346
+ metaFields: Array.isArray(template.metaFields) ? template.metaFields : [],
2347
+ sectionFields: template.sectionFields && typeof template.sectionFields === 'object' ? template.sectionFields : {},
2348
+ };
2349
+ }
2350
+
2351
+ function cloneJsonCompatible(value) {
2352
+ return JSON.parse(JSON.stringify(value));
2353
+ }
2354
+
2355
+ async function summarizeProjectArtifacts(projectPath, limit = 12) {
2356
+ const candidates = [
2357
+ '.pipeline/docs',
2358
+ '.pipeline/tasks',
2359
+ 'results',
2360
+ 'reports',
2361
+ 'artifacts',
2362
+ 'output',
2363
+ 'outputs',
2364
+ 'analysis',
2365
+ 'figures',
2366
+ 'plots',
2367
+ 'tables',
2368
+ 'paper',
2369
+ 'drafts',
2370
+ ];
2371
+
2372
+ const artifactFiles = [];
2373
+ for (const relativeDir of candidates) {
2374
+ const targetDir = path.join(projectPath, relativeDir);
2375
+ try {
2376
+ const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
2377
+ for (const entry of entries) {
2378
+ if (!entry.isFile()) {
2379
+ continue;
2380
+ }
2381
+ const fullPath = path.join(targetDir, entry.name);
2382
+ const stats = await fsPromises.stat(fullPath);
2383
+ artifactFiles.push({
2384
+ name: entry.name,
2385
+ relativePath: path.relative(projectPath, fullPath),
2386
+ category: relativeDir,
2387
+ size: stats.size,
2388
+ modified: stats.mtime.toISOString(),
2389
+ });
2390
+ }
2391
+ } catch (error) {
2392
+ if (error?.code !== 'ENOENT') {
2393
+ console.warn('[TaskMaster] Artifact scan skipped for', targetDir, error.message);
2394
+ }
2395
+ }
2396
+ }
2397
+
2398
+ artifactFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified));
2399
+ return artifactFiles.slice(0, limit);
2400
+ }
2401
+
2402
+ function buildBriefFromTemplate(template, nowDate) {
2403
+ const stageSkillMap = getStageSkillMap();
2404
+ return {
2405
+ schemaVersion: '1.1',
2406
+ templateId: template.id,
2407
+ meta: {
2408
+ title: '',
2409
+ lead_author: '',
2410
+ target_venue: '',
2411
+ date: nowDate,
2412
+ },
2413
+ sections: cloneJsonCompatible(DEFAULT_BRIEF_SECTIONS),
2414
+ pipeline: cloneJsonCompatible(
2415
+ template?.pipeline && typeof template.pipeline === 'object'
2416
+ ? template.pipeline
2417
+ : buildDefaultBriefPipeline(stageSkillMap),
2418
+ ),
2419
+ };
2420
+ }
2421
+
2422
+ /**
2423
+ * POST /api/taskmaster/apply-template/:projectName
2424
+ * Apply a structured template to create/update a Research Brief JSON file
2425
+ */
2426
+ router.post('/apply-template/:projectName', async (req, res) => {
2427
+ try {
2428
+ const { projectName } = req.params;
2429
+ const { templateId, fileName = DEFAULT_RESEARCH_BRIEF_FILENAME, customizations = {} } = req.body;
2430
+
2431
+ if (!templateId) {
2432
+ return res.status(400).json({
2433
+ error: 'Missing required parameter',
2434
+ message: 'templateId is required'
2435
+ });
2436
+ }
2437
+
2438
+ // Get project path
2439
+ let projectPath;
2440
+ try {
2441
+ projectPath = await extractProjectDirectory(projectName);
2442
+ } catch (error) {
2443
+ return res.status(404).json({
2444
+ error: 'Project not found',
2445
+ message: `Project "${projectName}" does not exist`
2446
+ });
2447
+ }
2448
+
2449
+ const templates = await getAvailableTemplates();
2450
+ const template = templates.find(t => t.id === templateId);
2451
+
2452
+ if (!template) {
2453
+ return res.status(404).json({
2454
+ error: 'Template not found',
2455
+ message: `Template "${templateId}" does not exist`
2456
+ });
2457
+ }
2458
+
2459
+ if (!fileName.endsWith('.json')) {
2460
+ return res.status(400).json({
2461
+ error: 'Invalid filename',
2462
+ message: 'Research Brief must be saved as .json',
2463
+ });
2464
+ }
2465
+
2466
+ const now = new Date().toISOString().split('T')[0];
2467
+ const brief = buildBriefFromTemplate(template, now);
2468
+
2469
+ const allFields = [
2470
+ ...(Array.isArray(template.metaFields) ? template.metaFields : []),
2471
+ ...Object.values(template.sectionFields || {}).flat(),
2472
+ ];
2473
+
2474
+ allFields.forEach((field) => {
2475
+ const submitted = customizations?.[field.path];
2476
+ if (submitted === undefined || submitted === null) return;
2477
+ const rawValue = typeof submitted === 'string' ? submitted.trim() : submitted;
2478
+ if (rawValue === '') return;
2479
+
2480
+ if (field.type === 'array') {
2481
+ const values = Array.isArray(rawValue)
2482
+ ? rawValue.map((item) => String(item).trim()).filter(Boolean)
2483
+ : String(rawValue).split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
2484
+ assignPath(brief, field.path, values);
2485
+ return;
2486
+ }
2487
+
2488
+ assignPath(brief, field.path, String(rawValue));
2489
+ });
2490
+
2491
+ const paths = await ensurePipelineInitialized(projectPath);
2492
+ const docsDir = paths.docsDir;
2493
+ try {
2494
+ await fsPromises.mkdir(docsDir, { recursive: true });
2495
+ } catch (error) {
2496
+ console.error('Failed to create docs directory:', error);
2497
+ }
2498
+
2499
+ const filePath = path.join(docsDir, fileName);
2500
+
2501
+ // Write the template content to the file
2502
+ try {
2503
+ await fsPromises.writeFile(filePath, `${JSON.stringify(brief, null, 2)}\n`, 'utf8');
2504
+
2505
+ res.json({
2506
+ projectName,
2507
+ projectPath,
2508
+ templateId,
2509
+ templateName: template.name,
2510
+ fileName,
2511
+ filePath: path.relative(projectPath, filePath),
2512
+ message: 'Research Brief template applied successfully',
2513
+ timestamp: new Date().toISOString()
2514
+ });
2515
+
2516
+ } catch (writeError) {
2517
+ console.error('Failed to write PRD template:', writeError);
2518
+ return res.status(500).json({
2519
+ error: 'Failed to write Research Brief',
2520
+ message: writeError.message
2521
+ });
2522
+ }
2523
+
2524
+ } catch (error) {
2525
+ console.error('Apply template error:', error);
2526
+ res.status(500).json({
2527
+ error: 'Failed to apply Research Brief template',
2528
+ message: error.message
2529
+ });
2530
+ }
2531
+ });
2532
+
2533
+ // Helper function to get available templates
2534
+ async function getAvailableTemplates() {
2535
+ if (cachedTemplates) {
2536
+ return cachedTemplates;
2537
+ }
2538
+
2539
+ let files = [];
2540
+ try {
2541
+ files = await fsPromises.readdir(TEMPLATES_DIR);
2542
+ } catch (error) {
2543
+ throw new Error(`Failed to read templates directory: ${error.message}`);
2544
+ }
2545
+
2546
+ const jsonFiles = files.filter((name) => name.endsWith('.json'));
2547
+ if (jsonFiles.length === 0) {
2548
+ throw new Error(`No template JSON files found in ${TEMPLATES_DIR}`);
2549
+ }
2550
+
2551
+ const loaded = [];
2552
+ for (const fileName of jsonFiles) {
2553
+ const filePath = path.join(TEMPLATES_DIR, fileName);
2554
+ const content = await fsPromises.readFile(filePath, 'utf8');
2555
+ const parsed = JSON.parse(content);
2556
+ if (!parsed?.id || !parsed?.name) {
2557
+ throw new Error(`Template "${fileName}" missing required fields: id/name`);
2558
+ }
2559
+ loaded.push(normalizeLoadedTemplate(parsed));
2560
+ }
2561
+
2562
+ cachedTemplates = loaded.sort((a, b) => a.name.localeCompare(b.name));
2563
+ return cachedTemplates;
2564
+ }
2565
+
2566
+ export default router;