@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,202 @@
1
+ /**
2
+ * H2 Phase 1 safety-net: StopHandler behavior summary.
3
+ *
4
+ * Locks the output format and aggregation logic of `generateBehaviorSummary`
5
+ * before its inline SQL (events GROUP BY tool_name / Agent-Task subagent_type /
6
+ * COUNT skill_invocations) is migrated into EventOperations / SkillOperations.
7
+ *
8
+ * Since `generateBehaviorSummary` is `private`, we exercise it indirectly via
9
+ * `handle()` and capture the resume content passed to `resume.save`. That is
10
+ * also the actually-exercised path in production, so this gives a higher-value
11
+ * baseline than reflection-style private access.
12
+ *
13
+ * NOTE on test location: the spec listed `src/tests/...`, but vitest.config.ts
14
+ * only discovers `tests/unit/**` and `tests/integration/**`, so the file is
15
+ * placed under `tests/unit/daemon/` to ensure it actually runs. The semantic
16
+ * intent (a unit test, not an integration test) is preserved.
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
20
+ import { StopHandler } from '../../../src/daemon/handlers/stop.js';
21
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
22
+ import type { HistoryExporter } from '../../../src/daemon/handlers/history-exporter.js';
23
+ import type { StopEvent } from '../../../src/core/types.js';
24
+
25
+ const SESSION = 'sess-behavior-summary';
26
+ const PROJECT = '/tmp/proj-behavior-summary';
27
+
28
+ function nowIso(): string {
29
+ return new Date().toISOString();
30
+ }
31
+
32
+ function makeEvent(): StopEvent {
33
+ return {
34
+ session_id: SESSION,
35
+ project_path: PROJECT,
36
+ timestamp: nowIso(),
37
+ hook_type: 'Stop',
38
+ };
39
+ }
40
+
41
+ describe('StopHandler.generateBehaviorSummary (safety-net)', () => {
42
+ let storage: SQLiteStorage;
43
+ let exporter: HistoryExporter;
44
+ let saved: { content: string | null };
45
+
46
+ // Build a mock ResumeManager that returns a known stub from generate() and
47
+ // captures whatever the handler ends up passing to save().
48
+ function makeResumeMock(): { generate: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn>; load: ReturnType<typeof vi.fn>; clear: ReturnType<typeof vi.fn> } {
49
+ return {
50
+ generate: vi.fn().mockReturnValue('STUB_RESUME\n_生成时间: 2026-01-01T00:00:00.000Z_\n'),
51
+ save: vi.fn((_p: string, c: string) => { saved.content = c; }),
52
+ load: vi.fn(),
53
+ clear: vi.fn(),
54
+ };
55
+ }
56
+
57
+ beforeEach(() => {
58
+ storage = new SQLiteStorage(':memory:');
59
+ saved = { content: null };
60
+ exporter = { export: vi.fn().mockResolvedValue(undefined) } as unknown as HistoryExporter;
61
+ });
62
+
63
+ afterEach(() => {
64
+ storage.close();
65
+ });
66
+
67
+ it('does not append summary when storage has no events for the session', async () => {
68
+ const resume = makeResumeMock();
69
+ const handler = new StopHandler(exporter, resume as any, null, storage, null, null);
70
+
71
+ const res = await handler.handle(makeEvent());
72
+ expect(res.allow).toBe(true);
73
+ // resume.save should still be called (resume content exists)
74
+ expect(resume.save).toHaveBeenCalledTimes(1);
75
+ expect(saved.content).not.toBeNull();
76
+ // Behavior summary line MUST NOT appear when there are no tool calls
77
+ expect(saved.content!).not.toContain('会话行为');
78
+ });
79
+
80
+ it('appends a tool-only behavior summary when only tool events exist', async () => {
81
+ // Seed events: 2 Bash, 1 Edit — no Agent/Task, no skill invocations
82
+ storage.writeEvent({
83
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
84
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls' },
85
+ });
86
+ storage.writeEvent({
87
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
88
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'pwd' },
89
+ });
90
+ storage.writeEvent({
91
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
92
+ hook_type: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: '/x' },
93
+ });
94
+
95
+ const resume = makeResumeMock();
96
+ const handler = new StopHandler(exporter, resume as any, null, storage, null, null);
97
+
98
+ const res = await handler.handle(makeEvent());
99
+ expect(res.allow).toBe(true);
100
+ expect(saved.content).not.toBeNull();
101
+
102
+ // Must contain a behavior summary line with total tool calls
103
+ expect(saved.content!).toContain('**会话行为**');
104
+ expect(saved.content!).toContain('工具调用 3 次');
105
+ expect(saved.content!).toContain('Agent 委托 0 次');
106
+ expect(saved.content!).toContain('Skill 调用 0 次');
107
+ });
108
+
109
+ it('includes Agent delegation counts and agent type names', async () => {
110
+ // 2 normal tools + 2 Task delegations to different subagents
111
+ storage.writeEvent({
112
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
113
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls' },
114
+ });
115
+ storage.writeEvent({
116
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
117
+ hook_type: 'PreToolUse', tool_name: 'Read', tool_input: { file_path: '/a' },
118
+ });
119
+ storage.writeEvent({
120
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
121
+ hook_type: 'PreToolUse', tool_name: 'Task',
122
+ tool_input: { subagent_type: 'explore', description: 'find x' },
123
+ });
124
+ storage.writeEvent({
125
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
126
+ hook_type: 'PreToolUse', tool_name: 'Task',
127
+ tool_input: { subagent_type: 'coder', description: 'fix y' },
128
+ });
129
+
130
+ const resume = makeResumeMock();
131
+ const handler = new StopHandler(exporter, resume as any, null, storage, null, null);
132
+
133
+ await handler.handle(makeEvent());
134
+ expect(saved.content).not.toBeNull();
135
+
136
+ const summary = saved.content!;
137
+ expect(summary).toContain('工具调用 4 次');
138
+ expect(summary).toContain('Agent 委托 2 次');
139
+ // Agent names appear in parenthesised detail
140
+ expect(summary).toMatch(/explore|coder/);
141
+ });
142
+
143
+ it('includes skill invocation count when skill_invocations rows exist', async () => {
144
+ storage.writeEvent({
145
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
146
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls' },
147
+ });
148
+ storage.writeSkillInvocation({
149
+ id: 'si-bs-1', route_request_id: null, session_id: SESSION,
150
+ agent_id: null, skill_id: 'official-tdd',
151
+ invocation_type: 'dynamic', reason: null,
152
+ workflow: null, phase: null, feature_slug: null, artifact_path: null,
153
+ depth: 0, success: 1, error: null, timestamp: Date.now(),
154
+ });
155
+ storage.writeSkillInvocation({
156
+ id: 'si-bs-2', route_request_id: null, session_id: SESSION,
157
+ agent_id: null, skill_id: 'official-debug',
158
+ invocation_type: 'dynamic', reason: null,
159
+ workflow: null, phase: null, feature_slug: null, artifact_path: null,
160
+ depth: 0, success: 1, error: null, timestamp: Date.now(),
161
+ });
162
+
163
+ const resume = makeResumeMock();
164
+ const handler = new StopHandler(exporter, resume as any, null, storage, null, null);
165
+
166
+ await handler.handle(makeEvent());
167
+ expect(saved.content).not.toBeNull();
168
+ expect(saved.content!).toContain('Skill 调用 2 次');
169
+ });
170
+
171
+ it('does not include events from other sessions in the summary', async () => {
172
+ // Same session — 1 tool call
173
+ storage.writeEvent({
174
+ session_id: SESSION, project_path: PROJECT, timestamp: nowIso(),
175
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls' },
176
+ });
177
+ // Different session — 5 tool calls + 3 skill invocations: MUST be excluded
178
+ for (let i = 0; i < 5; i++) {
179
+ storage.writeEvent({
180
+ session_id: 'other-session', project_path: PROJECT, timestamp: nowIso(),
181
+ hook_type: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'noise' },
182
+ });
183
+ }
184
+ for (let i = 0; i < 3; i++) {
185
+ storage.writeSkillInvocation({
186
+ id: `si-noise-${i}`, route_request_id: null, session_id: 'other-session',
187
+ agent_id: null, skill_id: 'official-noise',
188
+ invocation_type: 'dynamic', reason: null,
189
+ workflow: null, phase: null, feature_slug: null, artifact_path: null,
190
+ depth: 0, success: 1, error: null, timestamp: Date.now(),
191
+ });
192
+ }
193
+
194
+ const resume = makeResumeMock();
195
+ const handler = new StopHandler(exporter, resume as any, null, storage, null, null);
196
+
197
+ await handler.handle(makeEvent());
198
+ expect(saved.content).not.toBeNull();
199
+ expect(saved.content!).toContain('工具调用 1 次');
200
+ expect(saved.content!).toContain('Skill 调用 0 次');
201
+ });
202
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 单测:TaskSegmenter.completeCurrentTask recover fallback
3
+ *
4
+ * 覆盖:
5
+ * - Map 为空但 DB 有 active task → completeCurrentTask 仍能将其转为 completed
6
+ * - Map 不为空的正常路径不退化
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { SQLiteStorage } from '../../../src/core/storage/sqlite.js';
14
+ import { TaskSegmenter } from '../../../src/daemon/services/task-segmenter.js';
15
+
16
+ const SESSION = 'sess-segmenter-recover';
17
+
18
+ function openStorage(tmp: string): SQLiteStorage {
19
+ return new SQLiteStorage(join(tmp, 'data.db'));
20
+ }
21
+
22
+ describe('TaskSegmenter.completeCurrentTask recover fallback', () => {
23
+ let tmp: string;
24
+ let storage: SQLiteStorage;
25
+
26
+ beforeEach(() => {
27
+ tmp = mkdtempSync(join(tmpdir(), 'forge-segmenter-recover-'));
28
+ storage = openStorage(tmp);
29
+ });
30
+
31
+ afterEach(() => {
32
+ try { storage.close(); } catch { /* ignore */ }
33
+ rmSync(tmp, { recursive: true, force: true });
34
+ });
35
+
36
+ it('Map 为空但 DB 有 active task → completeCurrentTask 将其转 completed', () => {
37
+ // 直接写 active task 到 DB(不经过 processPrompt,模拟 daemon 重启后内存丢失)
38
+ storage.writeTask({
39
+ id: 'task-in-db',
40
+ session_id: SESSION,
41
+ title: 'Persisted active task',
42
+ start_time: '2026-01-01T10:00:00.000Z',
43
+ });
44
+
45
+ // 确认此刻 DB 中是 active
46
+ expect(storage.getTask('task-in-db')?.status).toBe('active');
47
+
48
+ // 创建新的 TaskSegmenter,Map 为空(模拟重启)
49
+ const segmenter = new TaskSegmenter(storage);
50
+
51
+ // Stop hook 触发 completeCurrentTask,Map 中没有该 session 的 entry
52
+ segmenter.completeCurrentTask(SESSION, '2026-01-01T10:30:00.000Z');
53
+
54
+ // task 应该被 recover 并标记为 completed
55
+ const task = storage.getTask('task-in-db');
56
+ expect(task?.status).toBe('completed');
57
+ expect(task?.end_time).toBe('2026-01-01T10:30:00.000Z');
58
+ });
59
+
60
+ it('DB 中无 active task 时 completeCurrentTask 安全退出(不报错)', () => {
61
+ // 完全空 DB,completeCurrentTask 不应抛出
62
+ const segmenter = new TaskSegmenter(storage);
63
+ expect(() => {
64
+ segmenter.completeCurrentTask(SESSION, '2026-01-01T10:30:00.000Z');
65
+ }).not.toThrow();
66
+ });
67
+
68
+ it('Map 有 entry 时(正常路径)completeCurrentTask 正常关闭 task', () => {
69
+ const segmenter = new TaskSegmenter(storage);
70
+
71
+ // 通过 processPrompt 走正常路径,将 task 写入 Map
72
+ const taskId = segmenter.processPrompt(SESSION, 'implement feature', '2026-01-01T10:00:00.000Z');
73
+
74
+ // 确认 DB 中是 active
75
+ expect(storage.getTask(taskId)?.status).toBe('active');
76
+
77
+ // completeCurrentTask 正常关闭
78
+ segmenter.completeCurrentTask(SESSION, '2026-01-01T10:30:00.000Z');
79
+
80
+ const task = storage.getTask(taskId);
81
+ expect(task?.status).toBe('completed');
82
+ expect(task?.end_time).toBe('2026-01-01T10:30:00.000Z');
83
+ });
84
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ getCommand,
4
+ getFilePath,
5
+ getUserPrompt,
6
+ getSubagentType,
7
+ } from '../../src/core/event-fields.js';
8
+ import type { ForgeEvent } from '../../src/core/types.js';
9
+
10
+ function makeEvent(overrides: Partial<ForgeEvent> = {}): ForgeEvent {
11
+ return {
12
+ session_id: 's',
13
+ project_path: '/p',
14
+ timestamp: '2026-05-18T00:00:00Z',
15
+ hook_type: 'PreToolUse',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('event-fields getters', () => {
21
+ describe('getCommand', () => {
22
+ it('returns undefined when tool_input is missing', () => {
23
+ expect(getCommand(makeEvent())).toBeUndefined();
24
+ });
25
+
26
+ it('returns undefined when command is not a string', () => {
27
+ expect(getCommand(makeEvent({ tool_input: { command: 42 as unknown as string } }))).toBeUndefined();
28
+ });
29
+
30
+ it('returns the command string when present', () => {
31
+ expect(getCommand(makeEvent({ tool_input: { command: 'ls -la' } }))).toBe('ls -la');
32
+ });
33
+ });
34
+
35
+ describe('getFilePath', () => {
36
+ it('returns undefined when tool_input is missing', () => {
37
+ expect(getFilePath(makeEvent())).toBeUndefined();
38
+ });
39
+
40
+ it('returns undefined when file_path is wrong type', () => {
41
+ expect(getFilePath(makeEvent({ tool_input: { file_path: 123 as unknown as string } }))).toBeUndefined();
42
+ });
43
+
44
+ it('returns file_path when present', () => {
45
+ expect(getFilePath(makeEvent({ tool_input: { file_path: '/foo/bar.ts' } }))).toBe('/foo/bar.ts');
46
+ });
47
+
48
+ it('falls back to notebook_path when file_path is missing', () => {
49
+ expect(getFilePath(makeEvent({ tool_input: { notebook_path: '/x.ipynb' } }))).toBe('/x.ipynb');
50
+ });
51
+
52
+ it('returns undefined for empty string', () => {
53
+ expect(getFilePath(makeEvent({ tool_input: { file_path: '' } }))).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe('getUserPrompt', () => {
58
+ it('returns undefined when neither field set', () => {
59
+ expect(getUserPrompt(makeEvent())).toBeUndefined();
60
+ });
61
+
62
+ it('returns top-level user_prompt when set', () => {
63
+ expect(getUserPrompt(makeEvent({ user_prompt: 'hi' }))).toBe('hi');
64
+ });
65
+
66
+ it('falls back to tool_input.user_prompt envelope', () => {
67
+ expect(getUserPrompt(makeEvent({ tool_input: { user_prompt: 'envelope' } }))).toBe('envelope');
68
+ });
69
+
70
+ it('returns undefined when fallback is wrong type', () => {
71
+ expect(getUserPrompt(makeEvent({ tool_input: { user_prompt: 7 as unknown as string } }))).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('getSubagentType', () => {
76
+ it('returns undefined when missing', () => {
77
+ expect(getSubagentType(makeEvent())).toBeUndefined();
78
+ });
79
+
80
+ it('returns undefined when wrong type', () => {
81
+ expect(getSubagentType(makeEvent({ tool_input: { subagent_type: {} as unknown as string } }))).toBeUndefined();
82
+ });
83
+
84
+ it('returns the subagent_type string', () => {
85
+ expect(getSubagentType(makeEvent({ tool_input: { subagent_type: 'planner' } }))).toBe('planner');
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EventParser } from '../../src/daemon/event-parser.js';
3
+
4
+ const parser = new EventParser();
5
+
6
+ function makeRaw(overrides: Record<string, unknown> = {}) {
7
+ return JSON.stringify({
8
+ hook_type: 'PreToolUse',
9
+ timestamp: '2026-05-18T00:00:00Z',
10
+ session_id: 's1',
11
+ project_path: '/p',
12
+ tool_name: 'Bash',
13
+ ...overrides,
14
+ });
15
+ }
16
+
17
+ describe('EventParser', () => {
18
+ it('generates a UUID event_id when none is provided', () => {
19
+ const evt = parser.parse(makeRaw());
20
+ expect(evt.event_id).toMatch(/^[0-9a-f-]{36}$/i);
21
+ });
22
+
23
+ it('passes through a valid externally-supplied event_id', () => {
24
+ const id = '11111111-2222-4333-8444-555555555555';
25
+ const evt = parser.parse(makeRaw({ event_id: id }));
26
+ expect(evt.event_id).toBe(id);
27
+ });
28
+
29
+ it('rejects a malformed event_id via schema', () => {
30
+ expect(() => parser.parse(makeRaw({ event_id: 'not-a-uuid' }))).toThrow();
31
+ });
32
+
33
+ it('lands tool_input fields into ToolInputFields shape', () => {
34
+ const evt = parser.parse(
35
+ makeRaw({ tool_input: { command: 'ls', file_path: '/a/b.ts', extra: 1 } })
36
+ );
37
+ expect(evt.tool_input?.command).toBe('ls');
38
+ expect(evt.tool_input?.file_path).toBe('/a/b.ts');
39
+ expect(evt.tool_input?.extra).toBe(1);
40
+ });
41
+
42
+ it('handles UserPromptSubmit hook with user_prompt at top level', () => {
43
+ const evt = parser.parse(
44
+ JSON.stringify({
45
+ hook_type: 'UserPromptSubmit',
46
+ timestamp: '2026-05-18T00:00:00Z',
47
+ session_id: 's1',
48
+ project_path: '/p',
49
+ user_prompt: 'hi there',
50
+ })
51
+ );
52
+ expect(evt.user_prompt).toBe('hi there');
53
+ expect(evt.event_id).toMatch(/^[0-9a-f-]{36}$/i);
54
+ });
55
+ });
@@ -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
+ });