@winspan/claude-forge 8.50.6 → 8.51.1

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 (367) hide show
  1. package/CLAUDE.md +7 -7
  2. package/dist/claudemd/claudemd-generator.d.ts.map +1 -1
  3. package/dist/claudemd/claudemd-generator.js +27 -237
  4. package/dist/claudemd/claudemd-generator.js.map +1 -1
  5. package/dist/claudemd/resume-manager.js +1 -1
  6. package/dist/claudemd/resume-manager.js.map +1 -1
  7. package/dist/claudemd/templates/swarm-protocol.md +222 -0
  8. package/dist/cli/commands/daemon.js +6 -6
  9. package/dist/cli/commands/daemon.js.map +1 -1
  10. package/dist/cli/commands/executions.d.ts.map +1 -1
  11. package/dist/cli/commands/executions.js +4 -3
  12. package/dist/cli/commands/executions.js.map +1 -1
  13. package/dist/cli/commands/init.js +2 -2
  14. package/dist/cli/commands/init.js.map +1 -1
  15. package/dist/cli/commands/logs.js.map +1 -1
  16. package/dist/cli/commands/mcp.d.ts.map +1 -1
  17. package/dist/cli/commands/mcp.js +3 -5
  18. package/dist/cli/commands/mcp.js.map +1 -1
  19. package/dist/cli/commands/menu.d.ts.map +1 -1
  20. package/dist/cli/commands/menu.js +4 -3
  21. package/dist/cli/commands/menu.js.map +1 -1
  22. package/dist/cli/commands/stats.d.ts.map +1 -1
  23. package/dist/cli/commands/stats.js +2 -3
  24. package/dist/cli/commands/stats.js.map +1 -1
  25. package/dist/cli/commands/status.js +2 -2
  26. package/dist/cli/commands/status.js.map +1 -1
  27. package/dist/cli/commands/trace.d.ts.map +1 -1
  28. package/dist/cli/commands/trace.js +11 -23
  29. package/dist/cli/commands/trace.js.map +1 -1
  30. package/dist/cli/init/hook-manager.d.ts.map +1 -1
  31. package/dist/cli/init/hook-manager.js +2 -2
  32. package/dist/cli/init/hook-manager.js.map +1 -1
  33. package/dist/core/ai/provider.js +2 -2
  34. package/dist/core/ai/provider.js.map +1 -1
  35. package/dist/core/constants.d.ts +12 -1
  36. package/dist/core/constants.d.ts.map +1 -1
  37. package/dist/core/constants.js +15 -1
  38. package/dist/core/constants.js.map +1 -1
  39. package/dist/core/event-fields.d.ts +16 -0
  40. package/dist/core/event-fields.d.ts.map +1 -0
  41. package/dist/core/event-fields.js +19 -0
  42. package/dist/core/event-fields.js.map +1 -0
  43. package/dist/core/queue/index.d.ts.map +1 -1
  44. package/dist/core/queue/index.js +3 -4
  45. package/dist/core/queue/index.js.map +1 -1
  46. package/dist/core/storage/base.d.ts +36 -3
  47. package/dist/core/storage/base.d.ts.map +1 -1
  48. package/dist/core/storage/base.js +101 -58
  49. package/dist/core/storage/base.js.map +1 -1
  50. package/dist/core/storage/events.d.ts +92 -3
  51. package/dist/core/storage/events.d.ts.map +1 -1
  52. package/dist/core/storage/events.js +147 -0
  53. package/dist/core/storage/events.js.map +1 -1
  54. package/dist/core/storage/routing.d.ts +54 -1
  55. package/dist/core/storage/routing.d.ts.map +1 -1
  56. package/dist/core/storage/routing.js +99 -1
  57. package/dist/core/storage/routing.js.map +1 -1
  58. package/dist/core/storage/schema.sql +12 -2
  59. package/dist/core/storage/sessions.d.ts +20 -0
  60. package/dist/core/storage/sessions.d.ts.map +1 -1
  61. package/dist/core/storage/sessions.js +59 -0
  62. package/dist/core/storage/sessions.js.map +1 -1
  63. package/dist/core/storage/skills.d.ts +23 -0
  64. package/dist/core/storage/skills.d.ts.map +1 -1
  65. package/dist/core/storage/skills.js +47 -0
  66. package/dist/core/storage/skills.js.map +1 -1
  67. package/dist/core/storage/sqlite.d.ts +35 -2
  68. package/dist/core/storage/sqlite.d.ts.map +1 -1
  69. package/dist/core/storage/sqlite.js +93 -4
  70. package/dist/core/storage/sqlite.js.map +1 -1
  71. package/dist/core/storage/tasks.d.ts +49 -0
  72. package/dist/core/storage/tasks.d.ts.map +1 -1
  73. package/dist/core/storage/tasks.js +143 -1
  74. package/dist/core/storage/tasks.js.map +1 -1
  75. package/dist/core/storage/token-usage.d.ts +1 -1
  76. package/dist/core/storage/token-usage.d.ts.map +1 -1
  77. package/dist/core/storage/token-usage.js +1 -1
  78. package/dist/core/storage/token-usage.js.map +1 -1
  79. package/dist/core/types.d.ts +24 -3
  80. package/dist/core/types.d.ts.map +1 -1
  81. package/dist/core/types.js.map +1 -1
  82. package/dist/core/utils/error-handler.d.ts.map +1 -1
  83. package/dist/core/utils/error-handler.js +3 -2
  84. package/dist/core/utils/error-handler.js.map +1 -1
  85. package/dist/core/utils/git.d.ts +10 -0
  86. package/dist/core/utils/git.d.ts.map +1 -0
  87. package/dist/core/utils/git.js +24 -0
  88. package/dist/core/utils/git.js.map +1 -0
  89. package/dist/core/utils/logger.d.ts.map +1 -1
  90. package/dist/core/utils/logger.js +15 -1
  91. package/dist/core/utils/logger.js.map +1 -1
  92. package/dist/core/utils/lru-cache.d.ts +1 -0
  93. package/dist/core/utils/lru-cache.d.ts.map +1 -1
  94. package/dist/core/utils/lru-cache.js +3 -0
  95. package/dist/core/utils/lru-cache.js.map +1 -1
  96. package/dist/core/utils/token-tracker.js +1 -1
  97. package/dist/core/utils/token-tracker.js.map +1 -1
  98. package/dist/daemon/event-parser.d.ts.map +1 -1
  99. package/dist/daemon/event-parser.js +2 -1
  100. package/dist/daemon/event-parser.js.map +1 -1
  101. package/dist/daemon/handlers/history-exporter.js.map +1 -1
  102. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  103. package/dist/daemon/handlers/post-tool-use.js +7 -3
  104. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  105. package/dist/daemon/handlers/stop.d.ts +4 -0
  106. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  107. package/dist/daemon/handlers/stop.js +23 -35
  108. package/dist/daemon/handlers/stop.js.map +1 -1
  109. package/dist/daemon/handlers/user-prompt.d.ts +3 -3
  110. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  111. package/dist/daemon/handlers/user-prompt.js +12 -22
  112. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  113. package/dist/daemon/hook-sync.d.ts +17 -0
  114. package/dist/daemon/hook-sync.d.ts.map +1 -0
  115. package/dist/daemon/hook-sync.js +74 -0
  116. package/dist/daemon/hook-sync.js.map +1 -0
  117. package/dist/daemon/index.d.ts.map +1 -1
  118. package/dist/daemon/index.js +33 -9
  119. package/dist/daemon/index.js.map +1 -1
  120. package/dist/daemon/lifecycle.js +3 -4
  121. package/dist/daemon/lifecycle.js.map +1 -1
  122. package/dist/daemon/server.d.ts +6 -4
  123. package/dist/daemon/server.d.ts.map +1 -1
  124. package/dist/daemon/server.js +76 -85
  125. package/dist/daemon/server.js.map +1 -1
  126. package/dist/daemon/services/task-segmenter.js +1 -1
  127. package/dist/daemon/services/task-segmenter.js.map +1 -1
  128. package/dist/hooks/hook-lib.sh +37 -0
  129. package/dist/hooks/notification.sh +2 -2
  130. package/dist/hooks/post-tool-use.sh +2 -2
  131. package/dist/hooks/pre-tool-use.sh +2 -2
  132. package/dist/hooks/stop.sh +9 -6
  133. package/dist/hooks/user-prompt-submit.sh +2 -2
  134. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.d.ts +3 -4
  135. package/dist/web/analytics/anti-pattern-detector.d.ts.map +1 -0
  136. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.js +7 -46
  137. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.js.map +1 -1
  138. package/dist/web/analytics/drift-detector.d.ts.map +1 -0
  139. package/dist/{daemon/services → web/analytics}/drift-detector.js +10 -13
  140. package/dist/web/analytics/drift-detector.js.map +1 -0
  141. package/dist/web/analytics/weekly-report.d.ts.map +1 -0
  142. package/dist/{daemon/services → web/analytics}/weekly-report.js +51 -50
  143. package/dist/web/analytics/weekly-report.js.map +1 -0
  144. package/dist/web/auth-middleware.d.ts.map +1 -1
  145. package/dist/web/auth-middleware.js +1 -2
  146. package/dist/web/auth-middleware.js.map +1 -1
  147. package/dist/web/routes/_helpers.d.ts +16 -0
  148. package/dist/web/routes/_helpers.d.ts.map +1 -0
  149. package/dist/web/routes/_helpers.js +32 -0
  150. package/dist/web/routes/_helpers.js.map +1 -0
  151. package/dist/web/routes/drift.js +1 -1
  152. package/dist/web/routes/drift.js.map +1 -1
  153. package/dist/web/routes/insights.js +1 -1
  154. package/dist/web/routes/insights.js.map +1 -1
  155. package/dist/web/routes/reports.js +1 -1
  156. package/dist/web/routes/reports.js.map +1 -1
  157. package/dist/web/routes/rules.d.ts +3 -0
  158. package/dist/web/routes/rules.d.ts.map +1 -1
  159. package/dist/web/routes/rules.js +28 -52
  160. package/dist/web/routes/rules.js.map +1 -1
  161. package/dist/web/routes/sessions.d.ts.map +1 -1
  162. package/dist/web/routes/sessions.js +16 -30
  163. package/dist/web/routes/sessions.js.map +1 -1
  164. package/dist/web/routes/skill-stats.d.ts +2 -0
  165. package/dist/web/routes/skill-stats.d.ts.map +1 -1
  166. package/dist/web/routes/skill-stats.js +28 -64
  167. package/dist/web/routes/skill-stats.js.map +1 -1
  168. package/dist/web/routes/skills.d.ts.map +1 -1
  169. package/dist/web/routes/skills.js +5 -4
  170. package/dist/web/routes/skills.js.map +1 -1
  171. package/dist/web/routes/stats.d.ts +4 -0
  172. package/dist/web/routes/stats.d.ts.map +1 -1
  173. package/dist/web/routes/stats.js +19 -21
  174. package/dist/web/routes/stats.js.map +1 -1
  175. package/dist/web/routes/tasks.d.ts.map +1 -1
  176. package/dist/web/routes/tasks.js +17 -42
  177. package/dist/web/routes/tasks.js.map +1 -1
  178. package/dist/web/routes/trace.d.ts.map +1 -1
  179. package/dist/web/routes/trace.js +7 -17
  180. package/dist/web/routes/trace.js.map +1 -1
  181. package/dist/web/routes/types.d.ts.map +1 -1
  182. package/dist/web/routes/types.js +4 -3
  183. package/dist/web/routes/types.js.map +1 -1
  184. package/dist/web/static/assets/{AIConfig-BQCAQE9D.js → AIConfig-CdDWzJyO.js} +2 -2
  185. package/dist/web/static/assets/{AIConfig-BQCAQE9D.js.map → AIConfig-CdDWzJyO.js.map} +1 -1
  186. package/dist/web/static/assets/{Dashboard-D7Bo6Kan.js → Dashboard-CoEmmIDt.js} +2 -2
  187. package/dist/web/static/assets/{Dashboard-D7Bo6Kan.js.map → Dashboard-CoEmmIDt.js.map} +1 -1
  188. package/dist/web/static/assets/{Drawer-BeHRQxUS.js → Drawer-DdRTzlLB.js} +2 -2
  189. package/dist/web/static/assets/{Drawer-BeHRQxUS.js.map → Drawer-DdRTzlLB.js.map} +1 -1
  190. package/dist/web/static/assets/{Events-K_tCY2ti.js → Events-DrIq1SUS.js} +2 -2
  191. package/dist/web/static/assets/{Events-K_tCY2ti.js.map → Events-DrIq1SUS.js.map} +1 -1
  192. package/dist/web/static/assets/{Reports-BJCmBnc_.js → Reports-DFBM3MDK.js} +2 -2
  193. package/dist/web/static/assets/{Reports-BJCmBnc_.js.map → Reports-DFBM3MDK.js.map} +1 -1
  194. package/dist/web/static/assets/{SearchInput-BX2KhMkw.js → SearchInput-qCj_jAcf.js} +2 -2
  195. package/dist/web/static/assets/{SearchInput-BX2KhMkw.js.map → SearchInput-qCj_jAcf.js.map} +1 -1
  196. package/dist/web/static/assets/{SessionDetail-Bkr-kC7V.js → SessionDetail-CCzwdoT7.js} +2 -2
  197. package/dist/web/static/assets/{SessionDetail-Bkr-kC7V.js.map → SessionDetail-CCzwdoT7.js.map} +1 -1
  198. package/dist/web/static/assets/{Sessions-Chx9OCLH.js → Sessions-FfLYkAw9.js} +2 -2
  199. package/dist/web/static/assets/{Sessions-Chx9OCLH.js.map → Sessions-FfLYkAw9.js.map} +1 -1
  200. package/dist/web/static/assets/{Skills-O0GT1i7m.js → Skills-C8Gvs3Qa.js} +2 -2
  201. package/dist/web/static/assets/{Skills-O0GT1i7m.js.map → Skills-C8Gvs3Qa.js.map} +1 -1
  202. package/dist/web/static/assets/TaskDetail-BS8pYhaR.js +2 -0
  203. package/dist/web/static/assets/TaskDetail-BS8pYhaR.js.map +1 -0
  204. package/dist/web/static/assets/Tasks-CyuhizG8.js +2 -0
  205. package/dist/web/static/assets/Tasks-CyuhizG8.js.map +1 -0
  206. package/dist/web/static/assets/index-CBX47X8l.js +3 -0
  207. package/dist/web/static/assets/{index-DxIbmNmr.js.map → index-CBX47X8l.js.map} +1 -1
  208. package/dist/web/static/assets/index-DjIoMdoR.css +1 -0
  209. package/dist/web/static/assets/{lucide-fJlPI3H7.js → lucide-Bs_edTLa.js} +44 -39
  210. package/dist/web/static/assets/lucide-Bs_edTLa.js.map +1 -0
  211. package/dist/web/static/assets/react-router-r79dBVy4.js +20 -0
  212. package/dist/web/static/assets/{react-router-I-HqunH7.js.map → react-router-r79dBVy4.js.map} +1 -1
  213. package/dist/web/static/assets/task-title-BhOcemuR.js +2 -0
  214. package/dist/web/static/assets/task-title-BhOcemuR.js.map +1 -0
  215. package/dist/web/static/index.html +4 -4
  216. package/docs/design/h1-storage-aggregation-spec-20260518-1121.md +299 -0
  217. package/docs/design/h2-getdatabase-encapsulation-spec-20260518-1450.md +191 -0
  218. package/docs/design/h3-fallback-removal-spec-20260518-1245.md +76 -0
  219. package/docs/design/h4-index-dedup-spec-20260518-1230.md +109 -0
  220. package/docs/design/h6-services-migration-spec-20260518-1355.md +82 -0
  221. package/docs/design/l1-swarm-protocol-extract-spec-20260518-1605.md +106 -0
  222. package/docs/design/m10-forge-paths-spec-20260518-1320.md +121 -0
  223. package/docs/design/m2-m3-tool-input-spec-20260518-1425.md +131 -0
  224. package/docs/design/m7-routing-event-association-spec-20260518-1545.md +103 -0
  225. package/docs/design/project-path-gitroot-spec-20260518-1715.md +134 -0
  226. package/docs/design/task-active-gc-spec-20260518-1745.md +146 -0
  227. package/docs/implementation/h1-storage-aggregation-changelog-20260518-1121.md +82 -0
  228. package/docs/implementation/h2-final-changelog-20260518-1530.md +61 -0
  229. package/docs/implementation/h2-phase1-safety-net-changelog-20260518-1450.md +70 -0
  230. package/docs/implementation/h2-phase2-operations-changelog-20260518-1450.md +120 -0
  231. package/docs/implementation/h2-phase3-callsites-changelog-20260518-1450.md +71 -0
  232. package/docs/implementation/h3-fallback-removal-changelog-20260518-1245.md +71 -0
  233. package/docs/implementation/h4-index-dedup-changelog-20260518-1230.md +60 -0
  234. package/docs/implementation/h6-services-migration-changelog-20260518-1355.md +46 -0
  235. package/docs/implementation/h7-m9-defaults-changelog-20260518-1300.md +46 -0
  236. package/docs/implementation/l1-swarm-protocol-extract-changelog-20260518-1605.md +45 -0
  237. package/docs/implementation/l3-l4-daemon-perf-changelog-20260518-1410.md +63 -0
  238. package/docs/implementation/l6-l8-final-cleanup-changelog-20260518-1640.md +38 -0
  239. package/docs/implementation/m1-m4-m5-l7-cleanup-changelog-20260518-1310.md +58 -0
  240. package/docs/implementation/m10-forge-paths-changelog-20260518-1320.md +60 -0
  241. package/docs/implementation/m2-m3-tool-input-changelog-20260518-1425.md +43 -0
  242. package/docs/implementation/m6-m8-naming-shutdown-changelog-20260518-1340.md +56 -0
  243. package/docs/implementation/m7-routing-association-changelog-20260518-1545.md +69 -0
  244. package/docs/implementation/project-path-gitroot-changelog-20260518-1715.md +63 -0
  245. package/docs/implementation/task-active-gc-changelog-20260518-1745.md +35 -0
  246. package/docs/implementation/task-title-summary-changelog-20260518-1130.md +39 -0
  247. package/docs/implementation/tasks-detail-back-loses-filters-changelog-20260518-1100.md +22 -0
  248. package/docs/implementation/tasks-page-white-screen-hotfix-changelog-20260518-1015.md +56 -0
  249. package/docs/reviews/task-title-summary.md +92 -0
  250. package/docs/reviews/tasks-detail-back-loses-filters.md +58 -0
  251. package/docs/reviews/tasks-page-white-screen-hotfix.md +126 -0
  252. package/package.json +2 -2
  253. package/src/claudemd/claudemd-generator.ts +29 -238
  254. package/src/claudemd/resume-manager.ts +1 -1
  255. package/src/claudemd/templates/swarm-protocol.md +222 -0
  256. package/src/cli/commands/daemon.ts +6 -6
  257. package/src/cli/commands/executions.ts +4 -3
  258. package/src/cli/commands/init.ts +2 -2
  259. package/src/cli/commands/logs.ts +1 -1
  260. package/src/cli/commands/mcp.ts +3 -5
  261. package/src/cli/commands/menu.ts +4 -3
  262. package/src/cli/commands/stats.ts +2 -3
  263. package/src/cli/commands/status.ts +2 -2
  264. package/src/cli/commands/trace.ts +10 -26
  265. package/src/cli/init/hook-manager.ts +2 -2
  266. package/src/core/ai/provider.ts +2 -2
  267. package/src/core/constants.ts +18 -1
  268. package/src/core/event-fields.ts +32 -0
  269. package/src/core/queue/index.ts +3 -4
  270. package/src/core/storage/base.ts +132 -56
  271. package/src/core/storage/events.ts +183 -4
  272. package/src/core/storage/routing.ts +129 -1
  273. package/src/core/storage/schema.sql +12 -2
  274. package/src/core/storage/sessions.ts +64 -0
  275. package/src/core/storage/skills.ts +69 -0
  276. package/src/core/storage/sqlite.ts +103 -4
  277. package/src/core/storage/tasks.ts +149 -1
  278. package/src/core/storage/token-usage.ts +1 -1
  279. package/src/core/types.ts +30 -3
  280. package/src/core/utils/error-handler.ts +3 -2
  281. package/src/core/utils/git.ts +23 -0
  282. package/src/core/utils/logger.ts +16 -1
  283. package/src/core/utils/lru-cache.ts +4 -0
  284. package/src/core/utils/token-tracker.ts +1 -1
  285. package/src/daemon/event-parser.ts +4 -3
  286. package/src/daemon/handlers/history-exporter.ts +1 -1
  287. package/src/daemon/handlers/post-tool-use.ts +7 -3
  288. package/src/daemon/handlers/stop.ts +32 -39
  289. package/src/daemon/handlers/user-prompt.ts +12 -22
  290. package/src/daemon/hook-sync.ts +91 -0
  291. package/src/daemon/index.ts +34 -10
  292. package/src/daemon/lifecycle.ts +3 -3
  293. package/src/daemon/server.ts +76 -89
  294. package/src/daemon/services/task-segmenter.ts +1 -1
  295. package/src/hooks/hook-lib.sh +37 -0
  296. package/src/hooks/notification.sh +2 -2
  297. package/src/hooks/post-tool-use.sh +2 -2
  298. package/src/hooks/pre-tool-use.sh +2 -2
  299. package/src/hooks/stop.sh +9 -6
  300. package/src/hooks/user-prompt-submit.sh +2 -2
  301. package/src/{daemon/services → web/analytics}/anti-pattern-detector.ts +9 -54
  302. package/src/{daemon/services → web/analytics}/drift-detector.ts +10 -23
  303. package/src/{daemon/services → web/analytics}/weekly-report.ts +52 -75
  304. package/src/web/auth-middleware.ts +1 -2
  305. package/src/web/routes/_helpers.ts +34 -0
  306. package/src/web/routes/drift.ts +1 -1
  307. package/src/web/routes/insights.ts +1 -1
  308. package/src/web/routes/reports.ts +1 -1
  309. package/src/web/routes/rules.ts +31 -56
  310. package/src/web/routes/sessions.ts +18 -30
  311. package/src/web/routes/skill-stats.ts +29 -69
  312. package/src/web/routes/skills.ts +5 -4
  313. package/src/web/routes/stats.ts +19 -29
  314. package/src/web/routes/tasks.ts +17 -42
  315. package/src/web/routes/trace.ts +7 -19
  316. package/src/web/routes/types.ts +4 -3
  317. package/tests/integration/claudemd-generator.test.ts +90 -0
  318. package/tests/integration/web-analytics.integration.test.ts +133 -0
  319. package/tests/integration/web-stats.integration.test.ts +135 -0
  320. package/tests/integration/web-trace.integration.test.ts +175 -0
  321. package/tests/unit/core/forge-paths.test.ts +99 -0
  322. package/tests/unit/daemon/hook-sync.test.ts +71 -0
  323. package/tests/unit/daemon/post-tool-use.test.ts +121 -0
  324. package/tests/unit/daemon/stop-handler-behavior-summary.test.ts +202 -0
  325. package/tests/unit/daemon/task-segmenter-recover.test.ts +84 -0
  326. package/tests/unit/event-fields.test.ts +88 -0
  327. package/tests/unit/event-parser.test.ts +55 -0
  328. package/tests/unit/hooks/resolve-project-path.test.ts +122 -0
  329. package/tests/unit/socket-server.test.ts +183 -0
  330. package/tests/unit/storage/event-operations-aggregates.test.ts +342 -0
  331. package/tests/unit/storage/migration-idempotent.test.ts +304 -0
  332. package/tests/unit/storage/routing-aggregates.test.ts +276 -0
  333. package/tests/unit/storage/routing.test.ts +117 -0
  334. package/tests/unit/storage/schema-missing.test.ts +81 -0
  335. package/tests/unit/storage/session-operations-aggregates.test.ts +120 -0
  336. package/tests/unit/storage/skill-operations-counts.test.ts +106 -0
  337. package/tests/unit/storage/skills-aggregates.test.ts +104 -0
  338. package/tests/unit/storage/sqlite-refactor-harness.test.ts +3 -3
  339. package/tests/unit/storage/task-operations-counts.test.ts +46 -0
  340. package/tests/unit/storage/tasks-getById.test.ts +343 -0
  341. package/tests/unit/storage/tasks-stale-gc.test.ts +86 -0
  342. package/tests/unit/token-usage.test.ts +6 -6
  343. package/tests/unit/web/navigation-back-contract.test.ts +134 -0
  344. package/tests/unit/web/routes-rules.test.ts +182 -0
  345. package/tests/unit/web/routes-tasks.test.ts +34 -0
  346. package/tests/unit/web/task-title-contract.test.ts +210 -0
  347. package/tests/unit/web/tasks-component-contract.test.ts +179 -0
  348. package/vitest.config.ts +1 -1
  349. package/web/src/pages/TaskDetail.tsx +9 -5
  350. package/web/src/pages/Tasks.tsx +315 -50
  351. package/web/src/utils/navigation.ts +25 -0
  352. package/web/src/utils/task-title.ts +49 -0
  353. package/dist/daemon/services/anti-pattern-detector.d.ts.map +0 -1
  354. package/dist/daemon/services/drift-detector.d.ts.map +0 -1
  355. package/dist/daemon/services/drift-detector.js.map +0 -1
  356. package/dist/daemon/services/weekly-report.d.ts.map +0 -1
  357. package/dist/daemon/services/weekly-report.js.map +0 -1
  358. package/dist/web/static/assets/TaskDetail-5SR8zGzv.js +0 -2
  359. package/dist/web/static/assets/TaskDetail-5SR8zGzv.js.map +0 -1
  360. package/dist/web/static/assets/Tasks-DCgDqvOZ.js +0 -2
  361. package/dist/web/static/assets/Tasks-DCgDqvOZ.js.map +0 -1
  362. package/dist/web/static/assets/index-D8AKj26b.css +0 -1
  363. package/dist/web/static/assets/index-DxIbmNmr.js +0 -3
  364. package/dist/web/static/assets/lucide-fJlPI3H7.js.map +0 -1
  365. package/dist/web/static/assets/react-router-I-HqunH7.js +0 -20
  366. /package/dist/{daemon/services → web/analytics}/drift-detector.d.ts +0 -0
  367. /package/dist/{daemon/services → web/analytics}/weekly-report.d.ts +0 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * H4: migration idempotent
