@winspan/claude-forge 8.50.6 → 8.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) 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/index.d.ts.map +1 -1
  114. package/dist/daemon/index.js +23 -9
  115. package/dist/daemon/index.js.map +1 -1
  116. package/dist/daemon/lifecycle.js +3 -4
  117. package/dist/daemon/lifecycle.js.map +1 -1
  118. package/dist/daemon/server.d.ts +6 -4
  119. package/dist/daemon/server.d.ts.map +1 -1
  120. package/dist/daemon/server.js +76 -85
  121. package/dist/daemon/server.js.map +1 -1
  122. package/dist/daemon/services/task-segmenter.js +1 -1
  123. package/dist/daemon/services/task-segmenter.js.map +1 -1
  124. package/dist/hooks/hook-lib.sh +37 -0
  125. package/dist/hooks/notification.sh +2 -2
  126. package/dist/hooks/post-tool-use.sh +2 -2
  127. package/dist/hooks/pre-tool-use.sh +2 -2
  128. package/dist/hooks/stop.sh +9 -6
  129. package/dist/hooks/user-prompt-submit.sh +2 -2
  130. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.d.ts +3 -4
  131. package/dist/web/analytics/anti-pattern-detector.d.ts.map +1 -0
  132. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.js +7 -46
  133. package/dist/{daemon/services → web/analytics}/anti-pattern-detector.js.map +1 -1
  134. package/dist/web/analytics/drift-detector.d.ts.map +1 -0
  135. package/dist/{daemon/services → web/analytics}/drift-detector.js +10 -13
  136. package/dist/web/analytics/drift-detector.js.map +1 -0
  137. package/dist/web/analytics/weekly-report.d.ts.map +1 -0
  138. package/dist/{daemon/services → web/analytics}/weekly-report.js +51 -50
  139. package/dist/web/analytics/weekly-report.js.map +1 -0
  140. package/dist/web/auth-middleware.d.ts.map +1 -1
  141. package/dist/web/auth-middleware.js +1 -2
  142. package/dist/web/auth-middleware.js.map +1 -1
  143. package/dist/web/routes/_helpers.d.ts +16 -0
  144. package/dist/web/routes/_helpers.d.ts.map +1 -0
  145. package/dist/web/routes/_helpers.js +32 -0
  146. package/dist/web/routes/_helpers.js.map +1 -0
  147. package/dist/web/routes/drift.js +1 -1
  148. package/dist/web/routes/drift.js.map +1 -1
  149. package/dist/web/routes/insights.js +1 -1
  150. package/dist/web/routes/insights.js.map +1 -1
  151. package/dist/web/routes/reports.js +1 -1
  152. package/dist/web/routes/reports.js.map +1 -1
  153. package/dist/web/routes/rules.d.ts +3 -0
  154. package/dist/web/routes/rules.d.ts.map +1 -1
  155. package/dist/web/routes/rules.js +28 -52
  156. package/dist/web/routes/rules.js.map +1 -1
  157. package/dist/web/routes/sessions.d.ts.map +1 -1
  158. package/dist/web/routes/sessions.js +16 -30
  159. package/dist/web/routes/sessions.js.map +1 -1
  160. package/dist/web/routes/skill-stats.d.ts +2 -0
  161. package/dist/web/routes/skill-stats.d.ts.map +1 -1
  162. package/dist/web/routes/skill-stats.js +28 -64
  163. package/dist/web/routes/skill-stats.js.map +1 -1
  164. package/dist/web/routes/skills.d.ts.map +1 -1
  165. package/dist/web/routes/skills.js +5 -4
  166. package/dist/web/routes/skills.js.map +1 -1
  167. package/dist/web/routes/stats.d.ts +4 -0
  168. package/dist/web/routes/stats.d.ts.map +1 -1
  169. package/dist/web/routes/stats.js +19 -21
  170. package/dist/web/routes/stats.js.map +1 -1
  171. package/dist/web/routes/tasks.d.ts.map +1 -1
  172. package/dist/web/routes/tasks.js +17 -42
  173. package/dist/web/routes/tasks.js.map +1 -1
  174. package/dist/web/routes/trace.d.ts.map +1 -1
  175. package/dist/web/routes/trace.js +7 -17
  176. package/dist/web/routes/trace.js.map +1 -1
  177. package/dist/web/routes/types.d.ts.map +1 -1
  178. package/dist/web/routes/types.js +4 -3
  179. package/dist/web/routes/types.js.map +1 -1
  180. package/dist/web/static/assets/{AIConfig-BQCAQE9D.js → AIConfig-CdDWzJyO.js} +2 -2
  181. package/dist/web/static/assets/{AIConfig-BQCAQE9D.js.map → AIConfig-CdDWzJyO.js.map} +1 -1
  182. package/dist/web/static/assets/{Dashboard-D7Bo6Kan.js → Dashboard-CoEmmIDt.js} +2 -2
  183. package/dist/web/static/assets/{Dashboard-D7Bo6Kan.js.map → Dashboard-CoEmmIDt.js.map} +1 -1
  184. package/dist/web/static/assets/{Drawer-BeHRQxUS.js → Drawer-DdRTzlLB.js} +2 -2
  185. package/dist/web/static/assets/{Drawer-BeHRQxUS.js.map → Drawer-DdRTzlLB.js.map} +1 -1
  186. package/dist/web/static/assets/{Events-K_tCY2ti.js → Events-DrIq1SUS.js} +2 -2
  187. package/dist/web/static/assets/{Events-K_tCY2ti.js.map → Events-DrIq1SUS.js.map} +1 -1
  188. package/dist/web/static/assets/{Reports-BJCmBnc_.js → Reports-DFBM3MDK.js} +2 -2
  189. package/dist/web/static/assets/{Reports-BJCmBnc_.js.map → Reports-DFBM3MDK.js.map} +1 -1
  190. package/dist/web/static/assets/{SearchInput-BX2KhMkw.js → SearchInput-qCj_jAcf.js} +2 -2
  191. package/dist/web/static/assets/{SearchInput-BX2KhMkw.js.map → SearchInput-qCj_jAcf.js.map} +1 -1
  192. package/dist/web/static/assets/{SessionDetail-Bkr-kC7V.js → SessionDetail-CCzwdoT7.js} +2 -2
  193. package/dist/web/static/assets/{SessionDetail-Bkr-kC7V.js.map → SessionDetail-CCzwdoT7.js.map} +1 -1
  194. package/dist/web/static/assets/{Sessions-Chx9OCLH.js → Sessions-FfLYkAw9.js} +2 -2
  195. package/dist/web/static/assets/{Sessions-Chx9OCLH.js.map → Sessions-FfLYkAw9.js.map} +1 -1
  196. package/dist/web/static/assets/{Skills-O0GT1i7m.js → Skills-C8Gvs3Qa.js} +2 -2
  197. package/dist/web/static/assets/{Skills-O0GT1i7m.js.map → Skills-C8Gvs3Qa.js.map} +1 -1
  198. package/dist/web/static/assets/TaskDetail-BS8pYhaR.js +2 -0
  199. package/dist/web/static/assets/TaskDetail-BS8pYhaR.js.map +1 -0
  200. package/dist/web/static/assets/Tasks-CyuhizG8.js +2 -0
  201. package/dist/web/static/assets/Tasks-CyuhizG8.js.map +1 -0
  202. package/dist/web/static/assets/index-CBX47X8l.js +3 -0
  203. package/dist/web/static/assets/{index-DxIbmNmr.js.map → index-CBX47X8l.js.map} +1 -1
  204. package/dist/web/static/assets/index-DjIoMdoR.css +1 -0
  205. package/dist/web/static/assets/{lucide-fJlPI3H7.js → lucide-Bs_edTLa.js} +44 -39
  206. package/dist/web/static/assets/lucide-Bs_edTLa.js.map +1 -0
  207. package/dist/web/static/assets/react-router-r79dBVy4.js +20 -0
  208. package/dist/web/static/assets/{react-router-I-HqunH7.js.map → react-router-r79dBVy4.js.map} +1 -1
  209. package/dist/web/static/assets/task-title-BhOcemuR.js +2 -0
  210. package/dist/web/static/assets/task-title-BhOcemuR.js.map +1 -0
  211. package/dist/web/static/index.html +4 -4
  212. package/docs/design/h1-storage-aggregation-spec-20260518-1121.md +299 -0
  213. package/docs/design/h2-getdatabase-encapsulation-spec-20260518-1450.md +191 -0
  214. package/docs/design/h3-fallback-removal-spec-20260518-1245.md +76 -0
  215. package/docs/design/h4-index-dedup-spec-20260518-1230.md +109 -0
  216. package/docs/design/h6-services-migration-spec-20260518-1355.md +82 -0
  217. package/docs/design/l1-swarm-protocol-extract-spec-20260518-1605.md +106 -0
  218. package/docs/design/m10-forge-paths-spec-20260518-1320.md +121 -0
  219. package/docs/design/m2-m3-tool-input-spec-20260518-1425.md +131 -0
  220. package/docs/design/m7-routing-event-association-spec-20260518-1545.md +103 -0
  221. package/docs/design/project-path-gitroot-spec-20260518-1715.md +134 -0
  222. package/docs/design/task-active-gc-spec-20260518-1745.md +146 -0
  223. package/docs/implementation/h1-storage-aggregation-changelog-20260518-1121.md +82 -0
  224. package/docs/implementation/h2-final-changelog-20260518-1530.md +61 -0
  225. package/docs/implementation/h2-phase1-safety-net-changelog-20260518-1450.md +70 -0
  226. package/docs/implementation/h2-phase2-operations-changelog-20260518-1450.md +120 -0
  227. package/docs/implementation/h2-phase3-callsites-changelog-20260518-1450.md +71 -0
  228. package/docs/implementation/h3-fallback-removal-changelog-20260518-1245.md +71 -0
  229. package/docs/implementation/h4-index-dedup-changelog-20260518-1230.md +60 -0
  230. package/docs/implementation/h6-services-migration-changelog-20260518-1355.md +46 -0
  231. package/docs/implementation/h7-m9-defaults-changelog-20260518-1300.md +46 -0
  232. package/docs/implementation/l1-swarm-protocol-extract-changelog-20260518-1605.md +45 -0
  233. package/docs/implementation/l3-l4-daemon-perf-changelog-20260518-1410.md +63 -0
  234. package/docs/implementation/l6-l8-final-cleanup-changelog-20260518-1640.md +38 -0
  235. package/docs/implementation/m1-m4-m5-l7-cleanup-changelog-20260518-1310.md +58 -0
  236. package/docs/implementation/m10-forge-paths-changelog-20260518-1320.md +60 -0
  237. package/docs/implementation/m2-m3-tool-input-changelog-20260518-1425.md +43 -0
  238. package/docs/implementation/m6-m8-naming-shutdown-changelog-20260518-1340.md +56 -0
  239. package/docs/implementation/m7-routing-association-changelog-20260518-1545.md +69 -0
  240. package/docs/implementation/project-path-gitroot-changelog-20260518-1715.md +63 -0
  241. package/docs/implementation/task-active-gc-changelog-20260518-1745.md +35 -0
  242. package/docs/implementation/task-title-summary-changelog-20260518-1130.md +39 -0
  243. package/docs/implementation/tasks-detail-back-loses-filters-changelog-20260518-1100.md +22 -0
  244. package/docs/implementation/tasks-page-white-screen-hotfix-changelog-20260518-1015.md +56 -0
  245. package/docs/reviews/task-title-summary.md +92 -0
  246. package/docs/reviews/tasks-detail-back-loses-filters.md +58 -0
  247. package/docs/reviews/tasks-page-white-screen-hotfix.md +126 -0
  248. package/package.json +2 -2
  249. package/src/claudemd/claudemd-generator.ts +29 -238
  250. package/src/claudemd/resume-manager.ts +1 -1
  251. package/src/claudemd/templates/swarm-protocol.md +222 -0
  252. package/src/cli/commands/daemon.ts +6 -6
  253. package/src/cli/commands/executions.ts +4 -3
  254. package/src/cli/commands/init.ts +2 -2
  255. package/src/cli/commands/logs.ts +1 -1
  256. package/src/cli/commands/mcp.ts +3 -5
  257. package/src/cli/commands/menu.ts +4 -3
  258. package/src/cli/commands/stats.ts +2 -3
  259. package/src/cli/commands/status.ts +2 -2
  260. package/src/cli/commands/trace.ts +10 -26
  261. package/src/cli/init/hook-manager.ts +2 -2
  262. package/src/core/ai/provider.ts +2 -2
  263. package/src/core/constants.ts +18 -1
  264. package/src/core/event-fields.ts +32 -0
  265. package/src/core/queue/index.ts +3 -4
  266. package/src/core/storage/base.ts +132 -56
  267. package/src/core/storage/events.ts +183 -4
  268. package/src/core/storage/routing.ts +129 -1
  269. package/src/core/storage/schema.sql +12 -2
  270. package/src/core/storage/sessions.ts +64 -0
  271. package/src/core/storage/skills.ts +69 -0
  272. package/src/core/storage/sqlite.ts +103 -4
  273. package/src/core/storage/tasks.ts +149 -1
  274. package/src/core/storage/token-usage.ts +1 -1
  275. package/src/core/types.ts +30 -3
  276. package/src/core/utils/error-handler.ts +3 -2
  277. package/src/core/utils/git.ts +23 -0
  278. package/src/core/utils/logger.ts +16 -1
  279. package/src/core/utils/lru-cache.ts +4 -0
  280. package/src/core/utils/token-tracker.ts +1 -1
  281. package/src/daemon/event-parser.ts +4 -3
  282. package/src/daemon/handlers/history-exporter.ts +1 -1
  283. package/src/daemon/handlers/post-tool-use.ts +7 -3
  284. package/src/daemon/handlers/stop.ts +32 -39
  285. package/src/daemon/handlers/user-prompt.ts +12 -22
  286. package/src/daemon/index.ts +24 -10
  287. package/src/daemon/lifecycle.ts +3 -3
  288. package/src/daemon/server.ts +76 -89
  289. package/src/daemon/services/task-segmenter.ts +1 -1
  290. package/src/hooks/hook-lib.sh +37 -0
  291. package/src/hooks/notification.sh +2 -2
  292. package/src/hooks/post-tool-use.sh +2 -2
  293. package/src/hooks/pre-tool-use.sh +2 -2
  294. package/src/hooks/stop.sh +9 -6
  295. package/src/hooks/user-prompt-submit.sh +2 -2
  296. package/src/{daemon/services → web/analytics}/anti-pattern-detector.ts +9 -54
  297. package/src/{daemon/services → web/analytics}/drift-detector.ts +10 -23
  298. package/src/{daemon/services → web/analytics}/weekly-report.ts +52 -75
  299. package/src/web/auth-middleware.ts +1 -2
  300. package/src/web/routes/_helpers.ts +34 -0
  301. package/src/web/routes/drift.ts +1 -1
  302. package/src/web/routes/insights.ts +1 -1
  303. package/src/web/routes/reports.ts +1 -1
  304. package/src/web/routes/rules.ts +31 -56
  305. package/src/web/routes/sessions.ts +18 -30
  306. package/src/web/routes/skill-stats.ts +29 -69
  307. package/src/web/routes/skills.ts +5 -4
  308. package/src/web/routes/stats.ts +19 -29
  309. package/src/web/routes/tasks.ts +17 -42
  310. package/src/web/routes/trace.ts +7 -19
  311. package/src/web/routes/types.ts +4 -3
  312. package/tests/integration/claudemd-generator.test.ts +90 -0
  313. package/tests/integration/web-analytics.integration.test.ts +133 -0
  314. package/tests/integration/web-stats.integration.test.ts +135 -0
  315. package/tests/integration/web-trace.integration.test.ts +175 -0
  316. package/tests/unit/core/forge-paths.test.ts +99 -0
  317. package/tests/unit/daemon/post-tool-use.test.ts +121 -0
  318. package/tests/unit/daemon/stop-handler-behavior-summary.test.ts +202 -0
  319. package/tests/unit/daemon/task-segmenter-recover.test.ts +84 -0
  320. package/tests/unit/event-fields.test.ts +88 -0
  321. package/tests/unit/event-parser.test.ts +55 -0
  322. package/tests/unit/hooks/resolve-project-path.test.ts +122 -0
  323. package/tests/unit/socket-server.test.ts +183 -0
  324. package/tests/unit/storage/event-operations-aggregates.test.ts +342 -0
  325. package/tests/unit/storage/migration-idempotent.test.ts +304 -0
  326. package/tests/unit/storage/routing-aggregates.test.ts +276 -0
  327. package/tests/unit/storage/routing.test.ts +117 -0
  328. package/tests/unit/storage/schema-missing.test.ts +81 -0
  329. package/tests/unit/storage/session-operations-aggregates.test.ts +120 -0
  330. package/tests/unit/storage/skill-operations-counts.test.ts +106 -0
  331. package/tests/unit/storage/skills-aggregates.test.ts +104 -0
  332. package/tests/unit/storage/sqlite-refactor-harness.test.ts +3 -3
  333. package/tests/unit/storage/task-operations-counts.test.ts +46 -0
  334. package/tests/unit/storage/tasks-getById.test.ts +343 -0
  335. package/tests/unit/storage/tasks-stale-gc.test.ts +86 -0
  336. package/tests/unit/token-usage.test.ts +6 -6
  337. package/tests/unit/web/navigation-back-contract.test.ts +134 -0
  338. package/tests/unit/web/routes-rules.test.ts +182 -0
  339. package/tests/unit/web/routes-tasks.test.ts +34 -0
  340. package/tests/unit/web/task-title-contract.test.ts +210 -0
  341. package/tests/unit/web/tasks-component-contract.test.ts +179 -0
  342. package/vitest.config.ts +1 -1
  343. package/web/src/pages/TaskDetail.tsx +9 -5
  344. package/web/src/pages/Tasks.tsx +315 -50
  345. package/web/src/utils/navigation.ts +25 -0
  346. package/web/src/utils/task-title.ts +49 -0
  347. package/dist/daemon/services/anti-pattern-detector.d.ts.map +0 -1
  348. package/dist/daemon/services/drift-detector.d.ts.map +0 -1
  349. package/dist/daemon/services/drift-detector.js.map +0 -1
  350. package/dist/daemon/services/weekly-report.d.ts.map +0 -1
  351. package/dist/daemon/services/weekly-report.js.map +0 -1
  352. package/dist/web/static/assets/TaskDetail-5SR8zGzv.js +0 -2
  353. package/dist/web/static/assets/TaskDetail-5SR8zGzv.js.map +0 -1
  354. package/dist/web/static/assets/Tasks-DCgDqvOZ.js +0 -2
  355. package/dist/web/static/assets/Tasks-DCgDqvOZ.js.map +0 -1
  356. package/dist/web/static/assets/index-D8AKj26b.css +0 -1
  357. package/dist/web/static/assets/index-DxIbmNmr.js +0 -3
  358. package/dist/web/static/assets/lucide-fJlPI3H7.js.map +0 -1
  359. package/dist/web/static/assets/react-router-I-HqunH7.js +0 -20
  360. /package/dist/{daemon/services → web/analytics}/drift-detector.d.ts +0 -0
  361. /package/dist/{daemon/services → web/analytics}/weekly-report.d.ts +0 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * H1 storage: tasks detail PK 直查 / JOIN 接口
