@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,122 @@
1
+ /**
2
+ * Safety-net unit tests for the bash function `resolve_project_path` in
3
+ * `src/hooks/hook-lib.sh`.
4
+ *
5
+ * We invoke bash directly through `child_process.execSync`, sourcing the lib
6
+ * and calling the function with various inputs. Each fixture is created under
7
+ * a per-test scratch directory in $TMPDIR (or /tmp) and cleaned up afterwards.
8
+ */
9
+
10
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11
+ import { execSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ const HOOK_LIB = path.resolve(__dirname, '../../../src/hooks/hook-lib.sh');
17
+
18
+ /**
19
+ * Invoke `resolve_project_path` and return its stdout (the resolved path).
20
+ * Throws if the bash invocation itself fails.
21
+ */
22
+ function callResolve(input: string, options: { cwd?: string } = {}): string {
23
+ // Use a single-quoted bash command body to avoid shell quoting hell;
24
+ // pass the input as $1 to the inner function so we don't need to escape it.
25
+ const cmd = `bash -c 'source "$1" && resolve_project_path "$2"' _ "${HOOK_LIB}" "${input}"`;
26
+ return execSync(cmd, {
27
+ cwd: options.cwd ?? process.cwd(),
28
+ encoding: 'utf-8',
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ }).toString();
31
+ }
32
+
33
+ describe('hook-lib.sh :: resolve_project_path', () => {
34
+ let scratch: string;
35
+ let repoRoot: string;
36
+ let nestedDir: string;
37
+ let worktreeRoot: string;
38
+ let nonGitDir: string;
39
+
40
+ beforeAll(() => {
41
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-resolve-'));
42
+
43
+ // Fixture 1: standard git repo with .git as a directory
44
+ repoRoot = path.join(scratch, 'repo');
45
+ nestedDir = path.join(repoRoot, 'a', 'b', 'c');
46
+ fs.mkdirSync(nestedDir, { recursive: true });
47
+ fs.mkdirSync(path.join(repoRoot, '.git'));
48
+
49
+ // Fixture 2: git worktree where .git is a regular file
50
+ worktreeRoot = path.join(scratch, 'worktree');
51
+ fs.mkdirSync(path.join(worktreeRoot, 'src'), { recursive: true });
52
+ fs.writeFileSync(
53
+ path.join(worktreeRoot, '.git'),
54
+ 'gitdir: /tmp/somewhere/else\n',
55
+ );
56
+
57
+ // Fixture 3: a plain directory with no git ancestor anywhere up to scratch
58
+ nonGitDir = path.join(scratch, 'plain', 'x', 'y');
59
+ fs.mkdirSync(nonGitDir, { recursive: true });
60
+ });
61
+
62
+ afterAll(() => {
63
+ fs.rmSync(scratch, { recursive: true, force: true });
64
+ });
65
+
66
+ it('case 1: resolves a deeply nested git child dir to repo root', () => {
67
+ const out = callResolve(nestedDir);
68
+ expect(out).toBe(repoRoot);
69
+ });
70
+
71
+ it('case 2: returns input cwd unchanged when no .git is found upstream', () => {
72
+ // To make this test robust we point HOME-ish ancestor away from any real
73
+ // repo by passing the fixture directly. Even if /tmp ancestors had .git
74
+ // somewhere, the guard caps at 64 levels. Here scratch lives in /tmp and
75
+ // has no .git, so the function should fall back to the input.
76
+ const out = callResolve(nonGitDir);
77
+ expect(out).toBe(nonGitDir);
78
+ });
79
+
80
+ it('case 3: empty input falls back to $PWD of the invoking shell', () => {
81
+ // We invoke bash with cwd=repoRoot/a so $PWD is that path and there IS a
82
+ // git ancestor — it should still resolve to repoRoot because empty input
83
+ // triggers `dir=$PWD` and then upstream search succeeds.
84
+ // Note: on macOS /tmp and /var are symlinked to /private/tmp /private/var,
85
+ // and bash's $PWD reflects the resolved path. We compare against realpath
86
+ // so the test is portable.
87
+ const startCwd = path.join(repoRoot, 'a');
88
+ const out = callResolve('', { cwd: startCwd });
89
+ expect(out).toBe(fs.realpathSync(repoRoot));
90
+ });
91
+
92
+ it('case 4: recognises git worktree (.git is a file, not a directory)', () => {
93
+ const sub = path.join(worktreeRoot, 'src');
94
+ const out = callResolve(sub);
95
+ expect(out).toBe(worktreeRoot);
96
+ });
97
+
98
+ it('case 5: walking all the way up without finding .git returns the original input', () => {
99
+ // /nonexistent/deeply/nested has no .git anywhere up to /
100
+ const out = callResolve('/nonexistent/deeply/nested');
101
+ expect(out).toBe('/nonexistent/deeply/nested');
102
+ });
103
+
104
+ it('case 6: simulates hook INPUT JSON with cwd pointing to nested dir', () => {
105
+ // Mirrors what a real hook does:
106
+ // RAW_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
107
+ // PROJECT_PATH=$(resolve_project_path "${RAW_CWD:-$PWD}")
108
+ // We just test resolve_project_path with the cwd extracted from such JSON.
109
+ const extractedCwd = nestedDir; // simulates jq result for cwd in fixture repo
110
+ const out = callResolve(extractedCwd);
111
+ expect(out).toBe(repoRoot);
112
+ });
113
+
114
+ it('case 7: hook INPUT with empty cwd field falls back to shell $PWD (repoRoot)', () => {
115
+ // When jq returns "" for .cwd, bash sets RAW_CWD="" and calls
116
+ // resolve_project_path "${RAW_CWD:-$PWD}" — effectively resolve_project_path ""
117
+ // with bash cwd=repoRoot/a, so $PWD-based lookup should find repoRoot.
118
+ const startCwd = path.join(repoRoot, 'a');
119
+ const out = callResolve('', { cwd: startCwd });
120
+ expect(out).toBe(fs.realpathSync(repoRoot));
121
+ });
122
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * SocketServer behavior tests
3
+ *
4
+ * 重点验证 L3 性能修复:buffer 分片到达时,仅在遇到换行符后才解析,
5
+ * 避免大事件每个 chunk 都跑一次完整 JSON.parse 的 N² 行为。
6
+ */
7
+ import { describe, it, expect, afterEach, vi } from 'vitest';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import fs from 'node:fs';
12
+ import { SocketServer } from '../../src/daemon/server.js';
13
+ import { EventParser } from '../../src/daemon/event-parser.js';
14
+
15
+ function makeSockPath(): string {
16
+ const name = `forge-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sock`;
17
+ return path.join(os.tmpdir(), name);
18
+ }
19
+
20
+ function makeEvent(overrides: Record<string, unknown> = {}): Record<string, unknown> {
21
+ return {
22
+ hook_type: 'PostToolUse',
23
+ timestamp: new Date().toISOString(),
24
+ session_id: 'test-session',
25
+ project_path: '/tmp/test',
26
+ tool_name: 'Read',
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function sendOnSocket(sockPath: string, chunks: string[], delayMs = 5): Promise<string> {
32
+ return new Promise((resolve, reject) => {
33
+ const client = net.createConnection(sockPath);
34
+ let response = '';
35
+ client.on('connect', async () => {
36
+ for (const chunk of chunks) {
37
+ client.write(chunk);
38
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
39
+ }
40
+ });
41
+ client.on('data', (data) => {
42
+ response += data.toString();
43
+ });
44
+ client.on('end', () => resolve(response));
45
+ client.on('close', () => resolve(response));
46
+ client.on('error', reject);
47
+ });
48
+ }
49
+
50
+ describe('SocketServer — L3 chunked-parse behavior', () => {
51
+ const servers: SocketServer[] = [];
52
+ const sockPaths: string[] = [];
53
+
54
+ afterEach(async () => {
55
+ for (const s of servers) await s.close();
56
+ servers.length = 0;
57
+ for (const p of sockPaths) {
58
+ try { fs.unlinkSync(p); } catch { /* ignore */ }
59
+ }
60
+ sockPaths.length = 0;
61
+ });
62
+
63
+ it('parses a complete single-chunk message (with trailing newline)', async () => {
64
+ const sockPath = makeSockPath();
65
+ sockPaths.push(sockPath);
66
+
67
+ const handler = vi.fn().mockReturnValue({ allow: true });
68
+ const server = new SocketServer(sockPath, handler);
69
+ servers.push(server);
70
+ await new Promise((r) => setTimeout(r, 30));
71
+
72
+ const event = makeEvent();
73
+ const payload = JSON.stringify(event) + '\n';
74
+ await sendOnSocket(sockPath, [payload]);
75
+
76
+ expect(handler).toHaveBeenCalledTimes(1);
77
+ const passed = handler.mock.calls[0][0];
78
+ expect(passed.hook_type).toBe('PostToolUse');
79
+ expect(passed.session_id).toBe('test-session');
80
+ });
81
+
82
+ it('parses a large message split across many chunks WITHOUT per-chunk JSON.parse', async () => {
83
+ const sockPath = makeSockPath();
84
+ sockPaths.push(sockPath);
85
+
86
+ // 监视 JSON.parse —— 用于断言不再 N²(不能为每个 chunk 都 parse 一次)
87
+ const parseSpy = vi.spyOn(JSON, 'parse');
88
+
89
+ const handler = vi.fn().mockReturnValue({ allow: true });
90
+ const server = new SocketServer(sockPath, handler);
91
+ servers.push(server);
92
+ await new Promise((r) => setTimeout(r, 30));
93
+
94
+ // 构造大事件(约 50KB tool_output),分成 ~20 个 chunk 喂入
95
+ const bigOutput = 'x'.repeat(50_000);
96
+ const event = makeEvent({ tool_output: { data: bigOutput } });
97
+ const payload = JSON.stringify(event) + '\n';
98
+ const chunkSize = Math.ceil(payload.length / 20);
99
+ const chunks: string[] = [];
100
+ for (let i = 0; i < payload.length; i += chunkSize) {
101
+ chunks.push(payload.slice(i, i + chunkSize));
102
+ }
103
+ expect(chunks.length).toBeGreaterThan(10);
104
+
105
+ const callsBefore = parseSpy.mock.calls.length;
106
+ await sendOnSocket(sockPath, chunks, 2);
107
+ const callsAfter = parseSpy.mock.calls.length;
108
+
109
+ // 关键断言:handler 必须收到完整事件
110
+ expect(handler).toHaveBeenCalledTimes(1);
111
+
112
+ // 关键断言:JSON.parse 调用次数远少于 chunk 数(旧代码会 ≈ chunks.length 次)
113
+ // 现在应该只有:可能 1 次(无 auth 时)或 2 次(有 auth 时——auth 检查 + EventParser)
114
+ const parseDelta = callsAfter - callsBefore;
115
+ expect(parseDelta).toBeLessThanOrEqual(3);
116
+ expect(parseDelta).toBeLessThan(chunks.length); // 强对比旧 N² 行为
117
+
118
+ parseSpy.mockRestore();
119
+ });
120
+
121
+ it('rejects oversized buffer that never sees a newline', async () => {
122
+ const sockPath = makeSockPath();
123
+ sockPaths.push(sockPath);
124
+
125
+ const handler = vi.fn();
126
+ const server = new SocketServer(sockPath, handler);
127
+ servers.push(server);
128
+ await new Promise((r) => setTimeout(r, 30));
129
+
130
+ // 600KB 无换行 —— 必须超过 MAX_BUFFER_SIZE (512KB) 并被丢弃
131
+ // 注意:服务端 destroy 后客户端继续 write 会触发 EPIPE,要容错
132
+ const oversized = 'a'.repeat(600 * 1024);
133
+ await new Promise<void>((resolve) => {
134
+ const client = net.createConnection(sockPath);
135
+ client.on('error', () => { /* EPIPE 预期 */ });
136
+ client.on('close', () => resolve());
137
+ client.on('connect', () => {
138
+ client.write(oversized, () => {
139
+ // 给服务端处理时间
140
+ setTimeout(() => client.destroy(), 80);
141
+ });
142
+ });
143
+ });
144
+
145
+ expect(handler).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('uses fallback parse on socket end() for messages without trailing newline', async () => {
149
+ const sockPath = makeSockPath();
150
+ sockPaths.push(sockPath);
151
+
152
+ const handler = vi.fn().mockReturnValue({ allow: true });
153
+ const server = new SocketServer(sockPath, handler);
154
+ servers.push(server);
155
+ await new Promise((r) => setTimeout(r, 30));
156
+
157
+ // 无换行结尾 —— 走 socket.end() 兜底
158
+ const event = makeEvent();
159
+ const payload = JSON.stringify(event);
160
+ await new Promise<void>((resolve, reject) => {
161
+ const client = net.createConnection(sockPath);
162
+ client.on('connect', () => {
163
+ client.end(payload); // 写完即关闭:触发 server 的 'end' 事件
164
+ });
165
+ client.on('close', () => resolve());
166
+ client.on('error', reject);
167
+ });
168
+ // 等 server 端处理完
169
+ await new Promise((r) => setTimeout(r, 50));
170
+
171
+ expect(handler).toHaveBeenCalledTimes(1);
172
+ });
173
+ });
174
+
175
+ describe('EventParser — sanity', () => {
176
+ it('parses a valid event JSON', () => {
177
+ const parser = new EventParser();
178
+ const raw = JSON.stringify(makeEvent());
179
+ const out = parser.parse(raw);
180
+ expect(out.session_id).toBe('test-session');
181
+ expect(out.hook_type).toBe('PostToolUse');
182
+ });
183
+ });
@@ -0,0 +1,342 @@
1
+ /**
2
+ * H2: EventOperations 新增 aggregate / count / query 方法测试
3
+ *
4
+ * 覆盖 11 个方法:
5
+ * countAllEvents
6
+ * aggregateToolUsage(含 hook_type 过滤)
7
+ * aggregateDailyEventCounts
8
+ * aggregateHookTypeBySession
9
+ * aggregateAgentTypeBySession
10
+ * aggregateToolUsageBySession
11
+ * countActiveDays
12
+ * aggregateOverviewByRange
13
+ * queryDistinctProjects
14
+ * aggregateToolFailureRate
15
+ * queryFileEditInputs
16
+ * queryEventsByTimeRange
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
24
+ import type { ForgeEvent } from '../../../src/core/types.js';
25
+
26
+ describe('EventOperations H2 aggregates', () => {
27
+ let tmp: string;
28
+ let storage: SQLiteStorage;
29
+
30
+ beforeEach(() => {
31
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h2-events-'));
32
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
33
+ });
34
+
35
+ afterEach(() => {
36
+ try { storage.close(); } catch { /* ignore */ }
37
+ rmSync(tmp, { recursive: true, force: true });
38
+ });
39
+
40
+ function makeEvent(overrides: Partial<ForgeEvent>): ForgeEvent {
41
+ return {
42
+ session_id: 's1',
43
+ project_path: '/tmp/proj',
44
+ timestamp: '2026-05-10T10:00:00.000Z',
45
+ hook_type: 'PreToolUse',
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ // ── countAllEvents ─────────────────────────────────────────────────
51
+ describe('countAllEvents', () => {
52
+ it('空表返回 0', () => {
53
+ expect(storage.countAllEvents()).toBe(0);
54
+ });
55
+ it('多条事件返回总数', () => {
56
+ storage.writeEvent(makeEvent({ session_id: 'a' }));
57
+ storage.writeEvent(makeEvent({ session_id: 'b' }));
58
+ storage.writeEvent(makeEvent({ session_id: 'c' }));
59
+ expect(storage.countAllEvents()).toBe(3);
60
+ });
61
+ });
62
+
63
+ // ── aggregateToolUsage ─────────────────────────────────────────────
64
+ describe('aggregateToolUsage', () => {
65
+ it('空表返回空数组', () => {
66
+ expect(storage.aggregateToolUsage()).toEqual([]);
67
+ });
68
+ it('按 count 降序,跳过空 tool_name', () => {
69
+ storage.writeEvent(makeEvent({ tool_name: 'Bash' }));
70
+ storage.writeEvent(makeEvent({ tool_name: 'Bash' }));
71
+ storage.writeEvent(makeEvent({ tool_name: 'Read' }));
72
+ storage.writeEvent(makeEvent({ hook_type: 'UserPromptSubmit' })); // null tool_name
73
+ const r = storage.aggregateToolUsage();
74
+ expect(r).toEqual([
75
+ { tool_name: 'Bash', count: 2 },
76
+ { tool_name: 'Read', count: 1 },
77
+ ]);
78
+ });
79
+ it('hook_type 过滤只统计指定类型', () => {
80
+ storage.writeEvent(makeEvent({ tool_name: 'Bash', hook_type: 'PreToolUse' }));
81
+ storage.writeEvent(makeEvent({ tool_name: 'Bash', hook_type: 'PostToolUse' }));
82
+ storage.writeEvent(makeEvent({ tool_name: 'Read', hook_type: 'PreToolUse' }));
83
+ const r = storage.aggregateToolUsage({ hook_type: 'PreToolUse' });
84
+ expect(r.find(x => x.tool_name === 'Bash')?.count).toBe(1);
85
+ expect(r.find(x => x.tool_name === 'Read')?.count).toBe(1);
86
+ });
87
+ it('since 过滤排除早期事件', () => {
88
+ storage.writeEvent(makeEvent({ tool_name: 'Bash', timestamp: '2026-01-01T00:00:00.000Z' }));
89
+ storage.writeEvent(makeEvent({ tool_name: 'Bash', timestamp: '2026-05-10T00:00:00.000Z', session_id: 's2' }));
90
+ const r = storage.aggregateToolUsage({ since: '2026-03-01T00:00:00.000Z' });
91
+ expect(r).toEqual([{ tool_name: 'Bash', count: 1 }]);
92
+ });
93
+ });
94
+
95
+ // ── aggregateDailyEventCounts ──────────────────────────────────────
96
+ describe('aggregateDailyEventCounts', () => {
97
+ it('按 date 分组并升序', () => {
98
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T10:00:00.000Z' }));
99
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T15:00:00.000Z', session_id: 's2' }));
100
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-11T10:00:00.000Z', session_id: 's3' }));
101
+ const r = storage.aggregateDailyEventCounts({ since: '2026-05-01T00:00:00.000Z' });
102
+ expect(r).toEqual([
103
+ { date: '2026-05-10', count: 2 },
104
+ { date: '2026-05-11', count: 1 },
105
+ ]);
106
+ });
107
+ it('until 过滤排除上界', () => {
108
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T10:00:00.000Z' }));
109
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-15T10:00:00.000Z', session_id: 's2' }));
110
+ const r = storage.aggregateDailyEventCounts({
111
+ since: '2026-05-01T00:00:00.000Z',
112
+ until: '2026-05-12T00:00:00.000Z',
113
+ });
114
+ expect(r).toEqual([{ date: '2026-05-10', count: 1 }]);
115
+ });
116
+ });
117
+
118
+ // ── aggregateHookTypeBySession ─────────────────────────────────────
119
+ describe('aggregateHookTypeBySession', () => {
120
+ it('空表返回空', () => {
121
+ expect(storage.aggregateHookTypeBySession('nope')).toEqual([]);
122
+ });
123
+ it('只统计指定 session 的 hook_type', () => {
124
+ storage.writeEvent(makeEvent({ session_id: 'a', hook_type: 'PreToolUse' }));
125
+ storage.writeEvent(makeEvent({ session_id: 'a', hook_type: 'PostToolUse' }));
126
+ storage.writeEvent(makeEvent({ session_id: 'a', hook_type: 'PreToolUse' }));
127
+ storage.writeEvent(makeEvent({ session_id: 'b', hook_type: 'PreToolUse' }));
128
+ const r = storage.aggregateHookTypeBySession('a');
129
+ const map = Object.fromEntries(r.map(x => [x.hook_type, x.count]));
130
+ expect(map).toEqual({ PreToolUse: 2, PostToolUse: 1 });
131
+ });
132
+ });
133
+
134
+ // ── aggregateAgentTypeBySession ────────────────────────────────────
135
+ describe('aggregateAgentTypeBySession', () => {
136
+ it('json_extract subagent_type 并分组', () => {
137
+ storage.writeEvent(makeEvent({
138
+ session_id: 'a',
139
+ tool_name: 'Task',
140
+ tool_input: { subagent_type: 'researcher' },
141
+ }));
142
+ storage.writeEvent(makeEvent({
143
+ session_id: 'a',
144
+ tool_name: 'Task',
145
+ tool_input: { subagent_type: 'researcher' },
146
+ }));
147
+ storage.writeEvent(makeEvent({
148
+ session_id: 'a',
149
+ tool_name: 'Agent',
150
+ tool_input: { subagent_type: 'coder' },
151
+ }));
152
+ // 非 Agent/Task 不应被纳入
153
+ storage.writeEvent(makeEvent({
154
+ session_id: 'a',
155
+ tool_name: 'Bash',
156
+ tool_input: { foo: 'bar' },
157
+ }));
158
+ const r = storage.aggregateAgentTypeBySession('a');
159
+ const map = Object.fromEntries(r.map(x => [x.agent_type, x.count]));
160
+ expect(map.researcher).toBe(2);
161
+ expect(map.coder).toBe(1);
162
+ });
163
+ });
164
+
165
+ // ── aggregateToolUsageBySession ────────────────────────────────────
166
+ describe('aggregateToolUsageBySession', () => {
167
+ it('按 session_id 统计 tool_name', () => {
168
+ storage.writeEvent(makeEvent({ session_id: 'a', tool_name: 'Bash' }));
169
+ storage.writeEvent(makeEvent({ session_id: 'a', tool_name: 'Bash' }));
170
+ storage.writeEvent(makeEvent({ session_id: 'a', tool_name: 'Read' }));
171
+ storage.writeEvent(makeEvent({ session_id: 'b', tool_name: 'Bash' }));
172
+ const r = storage.aggregateToolUsageBySession('a');
173
+ expect(r).toEqual([
174
+ { tool_name: 'Bash', count: 2 },
175
+ { tool_name: 'Read', count: 1 },
176
+ ]);
177
+ });
178
+ });
179
+
180
+ // ── countActiveDays ────────────────────────────────────────────────
181
+ describe('countActiveDays', () => {
182
+ it('distinct date 计数', () => {
183
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T10:00:00.000Z' }));
184
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T11:00:00.000Z', session_id: 's2' }));
185
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-11T10:00:00.000Z', session_id: 's3' }));
186
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-12T10:00:00.000Z', session_id: 's4' }));
187
+ expect(storage.countActiveDays({ since: '2026-05-01T00:00:00.000Z' })).toBe(3);
188
+ });
189
+ it('空范围返回 0', () => {
190
+ expect(storage.countActiveDays({ since: '2026-05-01T00:00:00.000Z' })).toBe(0);
191
+ });
192
+ });
193
+
194
+ // ── aggregateOverviewByRange ───────────────────────────────────────
195
+ describe('aggregateOverviewByRange', () => {
196
+ it('返回 event/session/day 复合计数', () => {
197
+ storage.writeEvent(makeEvent({ session_id: 'a', timestamp: '2026-05-10T10:00:00.000Z' }));
198
+ storage.writeEvent(makeEvent({ session_id: 'a', timestamp: '2026-05-10T11:00:00.000Z' }));
199
+ storage.writeEvent(makeEvent({ session_id: 'b', timestamp: '2026-05-11T10:00:00.000Z' }));
200
+ const r = storage.aggregateOverviewByRange({
201
+ since: '2026-05-01T00:00:00.000Z',
202
+ until: '2026-06-01T00:00:00.000Z',
203
+ });
204
+ expect(r.event_count).toBe(3);
205
+ expect(r.session_count).toBe(2);
206
+ expect(r.day_count).toBe(2);
207
+ });
208
+ it('空表返回全 0', () => {
209
+ const r = storage.aggregateOverviewByRange({
210
+ since: '2026-05-01T00:00:00.000Z',
211
+ until: '2026-06-01T00:00:00.000Z',
212
+ });
213
+ expect(r).toEqual({ event_count: 0, session_count: 0, day_count: 0 });
214
+ });
215
+ });
216
+
217
+ // ── queryDistinctProjects ──────────────────────────────────────────
218
+ describe('queryDistinctProjects', () => {
219
+ it('返回 distinct project_path', () => {
220
+ storage.writeEvent(makeEvent({ project_path: '/a' }));
221
+ storage.writeEvent(makeEvent({ project_path: '/a', session_id: 's2' }));
222
+ storage.writeEvent(makeEvent({ project_path: '/b', session_id: 's3' }));
223
+ const r = storage.queryDistinctProjects({
224
+ since: '2026-05-01T00:00:00.000Z',
225
+ until: '2026-06-01T00:00:00.000Z',
226
+ });
227
+ expect(r.sort()).toEqual(['/a', '/b']);
228
+ });
229
+ });
230
+
231
+ // ── aggregateToolFailureRate ───────────────────────────────────────
232
+ describe('aggregateToolFailureRate', () => {
233
+ it('LIKE %error% 三个分支正确识别失败', () => {
234
+ // PostToolUse with error
235
+ storage.writeEvent(makeEvent({
236
+ hook_type: 'PostToolUse',
237
+ tool_name: 'Bash',
238
+ tool_output: { error: 'oops' }, // becomes '"error":"oops"' in JSON
239
+ }));
240
+ storage.writeEvent(makeEvent({
241
+ hook_type: 'PostToolUse',
242
+ tool_name: 'Bash',
243
+ tool_output: { is_error: true },
244
+ session_id: 's2',
245
+ }));
246
+ storage.writeEvent(makeEvent({
247
+ hook_type: 'PostToolUse',
248
+ tool_name: 'Bash',
249
+ tool_output: { isError: true },
250
+ session_id: 's3',
251
+ }));
252
+ // PostToolUse OK
253
+ storage.writeEvent(makeEvent({
254
+ hook_type: 'PostToolUse',
255
+ tool_name: 'Bash',
256
+ tool_output: { result: 'ok' },
257
+ session_id: 's4',
258
+ }));
259
+ // PreToolUse should not count
260
+ storage.writeEvent(makeEvent({
261
+ hook_type: 'PreToolUse',
262
+ tool_name: 'Bash',
263
+ tool_output: { error: 'should not count' },
264
+ session_id: 's5',
265
+ }));
266
+
267
+ const r = storage.aggregateToolFailureRate({
268
+ since: '2026-05-01T00:00:00.000Z',
269
+ until: '2026-06-01T00:00:00.000Z',
270
+ });
271
+ expect(r.post_total).toBe(4);
272
+ expect(r.failed).toBe(3);
273
+ });
274
+ it('空范围返回 0/0', () => {
275
+ const r = storage.aggregateToolFailureRate({
276
+ since: '2026-05-01T00:00:00.000Z',
277
+ until: '2026-06-01T00:00:00.000Z',
278
+ });
279
+ expect(r).toEqual({ post_total: 0, failed: 0 });
280
+ });
281
+ });
282
+
283
+ // ── queryFileEditInputs ────────────────────────────────────────────
284
+ describe('queryFileEditInputs', () => {
285
+ it('IN 展开多个 tool_name', () => {
286
+ storage.writeEvent(makeEvent({
287
+ hook_type: 'PreToolUse',
288
+ tool_name: 'Edit',
289
+ tool_input: { file_path: '/a.ts' },
290
+ }));
291
+ storage.writeEvent(makeEvent({
292
+ hook_type: 'PreToolUse',
293
+ tool_name: 'Write',
294
+ tool_input: { file_path: '/b.ts' },
295
+ session_id: 's2',
296
+ }));
297
+ storage.writeEvent(makeEvent({
298
+ hook_type: 'PreToolUse',
299
+ tool_name: 'Bash', // not file edit
300
+ tool_input: { command: 'ls' },
301
+ session_id: 's3',
302
+ }));
303
+ const r = storage.queryFileEditInputs({
304
+ since: '2026-05-01T00:00:00.000Z',
305
+ until: '2026-06-01T00:00:00.000Z',
306
+ tool_names: ['Edit', 'Write', 'MultiEdit'],
307
+ });
308
+ expect(r.length).toBe(2);
309
+ expect(r.map(x => JSON.parse(x.tool_input).file_path).sort()).toEqual(['/a.ts', '/b.ts']);
310
+ });
311
+ it('空 tool_names 返回空', () => {
312
+ storage.writeEvent(makeEvent({ tool_name: 'Edit', tool_input: { file_path: '/a.ts' } }));
313
+ expect(storage.queryFileEditInputs({
314
+ since: '2026-05-01T00:00:00.000Z',
315
+ until: '2026-06-01T00:00:00.000Z',
316
+ tool_names: [],
317
+ })).toEqual([]);
318
+ });
319
+ });
320
+
321
+ // ── queryEventsByTimeRange ─────────────────────────────────────────
322
+ describe('queryEventsByTimeRange', () => {
323
+ it('返回 [since, until) 区间事件,升序', () => {
324
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T10:00:00.000Z', tool_name: 'A' }));
325
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-11T10:00:00.000Z', tool_name: 'B', session_id: 's2' }));
326
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-12T10:00:00.000Z', tool_name: 'C', session_id: 's3' }));
327
+ const r = storage.queryEventsByTimeRange({
328
+ since: '2026-05-10T00:00:00.000Z',
329
+ until: '2026-05-12T00:00:00.000Z',
330
+ });
331
+ expect(r.length).toBe(2);
332
+ expect(r[0].tool_name).toBe('A');
333
+ expect(r[1].tool_name).toBe('B');
334
+ });
335
+ it('未指定 until 返回 since 起始的全部', () => {
336
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-10T10:00:00.000Z' }));
337
+ storage.writeEvent(makeEvent({ timestamp: '2026-05-20T10:00:00.000Z', session_id: 's2' }));
338
+ const r = storage.queryEventsByTimeRange({ since: '2026-05-15T00:00:00.000Z' });
339
+ expect(r.length).toBe(1);
340
+ });
341
+ });
342
+ });