3
+ *
4
+ * 覆盖 schema.sql 与 base.ts::runMigrations 的去重边界:
5
+ * 1. baseline — 老库缺索引/列,SQLiteStorage 启动后必须补齐
6
+ * 2. 新库无害 — 走完整 schema.sql 后,再 new SQLiteStorage 不抛错,不重复创建
7
+ * 3. idempotent — 连续 new 多次,索引/列数量不变
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
11
+ import Database from 'better-sqlite3';
12
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
17
+ import { logger } from '../../../src/core/utils/logger.js';
18
+
19
+ // 与 base.ts::initSchema 解析方式一致
20
+ const THIS_DIR = dirname(fileURLToPath(import.meta.url));
21
+ const SCHEMA_PATH = join(THIS_DIR, '../../../src/core/storage/schema.sql');
22
+
23
+ function listIndexes(db: Database.Database): Set<string> {
24
+ const rows = db
25
+ .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'`)
26
+ .all() as Array<{ name: string }>;
27
+ return new Set(rows.map(r => r.name));
28
+ }
29
+
30
+ function listColumns(db: Database.Database, table: string): Set<string> {
31
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
32
+ return new Set(rows.map(r => r.name));
33
+ }
34
+
35
+ /**
36
+ * 写入一份"老库"——故意删掉若干索引,模拟存量库。
37
+ * 列定义保持与最新 schema.sql 等价(不缺列),因为 schema.sql 的
38
+ * CREATE TABLE IF NOT EXISTS 在表已存在时不会补列;同时 schema.sql 中
39
+ * 多个索引(如 idx_skill_invocations_workflow)依赖 workflow 列存在。
40
+ * 故"老库缺列"测试见单独 case,与"老库缺索引"分开覆盖,避免初始化期失败。
41
+ */
42
+ function execLegacySchemaMissingIndexes(db: Database.Database): void {
43
+ db.exec(`
44
+ CREATE TABLE events (
45
+ event_id TEXT PRIMARY KEY,
46
+ session_id TEXT NOT NULL,
47
+ project_path TEXT NOT NULL,
48
+ timestamp TEXT NOT NULL,
49
+ hook_type TEXT NOT NULL,
50
+ tool_name TEXT,
51
+ tool_input TEXT,
52
+ tool_output TEXT,
53
+ user_prompt TEXT,
54
+ ai_response TEXT,
55
+ distilled INTEGER DEFAULT 0,
56
+ created_at TEXT DEFAULT (datetime('now'))
57
+ );
58
+
59
+ CREATE TABLE sessions (
60
+ session_id TEXT PRIMARY KEY,
61
+ project_path TEXT NOT NULL,
62
+ status TEXT NOT NULL DEFAULT 'active',
63
+ first_prompt TEXT,
64
+ start_time TEXT NOT NULL,
65
+ end_time TEXT,
66
+ last_event_time TEXT,
67
+ event_count INTEGER DEFAULT 0,
68
+ created_at TEXT DEFAULT (datetime('now')),
69
+ updated_at TEXT DEFAULT (datetime('now'))
70
+ );
71
+
72
+ CREATE TABLE injections (
73
+ id TEXT PRIMARY KEY,
74
+ event_id TEXT,
75
+ session_id TEXT NOT NULL,
76
+ timestamp TEXT NOT NULL,
77
+ source_handler TEXT NOT NULL,
78
+ injection_type TEXT NOT NULL,
79
+ content TEXT NOT NULL,
80
+ created_at TEXT DEFAULT (datetime('now'))
81
+ );
82
+
83
+ CREATE TABLE tasks (
84
+ id TEXT PRIMARY KEY,
85
+ session_id TEXT NOT NULL,
86
+ title TEXT NOT NULL,
87
+ description TEXT,
88
+ start_time TEXT NOT NULL,
89
+ end_time TEXT,
90
+ status TEXT DEFAULT 'active',
91
+ event_count INTEGER DEFAULT 0,
92
+ created_at TEXT DEFAULT (datetime('now'))
93
+ );
94
+
95
+ CREATE TABLE task_events (
96
+ task_id TEXT NOT NULL,
97
+ event_id TEXT NOT NULL,
98
+ PRIMARY KEY (task_id, event_id)
99
+ );
100
+
101
+ CREATE TABLE routing_events (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ session_id TEXT NOT NULL,
104
+ route_request_id TEXT,
105
+ project_path TEXT NOT NULL,
106
+ ts INTEGER NOT NULL,
107
+ prompt TEXT NOT NULL,
108
+ intent_json TEXT NOT NULL,
109
+ routed_to_type TEXT,
110
+ routed_to_name TEXT,
111
+ is_forced INTEGER DEFAULT 0,
112
+ obeyed INTEGER,
113
+ classification_ms INTEGER,
114
+ fallback_used INTEGER DEFAULT 0,
115
+ refusal_reason TEXT,
116
+ first_tool_name TEXT,
117
+ first_tool_ts INTEGER,
118
+ completed_ts INTEGER,
119
+ total_execution_ms INTEGER,
120
+ completion_reason TEXT,
121
+ downstream_task_chain TEXT,
122
+ injection_version TEXT,
123
+ experiment_id TEXT,
124
+ experiment_group TEXT,
125
+ skill_confidence REAL,
126
+ skill_source TEXT,
127
+ created_at TEXT DEFAULT (datetime('now'))
128
+ );
129
+
130
+ CREATE TABLE token_usage (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
132
+ session_id TEXT NOT NULL,
133
+ timestamp INTEGER NOT NULL,
134
+ input_tokens INTEGER NOT NULL,
135
+ output_tokens INTEGER NOT NULL,
136
+ total_tokens INTEGER NOT NULL,
137
+ model TEXT,
138
+ tool_name TEXT,
139
+ created_at TEXT DEFAULT (datetime('now'))
140
+ );
141
+
142
+ CREATE TABLE skill_invocations (
143
+ id TEXT PRIMARY KEY,
144
+ route_request_id TEXT,
145
+ session_id TEXT NOT NULL,
146
+ agent_id TEXT,
147
+ skill_id TEXT NOT NULL,
148
+ invocation_type TEXT NOT NULL,
149
+ reason TEXT,
150
+ workflow TEXT,
151
+ phase TEXT,
152
+ feature_slug TEXT,
153
+ artifact_path TEXT,
154
+ depth INTEGER DEFAULT 0,
155
+ success INTEGER DEFAULT 1,
156
+ error TEXT,
157
+ timestamp INTEGER NOT NULL,
158
+ created_at TEXT DEFAULT (datetime('now'))
159
+ );
160
+ -- 故意不创建以下任何索引(migration 必须补齐)
161
+ `);
162
+ }
163
+
164
+ /**
165
+ * 老库——只缺 sessions.first_prompt 列(其他列齐全 + 完整索引)。
166
+ * 单独 case,验证 addColumnIfMissing 补齐。
167
+ */
168
+ function execLegacySchemaMissingFirstPrompt(db: Database.Database): void {
169
+ db.exec(`
170
+ CREATE TABLE sessions (
171
+ session_id TEXT PRIMARY KEY,
172
+ project_path TEXT NOT NULL,
173
+ status TEXT NOT NULL DEFAULT 'active',
174
+ start_time TEXT NOT NULL,
175
+ end_time TEXT,
176
+ last_event_time TEXT,
177
+ event_count INTEGER DEFAULT 0,
178
+ created_at TEXT DEFAULT (datetime('now')),
179
+ updated_at TEXT DEFAULT (datetime('now'))
180
+ );
181
+ `);
182
+ }
183
+
184
+ describe('H4: storage migration idempotent', () => {
185
+ let tmp: string;
186
+ let dbPath: string;
187
+
188
+ beforeEach(() => {
189
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h4-migration-'));
190
+ dbPath = join(tmp, 'data.db');
191
+ });
192
+
193
+ afterEach(() => {
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ vi.restoreAllMocks();
196
+ });
197
+
198
+ it('baseline (缺索引): 老库索引齐缺,SQLiteStorage 启动后补齐全部 migration 索引', () => {
199
+ // 1) 手工写入只缺索引的旧 schema
200
+ const raw = new Database(dbPath);
201
+ execLegacySchemaMissingIndexes(raw);
202
+ const beforeIdx = listIndexes(raw);
203
+ expect(beforeIdx.has('idx_routing_events_type_ts')).toBe(false);
204
+ expect(beforeIdx.has('idx_skill_invocations_workflow')).toBe(false);
205
+ expect(beforeIdx.has('idx_skill_invocations_feature')).toBe(false);
206
+ expect(beforeIdx.has('idx_sessions_start_time')).toBe(false);
207
+ expect(beforeIdx.has('idx_events_session_ts')).toBe(false);
208
+ raw.close();
209
+
210
+ // 2) 用 SQLiteStorage 打开 → 触发 schema.sql + runMigrations
211
+ const storage = new SQLiteStorage(dbPath);
212
+ const db = storage.getDatabase();
213
+
214
+ // 3) 关键索引全部补齐(涵盖 spec 重复清单中所有 migration-only / 双写索引)
215
+ const idx = listIndexes(db);
216
+ const expected = [
217
+ 'idx_sessions_start_time',
218
+ 'idx_events_session_ts',
219
+ 'idx_routing_events_session_ts',
220
+ 'idx_skill_invocations_session_ts',
221
+ 'idx_routing_events_obeyed_ts',
222
+ 'idx_events_session_hook',
223
+ 'idx_injections_session_handler',
224
+ 'idx_routing_events_type_ts',
225
+ 'idx_skill_invocations_workflow',
226
+ 'idx_skill_invocations_feature',
227
+ ];
228
+ for (const name of expected) {
229
+ expect(idx.has(name), `expected index ${name} to exist after migration`).toBe(true);
230
+ }
231
+
232
+ storage.close();
233
+ });
234
+
235
+ it('baseline (缺列): 老库缺 sessions.first_prompt,migration ALTER 补齐', () => {
236
+ const raw = new Database(dbPath);
237
+ execLegacySchemaMissingFirstPrompt(raw);
238
+ expect(listColumns(raw, 'sessions').has('first_prompt')).toBe(false);
239
+ raw.close();
240
+
241
+ const storage = new SQLiteStorage(dbPath);
242
+ const db = storage.getDatabase();
243
+
244
+ // first_prompt 列由 addColumnIfMissing 补齐
245
+ expect(listColumns(db, 'sessions').has('first_prompt')).toBe(true);
246
+
247
+ storage.close();
248
+ });
249
+
250
+ it('新库(完整 schema.sql 初始化)再启动 SQLiteStorage:migration 不会重复创建索引', () => {
251
+ // 1) 用完整 schema.sql 初始化(模拟 base.initSchema 已跑完,sqlite-base 同样这么干)
252
+ expect(existsSync(SCHEMA_PATH)).toBe(true);
253
+ const schemaSql = readFileSync(SCHEMA_PATH, 'utf-8');
254
+ const raw = new Database(dbPath);
255
+ raw.exec(schemaSql);
256
+ const indexesAfterSchema = listIndexes(raw);
257
+ raw.close();
258
+
259
+ // 2) 监听 logger.debug,统计"migration created idx_xxx"次数
260
+ const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {});
261
+ const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
262
+ const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {});
263
+
264
+ // 3) new SQLiteStorage → runMigrations 应当跳过所有 createIndexIfMissing
265
+ const storage = new SQLiteStorage(dbPath);
266
+
267
+ const createdCalls = debugSpy.mock.calls.filter(
268
+ args => typeof args[0] === 'string' && args[0].includes('migration created'),
269
+ );
270
+ expect(createdCalls.length).toBe(0);
271
+
272
+ // 不应有 warn/error
273
+ expect(warnSpy).not.toHaveBeenCalled();
274
+ expect(errorSpy).not.toHaveBeenCalled();
275
+
276
+ // 索引集合相比 schema.sql 初始化后无变化
277
+ const indexesAfterMigration = listIndexes(storage.getDatabase());
278
+ expect(indexesAfterMigration).toEqual(indexesAfterSchema);
279
+
280
+ storage.close();
281
+ });
282
+
283
+ it('idempotent: 连续多次 new SQLiteStorage 不改变索引/列数量', () => {
284
+ const snapshots: Array<{ idx: Set<string>; cols: Set<string> }> = [];
285
+
286
+ for (let i = 0; i < 3; i++) {
287
+ const storage = new SQLiteStorage(dbPath);
288
+ const db = storage.getDatabase();
289
+ snapshots.push({
290
+ idx: listIndexes(db),
291
+ cols: listColumns(db, 'skill_invocations'),
292
+ });
293
+ storage.close();
294
+ }
295
+
296
+ // 索引集合在三次之间完全一致
297
+ expect(snapshots[0].idx).toEqual(snapshots[1].idx);
298
+ expect(snapshots[1].idx).toEqual(snapshots[2].idx);
299
+
300
+ // skill_invocations 列集合在三次之间一致
301
+ expect(snapshots[0].cols).toEqual(snapshots[1].cols);
302
+ expect(snapshots[1].cols).toEqual(snapshots[2].cols);
303
+ });
304
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * H1 storage aggregates: routing_events
3
+ *
4
+ * 覆盖:
5
+ * - 空表返回 0
6
+ * - 混合 obeyed=1/0/NULL 的计数
7
+ * - since_ts 过滤边界
8
+ * - by_agent 排除 obeyed != 1 / routed_to_name IS NULL
9
+ * - by_skill_routed 只统计 routed_to_type='skill'
10
+ * - aggregateRoutingTrendByDay 跨日聚合(UTC)
11
+ * - ts <= 0 兜底(不应崩,不进入 trend 结果)
12
+ * - project_path 过滤
13
+ */
14
+
15
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
20
+
21
+ const SESSION = 'sess-r';
22
+ const PROJECT = '/tmp/p';
23
+
24
+ describe('storage.aggregateRoutingStats', () => {
25
+ let tmp: string;
26
+ let storage: SQLiteStorage;
27
+
28
+ beforeEach(() => {
29
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-routing-'));
30
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ try { storage.close(); } catch { /* ignore */ }
35
+ rmSync(tmp, { recursive: true, force: true });
36
+ });
37
+
38
+ it('空表返回全 0 / 空数组', () => {
39
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
40
+ expect(r.total).toBe(0);
41
+ expect(r.obeyed).toBe(0);
42
+ expect(r.refused).toBe(0);
43
+ expect(r.unknown).toBe(0);
44
+ expect(r.by_type).toEqual([]);
45
+ expect(r.by_agent).toEqual([]);
46
+ expect(r.by_skill_routed).toEqual([]);
47
+ });
48
+
49
+ it('混合 obeyed=1/0/NULL 的计数正确', () => {
50
+ const now = Date.now();
51
+ storage.writeRoutingEvent({
52
+ session_id: SESSION, project_path: PROJECT, ts: now,
53
+ prompt: 'p1', intent_json: '{}',
54
+ routed_to_type: 'agent', routed_to_name: 'researcher', obeyed: 1,
55
+ });
56
+ storage.writeRoutingEvent({
57
+ session_id: SESSION, project_path: PROJECT, ts: now,
58
+ prompt: 'p2', intent_json: '{}',
59
+ routed_to_type: 'agent', routed_to_name: 'coder', obeyed: 0,
60
+ });
61
+ storage.writeRoutingEvent({
62
+ session_id: SESSION, project_path: PROJECT, ts: now,
63
+ prompt: 'p3', intent_json: '{}',
64
+ routed_to_type: 'skill', routed_to_name: 'tdd', obeyed: null,
65
+ });
66
+
67
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
68
+ expect(r.total).toBe(3);
69
+ expect(r.obeyed).toBe(1);
70
+ expect(r.refused).toBe(1);
71
+ expect(r.unknown).toBe(1);
72
+ });
73
+
74
+ it('by_type 分布(含 NULL → "none")', () => {
75
+ const now = Date.now();
76
+ storage.writeRoutingEvent({
77
+ session_id: SESSION, project_path: PROJECT, ts: now,
78
+ prompt: 'a', intent_json: '{}', routed_to_type: 'agent', routed_to_name: 'x',
79
+ });
80
+ storage.writeRoutingEvent({
81
+ session_id: SESSION, project_path: PROJECT, ts: now,
82
+ prompt: 'b', intent_json: '{}', routed_to_type: 'skill', routed_to_name: 'y',
83
+ });
84
+ storage.writeRoutingEvent({
85
+ session_id: SESSION, project_path: PROJECT, ts: now,
86
+ prompt: 'c', intent_json: '{}', routed_to_type: null, routed_to_name: null,
87
+ });
88
+
89
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
90
+ const map = Object.fromEntries(r.by_type.map(e => [e.type, e.count]));
91
+ expect(map.agent).toBe(1);
92
+ expect(map.skill).toBe(1);
93
+ expect(map.none).toBe(1);
94
+ });
95
+
96
+ it('by_agent 仅含 obeyed=1 且 routed_to_name 非 NULL', () => {
97
+ const now = Date.now();
98
+ storage.writeRoutingEvent({
99
+ session_id: SESSION, project_path: PROJECT, ts: now,
100
+ prompt: 'p1', intent_json: '{}',
101
+ routed_to_type: 'agent', routed_to_name: 'researcher', obeyed: 1,
102
+ });
103
+ storage.writeRoutingEvent({
104
+ session_id: SESSION, project_path: PROJECT, ts: now,
105
+ prompt: 'p2', intent_json: '{}',
106
+ routed_to_type: 'agent', routed_to_name: 'researcher', obeyed: 1,
107
+ });
108
+ // obeyed = 0 → 不计入 by_agent
109
+ storage.writeRoutingEvent({
110
+ session_id: SESSION, project_path: PROJECT, ts: now,
111
+ prompt: 'p3', intent_json: '{}',
112
+ routed_to_type: 'agent', routed_to_name: 'tester', obeyed: 0,
113
+ });
114
+ // obeyed = NULL → 不计入 by_agent
115
+ storage.writeRoutingEvent({
116
+ session_id: SESSION, project_path: PROJECT, ts: now,
117
+ prompt: 'p4', intent_json: '{}',
118
+ routed_to_type: 'agent', routed_to_name: 'planner', obeyed: null,
119
+ });
120
+ // routed_to_name NULL → 不计入
121
+ storage.writeRoutingEvent({
122
+ session_id: SESSION, project_path: PROJECT, ts: now,
123
+ prompt: 'p5', intent_json: '{}',
124
+ routed_to_type: null, routed_to_name: null, obeyed: 1,
125
+ });
126
+
127
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
128
+ expect(r.by_agent).toEqual([{ agent: 'researcher', count: 2 }]);
129
+ });
130
+
131
+ it('by_skill_routed 仅含 routed_to_type=skill 且 routed_to_name 非 NULL', () => {
132
+ const now = Date.now();
133
+ storage.writeRoutingEvent({
134
+ session_id: SESSION, project_path: PROJECT, ts: now,
135
+ prompt: 'p1', intent_json: '{}',
136
+ routed_to_type: 'skill', routed_to_name: 'official-tdd',
137
+ });
138
+ storage.writeRoutingEvent({
139
+ session_id: SESSION, project_path: PROJECT, ts: now,
140
+ prompt: 'p2', intent_json: '{}',
141
+ routed_to_type: 'skill', routed_to_name: 'official-tdd',
142
+ });
143
+ storage.writeRoutingEvent({
144
+ session_id: SESSION, project_path: PROJECT, ts: now,
145
+ prompt: 'p3', intent_json: '{}',
146
+ routed_to_type: 'skill', routed_to_name: 'debug',
147
+ });
148
+ // agent 不计入
149
+ storage.writeRoutingEvent({
150
+ session_id: SESSION, project_path: PROJECT, ts: now,
151
+ prompt: 'p4', intent_json: '{}',
152
+ routed_to_type: 'agent', routed_to_name: 'researcher',
153
+ });
154
+
155
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
156
+ expect(r.by_skill_routed).toEqual([
157
+ { skill: 'official-tdd', count: 2 },
158
+ { skill: 'debug', count: 1 },
159
+ ]);
160
+ });
161
+
162
+ it('since_ts 严格 >= 边界(等于通过 / 小于排除)', () => {
163
+ storage.writeRoutingEvent({
164
+ session_id: SESSION, project_path: PROJECT, ts: 1000,
165
+ prompt: 'old', intent_json: '{}',
166
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
167
+ });
168
+ storage.writeRoutingEvent({
169
+ session_id: SESSION, project_path: PROJECT, ts: 2000,
170
+ prompt: 'mid', intent_json: '{}',
171
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
172
+ });
173
+
174
+ const r1 = storage.aggregateRoutingStats({ since_ts: 2000 });
175
+ expect(r1.total).toBe(1); // 仅 ts=2000
176
+
177
+ const r2 = storage.aggregateRoutingStats({ since_ts: 1500 });
178
+ expect(r2.total).toBe(1); // 仅 ts=2000
179
+
180
+ const r3 = storage.aggregateRoutingStats({ since_ts: 1000 });
181
+ expect(r3.total).toBe(2);
182
+ });
183
+
184
+ it('project_path 过滤', () => {
185
+ const now = Date.now();
186
+ storage.writeRoutingEvent({
187
+ session_id: SESSION, project_path: '/proj/a', ts: now,
188
+ prompt: 'pa', intent_json: '{}', routed_to_type: 'agent', routed_to_name: 'x', obeyed: 1,
189
+ });
190
+ storage.writeRoutingEvent({
191
+ session_id: SESSION, project_path: '/proj/b', ts: now,
192
+ prompt: 'pb', intent_json: '{}', routed_to_type: 'agent', routed_to_name: 'y', obeyed: 1,
193
+ });
194
+
195
+ const r = storage.aggregateRoutingStats({ since_ts: 0, project_path: '/proj/a' });
196
+ expect(r.total).toBe(1);
197
+ expect(r.by_agent).toEqual([{ agent: 'x', count: 1 }]);
198
+ });
199
+
200
+ it('ts <= 0 被 WHERE ts > 0 兜底排除', () => {
201
+ storage.writeRoutingEvent({
202
+ session_id: SESSION, project_path: PROJECT, ts: 0,
203
+ prompt: 'zero', intent_json: '{}',
204
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
205
+ });
206
+ storage.writeRoutingEvent({
207
+ session_id: SESSION, project_path: PROJECT, ts: 1000,
208
+ prompt: 'ok', intent_json: '{}',
209
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
210
+ });
211
+
212
+ const r = storage.aggregateRoutingStats({ since_ts: 0 });
213
+ // ts=0 被排除
214
+ expect(r.total).toBe(1);
215
+ });
216
+ });
217
+
218
+ describe('storage.aggregateRoutingTrendByDay', () => {
219
+ let tmp: string;
220
+ let storage: SQLiteStorage;
221
+
222
+ beforeEach(() => {
223
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-trend-'));
224
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
225
+ });
226
+
227
+ afterEach(() => {
228
+ try { storage.close(); } catch { /* ignore */ }
229
+ rmSync(tmp, { recursive: true, force: true });
230
+ });
231
+
232
+ it('空表返回 []', () => {
233
+ expect(storage.aggregateRoutingTrendByDay({ since_ts: 0 })).toEqual([]);
234
+ });
235
+
236
+ it('跨日聚合(UTC),返回每日 total + skill 计数', () => {
237
+ // 2026-01-01T00:00:01Z, 01-01T23:00:00Z, 01-02T00:00:01Z
238
+ const day1 = Date.UTC(2026, 0, 1, 0, 0, 1); // 2026-01-01
239
+ const day1b = Date.UTC(2026, 0, 1, 23, 0, 0); // 2026-01-01
240
+ const day2 = Date.UTC(2026, 0, 2, 0, 0, 1); // 2026-01-02
241
+
242
+ storage.writeRoutingEvent({
243
+ session_id: SESSION, project_path: PROJECT, ts: day1,
244
+ prompt: 'a', intent_json: '{}', routed_to_type: 'agent', routed_to_name: 'x',
245
+ });
246
+ storage.writeRoutingEvent({
247
+ session_id: SESSION, project_path: PROJECT, ts: day1b,
248
+ prompt: 'b', intent_json: '{}', routed_to_type: 'skill', routed_to_name: 'y',
249
+ });
250
+ storage.writeRoutingEvent({
251
+ session_id: SESSION, project_path: PROJECT, ts: day2,
252
+ prompt: 'c', intent_json: '{}', routed_to_type: 'skill', routed_to_name: 'y',
253
+ });
254
+
255
+ const trend = storage.aggregateRoutingTrendByDay({ since_ts: 0 });
256
+ expect(trend).toHaveLength(2);
257
+ expect(trend[0]).toEqual({ day: '2026-01-01', total: 2, skill: 1 });
258
+ expect(trend[1]).toEqual({ day: '2026-01-02', total: 1, skill: 1 });
259
+ });
260
+
261
+ it('ts <= 0 不出现在结果中(strftime 兜底)', () => {
262
+ storage.writeRoutingEvent({
263
+ session_id: SESSION, project_path: PROJECT, ts: 0,
264
+ prompt: 'zero', intent_json: '{}', routed_to_type: 'agent', routed_to_name: 'x',
265
+ });
266
+ storage.writeRoutingEvent({
267
+ session_id: SESSION, project_path: PROJECT, ts: Date.UTC(2026, 0, 1, 0, 0, 0),
268
+ prompt: 'ok', intent_json: '{}', routed_to_type: 'skill', routed_to_name: 'y',
269
+ });
270
+
271
+ const trend = storage.aggregateRoutingTrendByDay({ since_ts: 0 });
272
+ expect(trend.every(t => t.day !== null && t.day.length > 0)).toBe(true);
273
+ expect(trend).toHaveLength(1);
274
+ expect(trend[0].day).toBe('2026-01-01');
275
+ });
276
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * M7: getRecentPendingRoutingEvent semantics.
3
+ *
4
+ * Verifies the "pending only" filter that the PostToolUseHandler relies on
5
+ * to associate Agent invocations with the still-unfilled routing_event from
6
+ * the current user prompt (rather than re-overwriting a completed one).
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
14
+
15
+ const PROJECT = '/tmp/m7-proj';
16
+
17
+ describe('storage.getRecentPendingRoutingEvent', () => {
18
+ let tmp: string;
19
+ let storage: SQLiteStorage;
20
+
21
+ beforeEach(() => {
22
+ tmp = mkdtempSync(join(tmpdir(), 'forge-m7-routing-'));
23
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
24
+ });
25
+
26
+ afterEach(() => {
27
+ try { storage.close(); } catch { /* ignore */ }
28
+ rmSync(tmp, { recursive: true, force: true });
29
+ });
30
+
31
+ it('case 1: older obeyed=1 + newer pending → returns newer pending', () => {
32
+ const sess = 'sess-1';
33
+ const oldId = storage.writeRoutingEvent({
34
+ session_id: sess, project_path: PROJECT, ts: 1000,
35
+ prompt: 'old', intent_json: '{}',
36
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
37
+ });
38
+ const newId = storage.writeRoutingEvent({
39
+ session_id: sess, project_path: PROJECT, ts: 2000,
40
+ prompt: 'new', intent_json: '{}',
41
+ obeyed: null,
42
+ });
43
+
44
+ const recent = storage.getRecentPendingRoutingEvent(sess);
45
+ expect(recent).not.toBeNull();
46
+ expect(recent!.id).toBe(newId);
47
+ expect(recent!.id).not.toBe(oldId);
48
+ });
49
+
50
+ it('case 2: all events obeyed=1 → returns null', () => {
51
+ const sess = 'sess-2';
52
+ storage.writeRoutingEvent({
53
+ session_id: sess, project_path: PROJECT, ts: 1000,
54
+ prompt: 'p1', intent_json: '{}',
55
+ routed_to_type: 'agent', routed_to_name: 'a', obeyed: 1,
56
+ });
57
+ storage.writeRoutingEvent({
58
+ session_id: sess, project_path: PROJECT, ts: 2000,
59
+ prompt: 'p2', intent_json: '{}',
60
+ routed_to_type: 'agent', routed_to_name: 'b', obeyed: 1,
61
+ });
62
+
63
+ expect(storage.getRecentPendingRoutingEvent(sess)).toBeNull();
64
+ });
65
+
66
+ it('case 3: session isolation — pending in another session is ignored', () => {
67
+ const sessA = 'sess-A';
68
+ const sessB = 'sess-B';
69
+ const aId = storage.writeRoutingEvent({
70
+ session_id: sessA, project_path: PROJECT, ts: 1000,
71
+ prompt: 'a-pending', intent_json: '{}',
72
+ obeyed: null,
73
+ });
74
+ storage.writeRoutingEvent({
75
+ session_id: sessB, project_path: PROJECT, ts: 2000,
76
+ prompt: 'b-pending', intent_json: '{}',
77
+ obeyed: null,
78
+ });
79
+
80
+ const rA = storage.getRecentPendingRoutingEvent(sessA);
81
+ expect(rA).not.toBeNull();
82
+ expect(rA!.id).toBe(aId);
83
+
84
+ // Session with no rows at all returns null.
85
+ expect(storage.getRecentPendingRoutingEvent('sess-none')).toBeNull();
86
+ });
87
+
88
+ it('case 4: single pending row → returns that row', () => {
89
+ const sess = 'sess-4';
90
+ const id = storage.writeRoutingEvent({
91
+ session_id: sess, project_path: PROJECT, ts: 1234,
92
+ prompt: 'only', intent_json: '{}',
93
+ obeyed: null,
94
+ });
95
+
96
+ const recent = storage.getRecentPendingRoutingEvent(sess);
97
+ expect(recent).not.toBeNull();
98
+ expect(recent!.id).toBe(id);
99
+ });
100
+
101
+ it('legacy getRecentRoutingEvent still returns most-recent regardless of obeyed', () => {
102
+ const sess = 'sess-legacy';
103
+ storage.writeRoutingEvent({
104
+ session_id: sess, project_path: PROJECT, ts: 1000,
105
+ prompt: 'old-pending', intent_json: '{}', obeyed: null,
106
+ });
107
+ const newerCompletedId = storage.writeRoutingEvent({
108
+ session_id: sess, project_path: PROJECT, ts: 2000,
109
+ prompt: 'new-completed', intent_json: '{}',
110
+ routed_to_type: 'agent', routed_to_name: 'x', obeyed: 1,
111
+ });
112
+
113
+ const recent = storage.getRecentRoutingEvent(sess);
114
+ expect(recent).not.toBeNull();
115
+ expect(recent!.id).toBe(newerCompletedId);
116
+ });
117
+ });