3
+ *
4
+ * 覆盖:
5
+ * - getTask 命中 / null
6
+ * - getTask 通过 LEFT JOIN sessions 取 project_path
7
+ * - queryEventsByTaskId JOIN 正确性 + timestamp ASC 排序 + limit
8
+ * - queryInjectionsByTaskId event_id 关联
9
+ * - querySkillInvocationsByTaskWindow 时间窗包含/排除
10
+ * - querySkillInvocationsByTaskWindow end_time IS NULL 时取 now_ms
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
19
+ import type { ForgeEvent } from '../../../src/core/types.js';
20
+
21
+ const SESSION = 'sess-t';
22
+ const PROJECT = '/tmp/proj-t';
23
+
24
+ /** Map a friendly short id to a deterministic-per-test UUID. */
25
+ const idMap = new Map<string, string>();
26
+ function uid(short: string): string {
27
+ let v = idMap.get(short);
28
+ if (!v) {
29
+ v = randomUUID();
30
+ idMap.set(short, v);
31
+ }
32
+ return v;
33
+ }
34
+ function resetIdMap() { idMap.clear(); }
35
+
36
+ function makeEvent(overrides: Partial<ForgeEvent> & { event_id: string; timestamp: string }): ForgeEvent {
37
+ return {
38
+ event_id: overrides.event_id,
39
+ session_id: overrides.session_id ?? SESSION,
40
+ project_path: overrides.project_path ?? PROJECT,
41
+ timestamp: overrides.timestamp,
42
+ hook_type: overrides.hook_type ?? 'UserPromptSubmit',
43
+ user_prompt: overrides.user_prompt ?? 'hi',
44
+ ...overrides,
45
+ } as ForgeEvent;
46
+ }
47
+
48
+ function makeInv(overrides: {
49
+ id: string;
50
+ skill_id: string;
51
+ timestamp: number;
52
+ session_id?: string;
53
+ }) {
54
+ return {
55
+ id: overrides.id,
56
+ route_request_id: null,
57
+ session_id: overrides.session_id ?? SESSION,
58
+ agent_id: null,
59
+ skill_id: overrides.skill_id,
60
+ invocation_type: 'dynamic' as const,
61
+ reason: null,
62
+ workflow: null,
63
+ phase: null,
64
+ feature_slug: null,
65
+ artifact_path: null,
66
+ depth: 0,
67
+ success: 1 as 0 | 1,
68
+ error: null,
69
+ timestamp: overrides.timestamp,
70
+ };
71
+ }
72
+
73
+ /** 让 sessions 表有这条 session_id(写一条事件就会 upsertSession) */
74
+ function seedSession(storage: SQLiteStorage, sessionId: string, projectPath: string) {
75
+ storage.writeEvent(makeEvent({
76
+ event_id: randomUUID(),
77
+ session_id: sessionId,
78
+ project_path: projectPath,
79
+ timestamp: '2026-01-01T00:00:00.000Z',
80
+ hook_type: 'UserPromptSubmit',
81
+ user_prompt: 'seed',
82
+ }));
83
+ }
84
+
85
+ describe('storage.getTask', () => {
86
+ let tmp: string;
87
+ let storage: SQLiteStorage;
88
+
89
+ beforeEach(() => {
90
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-tasks-'));
91
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
92
+ resetIdMap();
93
+ });
94
+
95
+ afterEach(() => {
96
+ try { storage.close(); } catch { /* ignore */ }
97
+ rmSync(tmp, { recursive: true, force: true });
98
+ });
99
+
100
+ it('未知 taskId 返回 null', () => {
101
+ expect(storage.getTask('no-such-task')).toBeNull();
102
+ });
103
+
104
+ it('命中:返回 TaskRecord,含 LEFT JOIN 来的 project_path', () => {
105
+ seedSession(storage, SESSION, PROJECT);
106
+ storage.writeTask({
107
+ id: 't1',
108
+ session_id: SESSION,
109
+ title: 'Build something',
110
+ start_time: '2026-01-01T10:00:00.000Z',
111
+ });
112
+
113
+ const t = storage.getTask('t1');
114
+ expect(t).not.toBeNull();
115
+ expect(t!.id).toBe('t1');
116
+ expect(t!.session_id).toBe(SESSION);
117
+ expect(t!.title).toBe('Build something');
118
+ expect(t!.start_time).toBe('2026-01-01T10:00:00.000Z');
119
+ expect(t!.status).toBe('active');
120
+ expect(t!.event_count).toBe(0);
121
+ expect(t!.project_path).toBe(PROJECT);
122
+ });
123
+
124
+ it('session 不存在时 project_path 为 undefined(LEFT JOIN)', () => {
125
+ storage.writeTask({
126
+ id: 't-orphan',
127
+ session_id: 'missing-session',
128
+ title: 'Orphan',
129
+ start_time: '2026-01-01T10:00:00.000Z',
130
+ });
131
+ const t = storage.getTask('t-orphan');
132
+ expect(t).not.toBeNull();
133
+ expect(t!.project_path).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe('storage.queryEventsByTaskId', () => {
138
+ let tmp: string;
139
+ let storage: SQLiteStorage;
140
+
141
+ beforeEach(() => {
142
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-tasks-evt-'));
143
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
144
+ resetIdMap();
145
+ });
146
+
147
+ afterEach(() => {
148
+ try { storage.close(); } catch { /* ignore */ }
149
+ rmSync(tmp, { recursive: true, force: true });
150
+ });
151
+
152
+ it('未知 taskId 返回 []', () => {
153
+ expect(storage.queryEventsByTaskId('nope')).toEqual([]);
154
+ });
155
+
156
+ it('JOIN 仅返回 link 过的事件,且按 timestamp ASC 排序', () => {
157
+ seedSession(storage, SESSION, PROJECT);
158
+ storage.writeTask({
159
+ id: 't1', session_id: SESSION, title: 'T', start_time: '2026-01-01T10:00:00.000Z',
160
+ });
161
+
162
+ // 写 3 条事件,乱序 timestamp
163
+ storage.writeEvent(makeEvent({ event_id: uid('e3'), timestamp: '2026-01-01T10:30:00.000Z', hook_type: 'PostToolUse' }));
164
+ storage.writeEvent(makeEvent({ event_id: uid('e1'), timestamp: '2026-01-01T10:10:00.000Z', hook_type: 'PreToolUse' }));
165
+ storage.writeEvent(makeEvent({ event_id: uid('e2'), timestamp: '2026-01-01T10:20:00.000Z', hook_type: 'Stop' }));
166
+ // 一条不 link 的事件
167
+ storage.writeEvent(makeEvent({ event_id: uid('e-unlinked'), timestamp: '2026-01-01T10:15:00.000Z', hook_type: 'Notification' }));
168
+
169
+ storage.linkEventToTask('t1', uid('e1'));
170
+ storage.linkEventToTask('t1', uid('e2'));
171
+ storage.linkEventToTask('t1', uid('e3'));
172
+
173
+ const evts = storage.queryEventsByTaskId('t1');
174
+ expect(evts.map(e => e.event_id)).toEqual([uid('e1'), uid('e2'), uid('e3')]);
175
+ // 未 link 的不出现
176
+ expect(evts.find(e => e.event_id === uid('e-unlinked'))).toBeUndefined();
177
+ // hook_type 字段被正确还原
178
+ expect(evts[0].hook_type).toBe('PreToolUse');
179
+ });
180
+
181
+ it('limit 生效', () => {
182
+ seedSession(storage, SESSION, PROJECT);
183
+ storage.writeTask({
184
+ id: 't1', session_id: SESSION, title: 'T', start_time: '2026-01-01T10:00:00.000Z',
185
+ });
186
+ for (let i = 0; i < 5; i++) {
187
+ const short = `e${i}`;
188
+ const id = uid(short);
189
+ storage.writeEvent(makeEvent({ event_id: id, timestamp: `2026-01-01T10:0${i}:00.000Z` }));
190
+ storage.linkEventToTask('t1', id);
191
+ }
192
+ const evts = storage.queryEventsByTaskId('t1', { limit: 3 });
193
+ expect(evts).toHaveLength(3);
194
+ expect(evts.map(e => e.event_id)).toEqual([uid('e0'), uid('e1'), uid('e2')]);
195
+ });
196
+ });
197
+
198
+ describe('storage.queryInjectionsByTaskId', () => {
199
+ let tmp: string;
200
+ let storage: SQLiteStorage;
201
+
202
+ beforeEach(() => {
203
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-tasks-inj-'));
204
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
205
+ resetIdMap();
206
+ });
207
+
208
+ afterEach(() => {
209
+ try { storage.close(); } catch { /* ignore */ }
210
+ rmSync(tmp, { recursive: true, force: true });
211
+ });
212
+
213
+ it('未知 taskId 返回 []', () => {
214
+ expect(storage.queryInjectionsByTaskId('nope')).toEqual([]);
215
+ });
216
+
217
+ it('仅返回 event_id 关联到 task 的 injections,按 timestamp ASC', () => {
218
+ seedSession(storage, SESSION, PROJECT);
219
+ storage.writeTask({
220
+ id: 't1', session_id: SESSION, title: 'T', start_time: '2026-01-01T10:00:00.000Z',
221
+ });
222
+
223
+ // 两条 link 到 t1 的事件
224
+ storage.writeEvent(makeEvent({ event_id: uid('e1'), timestamp: '2026-01-01T10:10:00.000Z' }));
225
+ storage.writeEvent(makeEvent({ event_id: uid('e2'), timestamp: '2026-01-01T10:20:00.000Z' }));
226
+ // 一条未 link 的事件
227
+ storage.writeEvent(makeEvent({ event_id: uid('e-other'), timestamp: '2026-01-01T10:15:00.000Z' }));
228
+ storage.linkEventToTask('t1', uid('e1'));
229
+ storage.linkEventToTask('t1', uid('e2'));
230
+
231
+ storage.writeInjection({
232
+ id: 'i2', event_id: uid('e2'), session_id: SESSION,
233
+ timestamp: '2026-01-01T10:20:01.000Z',
234
+ source_handler: 'h', injection_type: 'systemMessage', content: 'b',
235
+ });
236
+ storage.writeInjection({
237
+ id: 'i1', event_id: uid('e1'), session_id: SESSION,
238
+ timestamp: '2026-01-01T10:10:01.000Z',
239
+ source_handler: 'h', injection_type: 'additionalContext', content: 'a',
240
+ });
241
+ // 未关联到 t1 的 injection
242
+ storage.writeInjection({
243
+ id: 'i-other', event_id: uid('e-other'), session_id: SESSION,
244
+ timestamp: '2026-01-01T10:15:01.000Z',
245
+ source_handler: 'h', injection_type: 'reason', content: 'x',
246
+ });
247
+
248
+ const injs = storage.queryInjectionsByTaskId('t1');
249
+ expect(injs.map(i => i.id)).toEqual(['i1', 'i2']);
250
+ expect(injs[0].event_id).toBe(uid('e1'));
251
+ expect(injs[1].injection_type).toBe('systemMessage');
252
+ });
253
+ });
254
+
255
+ describe('storage.querySkillInvocationsByTaskWindow', () => {
256
+ let tmp: string;
257
+ let storage: SQLiteStorage;
258
+
259
+ beforeEach(() => {
260
+ tmp = mkdtempSync(join(tmpdir(), 'forge-h1-tasks-skill-'));
261
+ storage = new SQLiteStorage(join(tmp, 'data.db'));
262
+ resetIdMap();
263
+ });
264
+
265
+ afterEach(() => {
266
+ try { storage.close(); } catch { /* ignore */ }
267
+ rmSync(tmp, { recursive: true, force: true });
268
+ });
269
+
270
+ it('未知 taskId 返回 []', () => {
271
+ expect(storage.querySkillInvocationsByTaskWindow('nope')).toEqual([]);
272
+ });
273
+
274
+ it('时间窗:仅包含 [start_time, end_time] 内 且 session 匹配 的 invocations,按 timestamp ASC', () => {
275
+ seedSession(storage, SESSION, PROJECT);
276
+ // task: 2026-01-01T10:00:00Z .. 2026-01-01T11:00:00Z
277
+ const startMs = Date.UTC(2026, 0, 1, 10, 0, 0);
278
+ const endMs = Date.UTC(2026, 0, 1, 11, 0, 0);
279
+ storage.writeTask({
280
+ id: 't1', session_id: SESSION, title: 'T',
281
+ start_time: '2026-01-01T10:00:00.000Z',
282
+ });
283
+ storage.updateTask('t1', { end_time: '2026-01-01T11:00:00.000Z' });
284
+
285
+ // 之前(排除)
286
+ storage.writeSkillInvocation(makeInv({
287
+ id: 'inv-before', skill_id: 'tdd', timestamp: startMs - 1000,
288
+ }));
289
+ // 边界 start(包含)
290
+ storage.writeSkillInvocation(makeInv({
291
+ id: 'inv-start', skill_id: 'tdd', timestamp: startMs,
292
+ }));
293
+ // 中间(包含)
294
+ storage.writeSkillInvocation(makeInv({
295
+ id: 'inv-mid', skill_id: 'debug', timestamp: startMs + 30 * 60 * 1000,
296
+ }));
297
+ // 边界 end(包含;implementation 给 end 加了 +999ms 容差)
298
+ storage.writeSkillInvocation(makeInv({
299
+ id: 'inv-end', skill_id: 'tdd', timestamp: endMs,
300
+ }));
301
+ // 之后(排除)
302
+ storage.writeSkillInvocation(makeInv({
303
+ id: 'inv-after', skill_id: 'tdd', timestamp: endMs + 5000,
304
+ }));
305
+ // 不同 session(排除)
306
+ seedSession(storage, 'other-sess', PROJECT);
307
+ storage.writeSkillInvocation(makeInv({
308
+ id: 'inv-other-session', skill_id: 'tdd', timestamp: startMs + 60000, session_id: 'other-sess',
309
+ }));
310
+
311
+ const invs = storage.querySkillInvocationsByTaskWindow('t1');
312
+ expect(invs.map(i => i.id)).toEqual(['inv-start', 'inv-mid', 'inv-end']);
313
+ });
314
+
315
+ it('end_time IS NULL → 取 now_ms 参数作为上界', () => {
316
+ seedSession(storage, SESSION, PROJECT);
317
+ const startMs = Date.UTC(2026, 0, 1, 10, 0, 0);
318
+ storage.writeTask({
319
+ id: 't-open', session_id: SESSION, title: 'Active',
320
+ start_time: '2026-01-01T10:00:00.000Z',
321
+ });
322
+ // 不 updateTask end_time → 保持 NULL
323
+
324
+ storage.writeSkillInvocation(makeInv({
325
+ id: 'inv-in-window', skill_id: 'tdd', timestamp: startMs + 60000,
326
+ }));
327
+ storage.writeSkillInvocation(makeInv({
328
+ id: 'inv-past-now', skill_id: 'tdd', timestamp: startMs + 999_000,
329
+ }));
330
+
331
+ // now_ms = startMs + 120_000 → inv-past-now 落在窗外
332
+ const invs = storage.querySkillInvocationsByTaskWindow('t-open', {
333
+ now_ms: startMs + 120_000,
334
+ });
335
+ expect(invs.map(i => i.id)).toEqual(['inv-in-window']);
336
+
337
+ // now_ms = startMs + 999_999 → 两条都在
338
+ const invs2 = storage.querySkillInvocationsByTaskWindow('t-open', {
339
+ now_ms: startMs + 999_999,
340
+ });
341
+ expect(invs2.map(i => i.id)).toEqual(['inv-in-window', 'inv-past-now']);
342
+ });
343
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 单测:completeStaleActiveTasks
3
+ *
4
+ * 覆盖:
5
+ * - 空表返回 0
6
+ * - 1 idle 超阈值 + 1 未超:只关超的那条
7
+ * - status='completed' 的 task 不被改动
8
+ * - end_time IS NULL 的 task 不被纳入 GC
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
16
+
17
+ const SESSION = 'sess-gc-test';
18
+
19
+ function openStorage(tmp: string): SQLiteStorage {
20
+ return new SQLiteStorage(join(tmp, 'data.db'));
21
+ }
22
+
23
+ describe('completeStaleActiveTasks', () => {
24
+ let tmp: string;
25
+ let storage: SQLiteStorage;
26
+
27
+ beforeEach(() => {
28
+ tmp = mkdtempSync(join(tmpdir(), 'forge-stale-gc-'));
29
+ storage = openStorage(tmp);
30
+ });
31
+
32
+ afterEach(() => {
33
+ try { storage.close(); } catch { /* ignore */ }
34
+ rmSync(tmp, { recursive: true, force: true });
35
+ });
36
+
37
+ it('空表返回 0', () => {
38
+ expect(storage.completeStaleActiveTasks(10)).toBe(0);
39
+ });
40
+
41
+ it('1 idle 超阈值 + 1 未超:只关超的那条', () => {
42
+ // 写 2 条 active task
43
+ storage.writeTask({ id: 'old-task', session_id: SESSION, title: 'old', start_time: '2026-01-01T00:00:00.000Z' });
44
+ storage.writeTask({ id: 'new-task', session_id: SESSION, title: 'new', start_time: '2026-01-01T01:00:00.000Z' });
45
+
46
+ // 用 DB 直接操作设置 end_time,让 old-task idle 15 分钟前,new-task 仅 3 分钟前
47
+ const db = storage.getDatabase();
48
+ db.prepare(`UPDATE tasks SET end_time = datetime('now', '-15 minutes') WHERE id = ?`).run('old-task');
49
+ db.prepare(`UPDATE tasks SET end_time = datetime('now', '-3 minutes') WHERE id = ?`).run('new-task');
50
+
51
+ const changed = storage.completeStaleActiveTasks(10);
52
+ expect(changed).toBe(1);
53
+
54
+ const oldTask = storage.getTask('old-task');
55
+ expect(oldTask?.status).toBe('completed');
56
+
57
+ const newTask = storage.getTask('new-task');
58
+ expect(newTask?.status).toBe('active');
59
+ });
60
+
61
+ it('status=completed 的 task 不被改动', () => {
62
+ storage.writeTask({ id: 'done-task', session_id: SESSION, title: 'done', start_time: '2026-01-01T00:00:00.000Z' });
63
+ storage.updateTask('done-task', { status: 'completed' });
64
+
65
+ // 设置很久以前的 end_time
66
+ const db = storage.getDatabase();
67
+ db.prepare(`UPDATE tasks SET end_time = datetime('now', '-60 minutes') WHERE id = ?`).run('done-task');
68
+
69
+ const changed = storage.completeStaleActiveTasks(10);
70
+ expect(changed).toBe(0);
71
+
72
+ const task = storage.getTask('done-task');
73
+ expect(task?.status).toBe('completed');
74
+ });
75
+
76
+ it('end_time IS NULL 的 task 不被纳入 GC', () => {
77
+ // 写 active task,end_time 保持 NULL(从未 linkEventToTask)
78
+ storage.writeTask({ id: 'null-end', session_id: SESSION, title: 'null-end', start_time: '2026-01-01T00:00:00.000Z' });
79
+
80
+ const changed = storage.completeStaleActiveTasks(10);
81
+ expect(changed).toBe(0);
82
+
83
+ const task = storage.getTask('null-end');
84
+ expect(task?.status).toBe('active');
85
+ });
86
+ });
@@ -29,7 +29,7 @@ describe('Token Usage Tracking', () => {
29
29
  });
30
30
 
31
31
  it('should record token usage', () => {
32
- storage.recordTokenUsage({
32
+ storage.writeTokenUsage({
33
33
  session_id: 'test-session-1',
34
34
  input_tokens: 100,
35
35
  output_tokens: 200,
@@ -46,13 +46,13 @@ describe('Token Usage Tracking', () => {
46
46
  it('should accumulate token usage across multiple calls', () => {
47
47
  const sessionId = 'test-session-2';
48
48
 
49
- storage.recordTokenUsage({
49
+ storage.writeTokenUsage({
50
50
  session_id: sessionId,
51
51
  input_tokens: 100,
52
52
  output_tokens: 200,
53
53
  });
54
54
 
55
- storage.recordTokenUsage({
55
+ storage.writeTokenUsage({
56
56
  session_id: sessionId,
57
57
  input_tokens: 50,
58
58
  output_tokens: 100,
@@ -67,7 +67,7 @@ describe('Token Usage Tracking', () => {
67
67
  it('should list token usage records', () => {
68
68
  const sessionId = 'test-session-4';
69
69
 
70
- storage.recordTokenUsage({
70
+ storage.writeTokenUsage({
71
71
  session_id: sessionId,
72
72
  input_tokens: 100,
73
73
  output_tokens: 200,
@@ -75,7 +75,7 @@ describe('Token Usage Tracking', () => {
75
75
  tool_name: 'Task',
76
76
  });
77
77
 
78
- storage.recordTokenUsage({
78
+ storage.writeTokenUsage({
79
79
  session_id: sessionId,
80
80
  input_tokens: 50,
81
81
  output_tokens: 100,
@@ -125,7 +125,7 @@ describe('Token Usage Tracking', () => {
125
125
  it('should handle missing methodology_execution_id', () => {
126
126
  const sessionId = 'test-session-7';
127
127
 
128
- storage.recordTokenUsage({
128
+ storage.writeTokenUsage({
129
129
  session_id: sessionId,
130
130
  input_tokens: 100,
131
131
  output_tokens: 200,
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Safety-net: TaskDetail back-navigation filter preservation contract
3
+ *
4
+ * harness/safety-net:tasks-detail-back-loses-filters
5
+ *
6
+ * Root cause: TaskDetail.tsx used <Link to="/tasks"> (hard absolute path),
7
+ * discarding any query string the user had on the Tasks page (project filter,
8
+ * page number, search, etc.).
9
+ *
10
+ * Fix strategy: Tasks.tsx passes { state: { from: location.pathname + location.search } }
11
+ * when navigating to a detail view. TaskDetail reads that state via a pure
12
+ * helper resolveBackTo(state) and uses the result as the Link target.
13
+ *
14
+ * This file tests resolveBackTo in isolation — no DOM, no React, no router.
15
+ * All tests must be GREEN before the utility is created (they lock behavior)
16
+ * and remain green after (they verify the implementation).
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { resolveBackTo } from '../../../web/src/utils/navigation';
21
+
22
+ // ── (inline reference implementation removed — now importing from real module) ─
23
+
24
+ // ── Safety-net: stable baseline behaviour (must pass BEFORE the fix) ─────────
25
+
26
+ describe('resolveBackTo – safety-net (harness/safety-net:tasks-detail-back-loses-filters)', () => {
27
+ describe('fallback path — no navigation state', () => {
28
+ it('returns /tasks when state is null', () => {
29
+ expect(resolveBackTo(null)).toBe('/tasks');
30
+ });
31
+
32
+ it('returns /tasks when state is undefined', () => {
33
+ expect(resolveBackTo(undefined)).toBe('/tasks');
34
+ });
35
+
36
+ it('returns /tasks when state is an empty object (no from key)', () => {
37
+ expect(resolveBackTo({})).toBe('/tasks');
38
+ });
39
+
40
+ it('returns /tasks when state.from is undefined', () => {
41
+ expect(resolveBackTo({ from: undefined })).toBe('/tasks');
42
+ });
43
+
44
+ it('returns /tasks when state.from is an empty string', () => {
45
+ expect(resolveBackTo({ from: '' })).toBe('/tasks');
46
+ });
47
+
48
+ it('returns the supplied fallback when state is null', () => {
49
+ expect(resolveBackTo(null, '/custom')).toBe('/custom');
50
+ });
51
+
52
+ it('returns the supplied fallback when state has no from', () => {
53
+ expect(resolveBackTo({}, '/other')).toBe('/other');
54
+ });
55
+ });
56
+
57
+ describe('happy path — navigation state carries from URL', () => {
58
+ it('returns state.from when it is a non-empty string', () => {
59
+ const state = { from: '/tasks?project=foo&page=2' };
60
+ expect(resolveBackTo(state)).toBe('/tasks?project=foo&page=2');
61
+ });
62
+
63
+ it('preserves query string with multiple project params', () => {
64
+ const qs = '/tasks?project=alpha&project=beta&preset=24h';
65
+ expect(resolveBackTo({ from: qs })).toBe(qs);
66
+ });
67
+
68
+ it('preserves page number in query string', () => {
69
+ expect(resolveBackTo({ from: '/tasks?page=5' })).toBe('/tasks?page=5');
70
+ });
71
+
72
+ it('preserves search param in query string', () => {
73
+ expect(resolveBackTo({ from: '/tasks?search=my+task' })).toBe('/tasks?search=my+task');
74
+ });
75
+
76
+ it('works when from is just /tasks with no query string', () => {
77
+ expect(resolveBackTo({ from: '/tasks' })).toBe('/tasks');
78
+ });
79
+ });
80
+
81
+ describe('type safety — state.from must be a string', () => {
82
+ it('returns fallback when state.from is a number', () => {
83
+ expect(resolveBackTo({ from: 42 })).toBe('/tasks');
84
+ });
85
+
86
+ it('returns fallback when state.from is an array', () => {
87
+ expect(resolveBackTo({ from: ['/tasks'] })).toBe('/tasks');
88
+ });
89
+
90
+ it('returns fallback when state.from is an object', () => {
91
+ expect(resolveBackTo({ from: { path: '/tasks' } })).toBe('/tasks');
92
+ });
93
+
94
+ it('returns fallback when state.from is null', () => {
95
+ expect(resolveBackTo({ from: null })).toBe('/tasks');
96
+ });
97
+
98
+ it('returns fallback when state is a primitive (number)', () => {
99
+ expect(resolveBackTo(42 as unknown)).toBe('/tasks');
100
+ });
101
+
102
+ it('returns fallback when state is a string', () => {
103
+ expect(resolveBackTo('/tasks' as unknown)).toBe('/tasks');
104
+ });
105
+ });
106
+
107
+ describe('navigation state construction — Tasks.tsx side', () => {
108
+ /**
109
+ * These tests lock the format of the state object that Tasks.tsx must produce.
110
+ * They act as a contract between the navigator (Tasks) and the consumer (TaskDetail).
111
+ */
112
+
113
+ it('from value = pathname + search reproduces the original URL exactly', () => {
114
+ const pathname = '/tasks';
115
+ const search = '?project=foo&page=2';
116
+ const from = pathname + search;
117
+ // TaskDetail receives this exact value back
118
+ expect(resolveBackTo({ from })).toBe('/tasks?project=foo&page=2');
119
+ });
120
+
121
+ it('from value when search is empty string yields just the pathname', () => {
122
+ const pathname = '/tasks';
123
+ const search = '';
124
+ const from = pathname + search; // '/tasks'
125
+ expect(resolveBackTo({ from })).toBe('/tasks');
126
+ });
127
+
128
+ it('round-trips a complex filter state without mutation', () => {
129
+ const original = '/tasks?project=my-project&preset=7d&size=100&page=3';
130
+ const state = { from: original };
131
+ expect(resolveBackTo(state)).toBe(original);
132
+ });
133
+ });
134
+ });