calvyn-code 0.14.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 (1718) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/README.zh-CN.md +180 -0
  4. package/acp_adapter/__init__.py +1 -0
  5. package/acp_adapter/__main__.py +5 -0
  6. package/acp_adapter/auth.py +68 -0
  7. package/acp_adapter/bootstrap/__init__.py +0 -0
  8. package/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +288 -0
  9. package/acp_adapter/bootstrap/bootstrap_browser_tools.sh +399 -0
  10. package/acp_adapter/entry.py +292 -0
  11. package/acp_adapter/events.py +265 -0
  12. package/acp_adapter/permissions.py +148 -0
  13. package/acp_adapter/server.py +1713 -0
  14. package/acp_adapter/session.py +629 -0
  15. package/acp_adapter/tools.py +1180 -0
  16. package/agent/__init__.py +6 -0
  17. package/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  18. package/agent/__pycache__/account_usage.cpython-312.pyc +0 -0
  19. package/agent/__pycache__/anthropic_adapter.cpython-312.pyc +0 -0
  20. package/agent/__pycache__/async_utils.cpython-312.pyc +0 -0
  21. package/agent/__pycache__/auxiliary_client.cpython-312.pyc +0 -0
  22. package/agent/__pycache__/codex_responses_adapter.cpython-312.pyc +0 -0
  23. package/agent/__pycache__/context_compressor.cpython-312.pyc +0 -0
  24. package/agent/__pycache__/context_engine.cpython-312.pyc +0 -0
  25. package/agent/__pycache__/context_references.cpython-312.pyc +0 -0
  26. package/agent/__pycache__/credential_pool.cpython-312.pyc +0 -0
  27. package/agent/__pycache__/curator.cpython-312.pyc +0 -0
  28. package/agent/__pycache__/display.cpython-312.pyc +0 -0
  29. package/agent/__pycache__/error_classifier.cpython-312.pyc +0 -0
  30. package/agent/__pycache__/file_safety.cpython-312.pyc +0 -0
  31. package/agent/__pycache__/google_code_assist.cpython-312.pyc +0 -0
  32. package/agent/__pycache__/google_oauth.cpython-312.pyc +0 -0
  33. package/agent/__pycache__/i18n.cpython-312.pyc +0 -0
  34. package/agent/__pycache__/image_gen_provider.cpython-312.pyc +0 -0
  35. package/agent/__pycache__/image_gen_registry.cpython-312.pyc +0 -0
  36. package/agent/__pycache__/insights.cpython-312.pyc +0 -0
  37. package/agent/__pycache__/lmstudio_reasoning.cpython-312.pyc +0 -0
  38. package/agent/__pycache__/manual_compression_feedback.cpython-312.pyc +0 -0
  39. package/agent/__pycache__/markdown_tables.cpython-312.pyc +0 -0
  40. package/agent/__pycache__/memory_manager.cpython-312.pyc +0 -0
  41. package/agent/__pycache__/memory_provider.cpython-312.pyc +0 -0
  42. package/agent/__pycache__/model_metadata.cpython-312.pyc +0 -0
  43. package/agent/__pycache__/models_dev.cpython-312.pyc +0 -0
  44. package/agent/__pycache__/moonshot_schema.cpython-312.pyc +0 -0
  45. package/agent/__pycache__/onboarding.cpython-312.pyc +0 -0
  46. package/agent/__pycache__/portal_tags.cpython-312.pyc +0 -0
  47. package/agent/__pycache__/prompt_builder.cpython-312.pyc +0 -0
  48. package/agent/__pycache__/prompt_caching.cpython-312.pyc +0 -0
  49. package/agent/__pycache__/redact.cpython-312.pyc +0 -0
  50. package/agent/__pycache__/retry_utils.cpython-312.pyc +0 -0
  51. package/agent/__pycache__/shell_hooks.cpython-312.pyc +0 -0
  52. package/agent/__pycache__/skill_commands.cpython-312.pyc +0 -0
  53. package/agent/__pycache__/skill_preprocessing.cpython-312.pyc +0 -0
  54. package/agent/__pycache__/skill_utils.cpython-312.pyc +0 -0
  55. package/agent/__pycache__/subdirectory_hints.cpython-312.pyc +0 -0
  56. package/agent/__pycache__/think_scrubber.cpython-312.pyc +0 -0
  57. package/agent/__pycache__/title_generator.cpython-312.pyc +0 -0
  58. package/agent/__pycache__/tool_guardrails.cpython-312.pyc +0 -0
  59. package/agent/__pycache__/tool_result_classification.cpython-312.pyc +0 -0
  60. package/agent/__pycache__/trajectory.cpython-312.pyc +0 -0
  61. package/agent/__pycache__/usage_pricing.cpython-312.pyc +0 -0
  62. package/agent/__pycache__/video_gen_provider.cpython-312.pyc +0 -0
  63. package/agent/__pycache__/video_gen_registry.cpython-312.pyc +0 -0
  64. package/agent/__pycache__/web_search_provider.cpython-312.pyc +0 -0
  65. package/agent/__pycache__/web_search_registry.cpython-312.pyc +0 -0
  66. package/agent/account_usage.py +326 -0
  67. package/agent/anthropic_adapter.py +2087 -0
  68. package/agent/async_utils.py +68 -0
  69. package/agent/auxiliary_client.py +4893 -0
  70. package/agent/bedrock_adapter.py +1276 -0
  71. package/agent/codex_responses_adapter.py +1084 -0
  72. package/agent/context_compressor.py +1583 -0
  73. package/agent/context_engine.py +211 -0
  74. package/agent/context_references.py +519 -0
  75. package/agent/copilot_acp_client.py +684 -0
  76. package/agent/credential_pool.py +1780 -0
  77. package/agent/credential_sources.py +449 -0
  78. package/agent/curator.py +1782 -0
  79. package/agent/curator_backup.py +694 -0
  80. package/agent/display.py +987 -0
  81. package/agent/error_classifier.py +1058 -0
  82. package/agent/file_safety.py +112 -0
  83. package/agent/gemini_cloudcode_adapter.py +909 -0
  84. package/agent/gemini_native_adapter.py +971 -0
  85. package/agent/gemini_schema.py +99 -0
  86. package/agent/google_code_assist.py +452 -0
  87. package/agent/google_oauth.py +1062 -0
  88. package/agent/i18n.py +258 -0
  89. package/agent/image_gen_provider.py +243 -0
  90. package/agent/image_gen_registry.py +145 -0
  91. package/agent/image_routing.py +301 -0
  92. package/agent/insights.py +931 -0
  93. package/agent/lmstudio_reasoning.py +48 -0
  94. package/agent/lsp/__init__.py +106 -0
  95. package/agent/lsp/__pycache__/__init__.cpython-312.pyc +0 -0
  96. package/agent/lsp/__pycache__/cli.cpython-312.pyc +0 -0
  97. package/agent/lsp/__pycache__/client.cpython-312.pyc +0 -0
  98. package/agent/lsp/__pycache__/eventlog.cpython-312.pyc +0 -0
  99. package/agent/lsp/__pycache__/manager.cpython-312.pyc +0 -0
  100. package/agent/lsp/__pycache__/protocol.cpython-312.pyc +0 -0
  101. package/agent/lsp/__pycache__/servers.cpython-312.pyc +0 -0
  102. package/agent/lsp/__pycache__/workspace.cpython-312.pyc +0 -0
  103. package/agent/lsp/cli.py +308 -0
  104. package/agent/lsp/client.py +930 -0
  105. package/agent/lsp/eventlog.py +213 -0
  106. package/agent/lsp/install.py +376 -0
  107. package/agent/lsp/manager.py +644 -0
  108. package/agent/lsp/protocol.py +196 -0
  109. package/agent/lsp/range_shift.py +149 -0
  110. package/agent/lsp/reporter.py +78 -0
  111. package/agent/lsp/servers.py +1040 -0
  112. package/agent/lsp/workspace.py +223 -0
  113. package/agent/manual_compression_feedback.py +49 -0
  114. package/agent/markdown_tables.py +309 -0
  115. package/agent/memory_manager.py +556 -0
  116. package/agent/memory_provider.py +279 -0
  117. package/agent/model_metadata.py +1827 -0
  118. package/agent/models_dev.py +724 -0
  119. package/agent/moonshot_schema.py +231 -0
  120. package/agent/nous_rate_guard.py +326 -0
  121. package/agent/onboarding.py +193 -0
  122. package/agent/plugin_llm.py +1046 -0
  123. package/agent/portal_tags.py +64 -0
  124. package/agent/prompt_builder.py +1457 -0
  125. package/agent/prompt_caching.py +79 -0
  126. package/agent/rate_limit_tracker.py +246 -0
  127. package/agent/redact.py +403 -0
  128. package/agent/retry_utils.py +57 -0
  129. package/agent/shell_hooks.py +837 -0
  130. package/agent/skill_commands.py +502 -0
  131. package/agent/skill_preprocessing.py +131 -0
  132. package/agent/skill_utils.py +512 -0
  133. package/agent/subdirectory_hints.py +224 -0
  134. package/agent/think_scrubber.py +386 -0
  135. package/agent/title_generator.py +171 -0
  136. package/agent/tool_guardrails.py +458 -0
  137. package/agent/tool_result_classification.py +26 -0
  138. package/agent/trajectory.py +56 -0
  139. package/agent/transports/__init__.py +68 -0
  140. package/agent/transports/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/agent/transports/__pycache__/anthropic.cpython-312.pyc +0 -0
  142. package/agent/transports/__pycache__/base.cpython-312.pyc +0 -0
  143. package/agent/transports/__pycache__/bedrock.cpython-312.pyc +0 -0
  144. package/agent/transports/__pycache__/chat_completions.cpython-312.pyc +0 -0
  145. package/agent/transports/__pycache__/codex.cpython-312.pyc +0 -0
  146. package/agent/transports/__pycache__/types.cpython-312.pyc +0 -0
  147. package/agent/transports/anthropic.py +179 -0
  148. package/agent/transports/base.py +89 -0
  149. package/agent/transports/bedrock.py +154 -0
  150. package/agent/transports/chat_completions.py +614 -0
  151. package/agent/transports/codex.py +283 -0
  152. package/agent/transports/codex_app_server.py +368 -0
  153. package/agent/transports/codex_app_server_session.py +810 -0
  154. package/agent/transports/codex_event_projector.py +312 -0
  155. package/agent/transports/hermes_tools_mcp_server.py +233 -0
  156. package/agent/transports/types.py +162 -0
  157. package/agent/usage_pricing.py +877 -0
  158. package/agent/video_gen_provider.py +300 -0
  159. package/agent/video_gen_registry.py +117 -0
  160. package/agent/web_search_provider.py +221 -0
  161. package/agent/web_search_registry.py +262 -0
  162. package/assets/banner.png +0 -0
  163. package/batch_runner.py +1303 -0
  164. package/bin/calvyn.js +67 -0
  165. package/calvyn_bootstrap.py +130 -0
  166. package/calvyn_constants.py +346 -0
  167. package/calvyn_logging.py +390 -0
  168. package/calvyn_state.py +2967 -0
  169. package/calvyn_time.py +105 -0
  170. package/cli.py +14160 -0
  171. package/cron/__init__.py +42 -0
  172. package/cron/__pycache__/__init__.cpython-312.pyc +0 -0
  173. package/cron/__pycache__/jobs.cpython-312.pyc +0 -0
  174. package/cron/__pycache__/scheduler.cpython-312.pyc +0 -0
  175. package/cron/jobs.py +1160 -0
  176. package/cron/scheduler.py +1832 -0
  177. package/gateway/__init__.py +35 -0
  178. package/gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  179. package/gateway/__pycache__/channel_directory.cpython-312.pyc +0 -0
  180. package/gateway/__pycache__/config.cpython-312.pyc +0 -0
  181. package/gateway/__pycache__/delivery.cpython-312.pyc +0 -0
  182. package/gateway/__pycache__/display_config.cpython-312.pyc +0 -0
  183. package/gateway/__pycache__/hooks.cpython-312.pyc +0 -0
  184. package/gateway/__pycache__/pairing.cpython-312.pyc +0 -0
  185. package/gateway/__pycache__/platform_registry.cpython-312.pyc +0 -0
  186. package/gateway/__pycache__/restart.cpython-312.pyc +0 -0
  187. package/gateway/__pycache__/run.cpython-312.pyc +0 -0
  188. package/gateway/__pycache__/runtime_footer.cpython-312.pyc +0 -0
  189. package/gateway/__pycache__/session.cpython-312.pyc +0 -0
  190. package/gateway/__pycache__/session_context.cpython-312.pyc +0 -0
  191. package/gateway/__pycache__/shutdown_forensics.cpython-312.pyc +0 -0
  192. package/gateway/__pycache__/slash_access.cpython-312.pyc +0 -0
  193. package/gateway/__pycache__/status.cpython-312.pyc +0 -0
  194. package/gateway/__pycache__/stream_consumer.cpython-312.pyc +0 -0
  195. package/gateway/__pycache__/whatsapp_identity.cpython-312.pyc +0 -0
  196. package/gateway/assets/telegram-botfather-threads-settings.jpg +0 -0
  197. package/gateway/builtin_hooks/__init__.py +1 -0
  198. package/gateway/channel_directory.py +357 -0
  199. package/gateway/config.py +1873 -0
  200. package/gateway/delivery.py +258 -0
  201. package/gateway/display_config.py +206 -0
  202. package/gateway/hooks.py +210 -0
  203. package/gateway/mirror.py +179 -0
  204. package/gateway/pairing.py +322 -0
  205. package/gateway/platform_registry.py +260 -0
  206. package/gateway/platforms/ADDING_A_PLATFORM.md +374 -0
  207. package/gateway/platforms/__init__.py +45 -0
  208. package/gateway/platforms/__pycache__/__init__.cpython-312.pyc +0 -0
  209. package/gateway/platforms/__pycache__/base.cpython-312.pyc +0 -0
  210. package/gateway/platforms/__pycache__/helpers.cpython-312.pyc +0 -0
  211. package/gateway/platforms/__pycache__/telegram.cpython-312.pyc +0 -0
  212. package/gateway/platforms/__pycache__/telegram_network.cpython-312.pyc +0 -0
  213. package/gateway/platforms/__pycache__/yuanbao.cpython-312.pyc +0 -0
  214. package/gateway/platforms/__pycache__/yuanbao_media.cpython-312.pyc +0 -0
  215. package/gateway/platforms/__pycache__/yuanbao_proto.cpython-312.pyc +0 -0
  216. package/gateway/platforms/_http_client_limits.py +84 -0
  217. package/gateway/platforms/api_server.py +3488 -0
  218. package/gateway/platforms/base.py +3747 -0
  219. package/gateway/platforms/bluebubbles.py +937 -0
  220. package/gateway/platforms/dingtalk.py +1473 -0
  221. package/gateway/platforms/discord.py +5584 -0
  222. package/gateway/platforms/email.py +773 -0
  223. package/gateway/platforms/feishu.py +5059 -0
  224. package/gateway/platforms/feishu_comment.py +1382 -0
  225. package/gateway/platforms/feishu_comment_rules.py +430 -0
  226. package/gateway/platforms/helpers.py +279 -0
  227. package/gateway/platforms/homeassistant.py +449 -0
  228. package/gateway/platforms/matrix.py +2777 -0
  229. package/gateway/platforms/mattermost.py +852 -0
  230. package/gateway/platforms/msgraph_webhook.py +397 -0
  231. package/gateway/platforms/qqbot/__init__.py +91 -0
  232. package/gateway/platforms/qqbot/adapter.py +3072 -0
  233. package/gateway/platforms/qqbot/chunked_upload.py +602 -0
  234. package/gateway/platforms/qqbot/constants.py +74 -0
  235. package/gateway/platforms/qqbot/crypto.py +45 -0
  236. package/gateway/platforms/qqbot/keyboards.py +473 -0
  237. package/gateway/platforms/qqbot/onboard.py +220 -0
  238. package/gateway/platforms/qqbot/utils.py +71 -0
  239. package/gateway/platforms/signal.py +1518 -0
  240. package/gateway/platforms/signal_rate_limit.py +369 -0
  241. package/gateway/platforms/slack.py +3028 -0
  242. package/gateway/platforms/sms.py +377 -0
  243. package/gateway/platforms/telegram.py +4836 -0
  244. package/gateway/platforms/telegram_network.py +249 -0
  245. package/gateway/platforms/webhook.py +806 -0
  246. package/gateway/platforms/wecom.py +1610 -0
  247. package/gateway/platforms/wecom_callback.py +403 -0
  248. package/gateway/platforms/wecom_crypto.py +142 -0
  249. package/gateway/platforms/weixin.py +2170 -0
  250. package/gateway/platforms/whatsapp.py +1283 -0
  251. package/gateway/platforms/yuanbao.py +4873 -0
  252. package/gateway/platforms/yuanbao_media.py +645 -0
  253. package/gateway/platforms/yuanbao_proto.py +1209 -0
  254. package/gateway/platforms/yuanbao_sticker.py +558 -0
  255. package/gateway/restart.py +20 -0
  256. package/gateway/run.py +17074 -0
  257. package/gateway/runtime_footer.py +150 -0
  258. package/gateway/session.py +1399 -0
  259. package/gateway/session_context.py +156 -0
  260. package/gateway/shutdown_forensics.py +462 -0
  261. package/gateway/slash_access.py +229 -0
  262. package/gateway/status.py +972 -0
  263. package/gateway/sticker_cache.py +111 -0
  264. package/gateway/stream_consumer.py +1286 -0
  265. package/gateway/whatsapp_identity.py +156 -0
  266. package/hermes_cli/__init__.py +47 -0
  267. package/hermes_cli/__pycache__/__init__.cpython-312.pyc +0 -0
  268. package/hermes_cli/__pycache__/_parser.cpython-312.pyc +0 -0
  269. package/hermes_cli/__pycache__/auth.cpython-312.pyc +0 -0
  270. package/hermes_cli/__pycache__/banner.cpython-312.pyc +0 -0
  271. package/hermes_cli/__pycache__/browser_connect.cpython-312.pyc +0 -0
  272. package/hermes_cli/__pycache__/callbacks.cpython-312.pyc +0 -0
  273. package/hermes_cli/__pycache__/checkpoints.cpython-312.pyc +0 -0
  274. package/hermes_cli/__pycache__/cli_output.cpython-312.pyc +0 -0
  275. package/hermes_cli/__pycache__/codex_models.cpython-312.pyc +0 -0
  276. package/hermes_cli/__pycache__/codex_runtime_switch.cpython-312.pyc +0 -0
  277. package/hermes_cli/__pycache__/colors.cpython-312.pyc +0 -0
  278. package/hermes_cli/__pycache__/commands.cpython-312.pyc +0 -0
  279. package/hermes_cli/__pycache__/config.cpython-312.pyc +0 -0
  280. package/hermes_cli/__pycache__/copilot_auth.cpython-312.pyc +0 -0
  281. package/hermes_cli/__pycache__/curator.cpython-312.pyc +0 -0
  282. package/hermes_cli/__pycache__/curses_ui.cpython-312.pyc +0 -0
  283. package/hermes_cli/__pycache__/debug.cpython-312.pyc +0 -0
  284. package/hermes_cli/__pycache__/default_soul.cpython-312.pyc +0 -0
  285. package/hermes_cli/__pycache__/env_loader.cpython-312.pyc +0 -0
  286. package/hermes_cli/__pycache__/fallback_cmd.cpython-312.pyc +0 -0
  287. package/hermes_cli/__pycache__/gateway.cpython-312.pyc +0 -0
  288. package/hermes_cli/__pycache__/gateway_windows.cpython-312.pyc +0 -0
  289. package/hermes_cli/__pycache__/goals.cpython-312.pyc +0 -0
  290. package/hermes_cli/__pycache__/inventory.cpython-312.pyc +0 -0
  291. package/hermes_cli/__pycache__/kanban.cpython-312.pyc +0 -0
  292. package/hermes_cli/__pycache__/kanban_db.cpython-312.pyc +0 -0
  293. package/hermes_cli/__pycache__/main.cpython-312.pyc +0 -0
  294. package/hermes_cli/__pycache__/model_catalog.cpython-312.pyc +0 -0
  295. package/hermes_cli/__pycache__/model_normalize.cpython-312.pyc +0 -0
  296. package/hermes_cli/__pycache__/model_switch.cpython-312.pyc +0 -0
  297. package/hermes_cli/__pycache__/models.cpython-312.pyc +0 -0
  298. package/hermes_cli/__pycache__/nous_subscription.cpython-312.pyc +0 -0
  299. package/hermes_cli/__pycache__/pairing.cpython-312.pyc +0 -0
  300. package/hermes_cli/__pycache__/platforms.cpython-312.pyc +0 -0
  301. package/hermes_cli/__pycache__/plugins.cpython-312.pyc +0 -0
  302. package/hermes_cli/__pycache__/profiles.cpython-312.pyc +0 -0
  303. package/hermes_cli/__pycache__/providers.cpython-312.pyc +0 -0
  304. package/hermes_cli/__pycache__/pt_input_extras.cpython-312.pyc +0 -0
  305. package/hermes_cli/__pycache__/runtime_provider.cpython-312.pyc +0 -0
  306. package/hermes_cli/__pycache__/security_advisories.cpython-312.pyc +0 -0
  307. package/hermes_cli/__pycache__/setup.cpython-312.pyc +0 -0
  308. package/hermes_cli/__pycache__/skills_hub.cpython-312.pyc +0 -0
  309. package/hermes_cli/__pycache__/skin_engine.cpython-312.pyc +0 -0
  310. package/hermes_cli/__pycache__/stdio.cpython-312.pyc +0 -0
  311. package/hermes_cli/__pycache__/timeouts.cpython-312.pyc +0 -0
  312. package/hermes_cli/__pycache__/tips.cpython-312.pyc +0 -0
  313. package/hermes_cli/__pycache__/tools_config.cpython-312.pyc +0 -0
  314. package/hermes_cli/__pycache__/voice.cpython-312.pyc +0 -0
  315. package/hermes_cli/_parser.py +365 -0
  316. package/hermes_cli/_subprocess_compat.py +175 -0
  317. package/hermes_cli/auth.py +6299 -0
  318. package/hermes_cli/auth_commands.py +749 -0
  319. package/hermes_cli/azure_detect.py +300 -0
  320. package/hermes_cli/backup.py +938 -0
  321. package/hermes_cli/banner.py +703 -0
  322. package/hermes_cli/browser_connect.py +139 -0
  323. package/hermes_cli/callbacks.py +243 -0
  324. package/hermes_cli/checkpoints.py +244 -0
  325. package/hermes_cli/claw.py +810 -0
  326. package/hermes_cli/cli_output.py +78 -0
  327. package/hermes_cli/clipboard.py +495 -0
  328. package/hermes_cli/codex_models.py +198 -0
  329. package/hermes_cli/codex_runtime_plugin_migration.py +757 -0
  330. package/hermes_cli/codex_runtime_switch.py +266 -0
  331. package/hermes_cli/colors.py +38 -0
  332. package/hermes_cli/commands.py +1728 -0
  333. package/hermes_cli/completion.py +315 -0
  334. package/hermes_cli/config.py +5382 -0
  335. package/hermes_cli/copilot_auth.py +392 -0
  336. package/hermes_cli/cron.py +313 -0
  337. package/hermes_cli/curator.py +598 -0
  338. package/hermes_cli/curses_ui.py +472 -0
  339. package/hermes_cli/debug.py +747 -0
  340. package/hermes_cli/default_soul.py +11 -0
  341. package/hermes_cli/dep_ensure.py +107 -0
  342. package/hermes_cli/dingtalk_auth.py +293 -0
  343. package/hermes_cli/doctor.py +1863 -0
  344. package/hermes_cli/dump.py +326 -0
  345. package/hermes_cli/env_loader.py +175 -0
  346. package/hermes_cli/fallback_cmd.py +361 -0
  347. package/hermes_cli/gateway.py +5422 -0
  348. package/hermes_cli/gateway_windows.py +692 -0
  349. package/hermes_cli/goals.py +757 -0
  350. package/hermes_cli/hooks.py +385 -0
  351. package/hermes_cli/inventory.py +240 -0
  352. package/hermes_cli/kanban.py +2252 -0
  353. package/hermes_cli/kanban_db.py +4840 -0
  354. package/hermes_cli/kanban_diagnostics.py +776 -0
  355. package/hermes_cli/kanban_specify.py +266 -0
  356. package/hermes_cli/logs.py +391 -0
  357. package/hermes_cli/main.py +12396 -0
  358. package/hermes_cli/mcp_config.py +781 -0
  359. package/hermes_cli/memory_setup.py +465 -0
  360. package/hermes_cli/model_catalog.py +330 -0
  361. package/hermes_cli/model_normalize.py +473 -0
  362. package/hermes_cli/model_switch.py +1777 -0
  363. package/hermes_cli/models.py +3789 -0
  364. package/hermes_cli/nous_subscription.py +799 -0
  365. package/hermes_cli/oneshot.py +351 -0
  366. package/hermes_cli/pairing.py +115 -0
  367. package/hermes_cli/platforms.py +83 -0
  368. package/hermes_cli/plugins.py +1562 -0
  369. package/hermes_cli/plugins_cmd.py +1587 -0
  370. package/hermes_cli/profile_distribution.py +703 -0
  371. package/hermes_cli/profiles.py +1319 -0
  372. package/hermes_cli/providers.py +720 -0
  373. package/hermes_cli/proxy/__init__.py +20 -0
  374. package/hermes_cli/proxy/adapters/__init__.py +35 -0
  375. package/hermes_cli/proxy/adapters/base.py +94 -0
  376. package/hermes_cli/proxy/adapters/nous_portal.py +137 -0
  377. package/hermes_cli/proxy/cli.py +141 -0
  378. package/hermes_cli/proxy/server.py +265 -0
  379. package/hermes_cli/pt_input_extras.py +83 -0
  380. package/hermes_cli/pty_bridge.py +237 -0
  381. package/hermes_cli/relaunch.py +205 -0
  382. package/hermes_cli/runtime_provider.py +1428 -0
  383. package/hermes_cli/security_advisories.py +452 -0
  384. package/hermes_cli/setup.py +3559 -0
  385. package/hermes_cli/skills_config.py +177 -0
  386. package/hermes_cli/skills_hub.py +1595 -0
  387. package/hermes_cli/skin_engine.py +929 -0
  388. package/hermes_cli/slack_cli.py +160 -0
  389. package/hermes_cli/status.py +550 -0
  390. package/hermes_cli/stdio.py +252 -0
  391. package/hermes_cli/timeouts.py +82 -0
  392. package/hermes_cli/tips.py +487 -0
  393. package/hermes_cli/tools_config.py +3151 -0
  394. package/hermes_cli/uninstall.py +681 -0
  395. package/hermes_cli/vercel_auth.py +70 -0
  396. package/hermes_cli/voice.py +846 -0
  397. package/hermes_cli/web_server.py +4438 -0
  398. package/hermes_cli/webhook.py +275 -0
  399. package/locales/af.yaml +350 -0
  400. package/locales/de.yaml +350 -0
  401. package/locales/en.yaml +365 -0
  402. package/locales/es.yaml +350 -0
  403. package/locales/fr.yaml +350 -0
  404. package/locales/ga.yaml +354 -0
  405. package/locales/hu.yaml +350 -0
  406. package/locales/it.yaml +350 -0
  407. package/locales/ja.yaml +350 -0
  408. package/locales/ko.yaml +350 -0
  409. package/locales/pt.yaml +350 -0
  410. package/locales/ru.yaml +350 -0
  411. package/locales/tr.yaml +350 -0
  412. package/locales/uk.yaml +350 -0
  413. package/locales/zh-hant.yaml +350 -0
  414. package/locales/zh.yaml +350 -0
  415. package/mcp_serve.py +898 -0
  416. package/model_tools.py +899 -0
  417. package/optional-skills/DESCRIPTION.md +24 -0
  418. package/optional-skills/autonomous-ai-agents/DESCRIPTION.md +2 -0
  419. package/optional-skills/autonomous-ai-agents/blackbox/SKILL.md +144 -0
  420. package/optional-skills/autonomous-ai-agents/honcho/SKILL.md +431 -0
  421. package/optional-skills/blockchain/evm/SKILL.md +211 -0
  422. package/optional-skills/blockchain/evm/scripts/evm_client.py +1508 -0
  423. package/optional-skills/blockchain/hyperliquid/SKILL.md +211 -0
  424. package/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py +1660 -0
  425. package/optional-skills/blockchain/solana/SKILL.md +208 -0
  426. package/optional-skills/blockchain/solana/scripts/solana_client.py +698 -0
  427. package/optional-skills/communication/DESCRIPTION.md +1 -0
  428. package/optional-skills/communication/one-three-one-rule/SKILL.md +104 -0
  429. package/optional-skills/creative/blender-mcp/SKILL.md +117 -0
  430. package/optional-skills/creative/concept-diagrams/SKILL.md +362 -0
  431. package/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +244 -0
  432. package/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +276 -0
  433. package/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +240 -0
  434. package/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +161 -0
  435. package/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +209 -0
  436. package/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +236 -0
  437. package/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +182 -0
  438. package/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +172 -0
  439. package/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +165 -0
  440. package/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +114 -0
  441. package/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +325 -0
  442. package/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +173 -0
  443. package/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +154 -0
  444. package/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +247 -0
  445. package/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +338 -0
  446. package/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +43 -0
  447. package/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +144 -0
  448. package/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +42 -0
  449. package/optional-skills/creative/concept-diagrams/templates/template.html +174 -0
  450. package/optional-skills/creative/hyperframes/SKILL.md +191 -0
  451. package/optional-skills/creative/hyperframes/references/cli.md +185 -0
  452. package/optional-skills/creative/hyperframes/references/composition.md +129 -0
  453. package/optional-skills/creative/hyperframes/references/features.md +289 -0
  454. package/optional-skills/creative/hyperframes/references/gsap.md +136 -0
  455. package/optional-skills/creative/hyperframes/references/troubleshooting.md +137 -0
  456. package/optional-skills/creative/hyperframes/references/website-to-video.md +145 -0
  457. package/optional-skills/creative/hyperframes/scripts/setup.sh +135 -0
  458. package/optional-skills/creative/kanban-video-orchestrator/SKILL.md +207 -0
  459. package/optional-skills/creative/kanban-video-orchestrator/assets/brief.md.tmpl +79 -0
  460. package/optional-skills/creative/kanban-video-orchestrator/assets/setup.sh.tmpl +185 -0
  461. package/optional-skills/creative/kanban-video-orchestrator/assets/soul.md.tmpl +38 -0
  462. package/optional-skills/creative/kanban-video-orchestrator/references/examples.md +227 -0
  463. package/optional-skills/creative/kanban-video-orchestrator/references/intake.md +166 -0
  464. package/optional-skills/creative/kanban-video-orchestrator/references/kanban-setup.md +276 -0
  465. package/optional-skills/creative/kanban-video-orchestrator/references/monitoring.md +180 -0
  466. package/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +298 -0
  467. package/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +317 -0
  468. package/optional-skills/creative/kanban-video-orchestrator/scripts/bootstrap_pipeline.py +501 -0
  469. package/optional-skills/creative/kanban-video-orchestrator/scripts/monitor.py +195 -0
  470. package/optional-skills/creative/meme-generation/EXAMPLES.md +46 -0
  471. package/optional-skills/creative/meme-generation/SKILL.md +130 -0
  472. package/optional-skills/creative/meme-generation/scripts/generate_meme.py +471 -0
  473. package/optional-skills/creative/meme-generation/scripts/templates.json +97 -0
  474. package/optional-skills/devops/cli/SKILL.md +156 -0
  475. package/optional-skills/devops/cli/references/app-discovery.md +112 -0
  476. package/optional-skills/devops/cli/references/authentication.md +59 -0
  477. package/optional-skills/devops/cli/references/cli-reference.md +104 -0
  478. package/optional-skills/devops/cli/references/running-apps.md +171 -0
  479. package/optional-skills/devops/docker-management/SKILL.md +281 -0
  480. package/optional-skills/devops/pinggy-tunnel/SKILL.md +309 -0
  481. package/optional-skills/devops/watchers/SKILL.md +112 -0
  482. package/optional-skills/devops/watchers/scripts/_watermark.py +148 -0
  483. package/optional-skills/devops/watchers/scripts/watch_github.py +168 -0
  484. package/optional-skills/devops/watchers/scripts/watch_http_json.py +131 -0
  485. package/optional-skills/devops/watchers/scripts/watch_rss.py +121 -0
  486. package/optional-skills/dogfood/DESCRIPTION.md +3 -0
  487. package/optional-skills/dogfood/adversarial-ux-test/SKILL.md +191 -0
  488. package/optional-skills/email/agentmail/SKILL.md +126 -0
  489. package/optional-skills/finance/3-statement-model/SKILL.md +433 -0
  490. package/optional-skills/finance/3-statement-model/references/formatting.md +118 -0
  491. package/optional-skills/finance/3-statement-model/references/formulas.md +292 -0
  492. package/optional-skills/finance/3-statement-model/references/sec-filings.md +125 -0
  493. package/optional-skills/finance/comps-analysis/SKILL.md +662 -0
  494. package/optional-skills/finance/dcf-model/SKILL.md +1270 -0
  495. package/optional-skills/finance/dcf-model/TROUBLESHOOTING.md +40 -0
  496. package/optional-skills/finance/dcf-model/requirements.txt +7 -0
  497. package/optional-skills/finance/dcf-model/scripts/validate_dcf.py +292 -0
  498. package/optional-skills/finance/excel-author/SKILL.md +244 -0
  499. package/optional-skills/finance/excel-author/scripts/recalc.py +88 -0
  500. package/optional-skills/finance/lbo-model/SKILL.md +291 -0
  501. package/optional-skills/finance/merger-model/SKILL.md +144 -0
  502. package/optional-skills/finance/pptx-author/SKILL.md +173 -0
  503. package/optional-skills/finance/stocks/SKILL.md +95 -0
  504. package/optional-skills/finance/stocks/scripts/stocks_client.py +755 -0
  505. package/optional-skills/health/DESCRIPTION.md +1 -0
  506. package/optional-skills/health/fitness-nutrition/SKILL.md +256 -0
  507. package/optional-skills/health/fitness-nutrition/references/FORMULAS.md +100 -0
  508. package/optional-skills/health/fitness-nutrition/scripts/body_calc.py +210 -0
  509. package/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +86 -0
  510. package/optional-skills/health/neuroskill-bci/SKILL.md +459 -0
  511. package/optional-skills/health/neuroskill-bci/references/api.md +286 -0
  512. package/optional-skills/health/neuroskill-bci/references/metrics.md +220 -0
  513. package/optional-skills/health/neuroskill-bci/references/protocols.md +452 -0
  514. package/optional-skills/mcp/DESCRIPTION.md +3 -0
  515. package/optional-skills/mcp/fastmcp/SKILL.md +300 -0
  516. package/optional-skills/mcp/fastmcp/references/fastmcp-cli.md +110 -0
  517. package/optional-skills/mcp/fastmcp/scripts/scaffold_fastmcp.py +56 -0
  518. package/optional-skills/mcp/fastmcp/templates/api_wrapper.py +54 -0
  519. package/optional-skills/mcp/fastmcp/templates/database_server.py +77 -0
  520. package/optional-skills/mcp/fastmcp/templates/file_processor.py +55 -0
  521. package/optional-skills/mcp/mcporter/SKILL.md +123 -0
  522. package/optional-skills/migration/DESCRIPTION.md +2 -0
  523. package/optional-skills/migration/openclaw-migration/SKILL.md +298 -0
  524. package/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +3136 -0
  525. package/optional-skills/mlops/accelerate/SKILL.md +336 -0
  526. package/optional-skills/mlops/accelerate/references/custom-plugins.md +453 -0
  527. package/optional-skills/mlops/accelerate/references/megatron-integration.md +489 -0
  528. package/optional-skills/mlops/accelerate/references/performance.md +525 -0
  529. package/optional-skills/mlops/chroma/SKILL.md +410 -0
  530. package/optional-skills/mlops/chroma/references/integration.md +38 -0
  531. package/optional-skills/mlops/clip/SKILL.md +257 -0
  532. package/optional-skills/mlops/clip/references/applications.md +207 -0
  533. package/optional-skills/mlops/faiss/SKILL.md +225 -0
  534. package/optional-skills/mlops/faiss/references/index_types.md +280 -0
  535. package/optional-skills/mlops/flash-attention/SKILL.md +367 -0
  536. package/optional-skills/mlops/flash-attention/references/benchmarks.md +215 -0
  537. package/optional-skills/mlops/flash-attention/references/transformers-integration.md +293 -0
  538. package/optional-skills/mlops/guidance/SKILL.md +576 -0
  539. package/optional-skills/mlops/guidance/references/backends.md +554 -0
  540. package/optional-skills/mlops/guidance/references/constraints.md +674 -0
  541. package/optional-skills/mlops/guidance/references/examples.md +767 -0
  542. package/optional-skills/mlops/huggingface-tokenizers/SKILL.md +520 -0
  543. package/optional-skills/mlops/huggingface-tokenizers/references/algorithms.md +653 -0
  544. package/optional-skills/mlops/huggingface-tokenizers/references/integration.md +637 -0
  545. package/optional-skills/mlops/huggingface-tokenizers/references/pipeline.md +723 -0
  546. package/optional-skills/mlops/huggingface-tokenizers/references/training.md +565 -0
  547. package/optional-skills/mlops/inference/outlines/SKILL.md +656 -0
  548. package/optional-skills/mlops/inference/outlines/references/backends.md +615 -0
  549. package/optional-skills/mlops/inference/outlines/references/examples.md +773 -0
  550. package/optional-skills/mlops/inference/outlines/references/json_generation.md +652 -0
  551. package/optional-skills/mlops/instructor/SKILL.md +744 -0
  552. package/optional-skills/mlops/instructor/references/examples.md +107 -0
  553. package/optional-skills/mlops/instructor/references/providers.md +70 -0
  554. package/optional-skills/mlops/instructor/references/validation.md +606 -0
  555. package/optional-skills/mlops/lambda-labs/SKILL.md +549 -0
  556. package/optional-skills/mlops/lambda-labs/references/advanced-usage.md +611 -0
  557. package/optional-skills/mlops/lambda-labs/references/troubleshooting.md +530 -0
  558. package/optional-skills/mlops/llava/SKILL.md +308 -0
  559. package/optional-skills/mlops/llava/references/training.md +197 -0
  560. package/optional-skills/mlops/modal/SKILL.md +345 -0
  561. package/optional-skills/mlops/modal/references/advanced-usage.md +503 -0
  562. package/optional-skills/mlops/modal/references/troubleshooting.md +494 -0
  563. package/optional-skills/mlops/nemo-curator/SKILL.md +387 -0
  564. package/optional-skills/mlops/nemo-curator/references/deduplication.md +87 -0
  565. package/optional-skills/mlops/nemo-curator/references/filtering.md +102 -0
  566. package/optional-skills/mlops/peft/SKILL.md +435 -0
  567. package/optional-skills/mlops/peft/references/advanced-usage.md +514 -0
  568. package/optional-skills/mlops/peft/references/troubleshooting.md +480 -0
  569. package/optional-skills/mlops/pinecone/SKILL.md +362 -0
  570. package/optional-skills/mlops/pinecone/references/deployment.md +181 -0
  571. package/optional-skills/mlops/pytorch-fsdp/SKILL.md +130 -0
  572. package/optional-skills/mlops/pytorch-fsdp/references/index.md +7 -0
  573. package/optional-skills/mlops/pytorch-fsdp/references/other.md +4261 -0
  574. package/optional-skills/mlops/pytorch-lightning/SKILL.md +350 -0
  575. package/optional-skills/mlops/pytorch-lightning/references/callbacks.md +436 -0
  576. package/optional-skills/mlops/pytorch-lightning/references/distributed.md +490 -0
  577. package/optional-skills/mlops/pytorch-lightning/references/hyperparameter-tuning.md +556 -0
  578. package/optional-skills/mlops/qdrant/SKILL.md +497 -0
  579. package/optional-skills/mlops/qdrant/references/advanced-usage.md +648 -0
  580. package/optional-skills/mlops/qdrant/references/troubleshooting.md +631 -0
  581. package/optional-skills/mlops/saelens/SKILL.md +390 -0
  582. package/optional-skills/mlops/saelens/references/README.md +69 -0
  583. package/optional-skills/mlops/saelens/references/api.md +333 -0
  584. package/optional-skills/mlops/saelens/references/tutorials.md +318 -0
  585. package/optional-skills/mlops/simpo/SKILL.md +223 -0
  586. package/optional-skills/mlops/simpo/references/datasets.md +478 -0
  587. package/optional-skills/mlops/simpo/references/hyperparameters.md +452 -0
  588. package/optional-skills/mlops/simpo/references/loss-functions.md +350 -0
  589. package/optional-skills/mlops/slime/SKILL.md +468 -0
  590. package/optional-skills/mlops/slime/references/api-reference.md +392 -0
  591. package/optional-skills/mlops/slime/references/troubleshooting.md +386 -0
  592. package/optional-skills/mlops/stable-diffusion/SKILL.md +523 -0
  593. package/optional-skills/mlops/stable-diffusion/references/advanced-usage.md +716 -0
  594. package/optional-skills/mlops/stable-diffusion/references/troubleshooting.md +555 -0
  595. package/optional-skills/mlops/tensorrt-llm/SKILL.md +191 -0
  596. package/optional-skills/mlops/tensorrt-llm/references/multi-gpu.md +298 -0
  597. package/optional-skills/mlops/tensorrt-llm/references/optimization.md +242 -0
  598. package/optional-skills/mlops/tensorrt-llm/references/serving.md +470 -0
  599. package/optional-skills/mlops/torchtitan/SKILL.md +362 -0
  600. package/optional-skills/mlops/torchtitan/references/checkpoint.md +181 -0
  601. package/optional-skills/mlops/torchtitan/references/custom-models.md +258 -0
  602. package/optional-skills/mlops/torchtitan/references/float8.md +133 -0
  603. package/optional-skills/mlops/torchtitan/references/fsdp.md +126 -0
  604. package/optional-skills/mlops/training/axolotl/SKILL.md +166 -0
  605. package/optional-skills/mlops/training/axolotl/references/api.md +5548 -0
  606. package/optional-skills/mlops/training/axolotl/references/dataset-formats.md +1029 -0
  607. package/optional-skills/mlops/training/axolotl/references/index.md +15 -0
  608. package/optional-skills/mlops/training/axolotl/references/other.md +3563 -0
  609. package/optional-skills/mlops/training/trl-fine-tuning/SKILL.md +463 -0
  610. package/optional-skills/mlops/training/trl-fine-tuning/references/dpo-variants.md +227 -0
  611. package/optional-skills/mlops/training/trl-fine-tuning/references/grpo-training.md +504 -0
  612. package/optional-skills/mlops/training/trl-fine-tuning/references/online-rl.md +82 -0
  613. package/optional-skills/mlops/training/trl-fine-tuning/references/reward-modeling.md +122 -0
  614. package/optional-skills/mlops/training/trl-fine-tuning/references/sft-training.md +168 -0
  615. package/optional-skills/mlops/training/trl-fine-tuning/templates/basic_grpo_training.py +228 -0
  616. package/optional-skills/mlops/training/unsloth/SKILL.md +84 -0
  617. package/optional-skills/mlops/training/unsloth/references/index.md +7 -0
  618. package/optional-skills/mlops/training/unsloth/references/llms-full.md +16799 -0
  619. package/optional-skills/mlops/training/unsloth/references/llms-txt.md +12044 -0
  620. package/optional-skills/mlops/training/unsloth/references/llms.md +82 -0
  621. package/optional-skills/mlops/whisper/SKILL.md +321 -0
  622. package/optional-skills/mlops/whisper/references/languages.md +189 -0
  623. package/optional-skills/productivity/canvas/SKILL.md +98 -0
  624. package/optional-skills/productivity/canvas/scripts/canvas_api.py +157 -0
  625. package/optional-skills/productivity/here-now/SKILL.md +217 -0
  626. package/optional-skills/productivity/here-now/scripts/drive.sh +406 -0
  627. package/optional-skills/productivity/here-now/scripts/publish.sh +445 -0
  628. package/optional-skills/productivity/memento-flashcards/SKILL.md +324 -0
  629. package/optional-skills/productivity/memento-flashcards/scripts/memento_cards.py +353 -0
  630. package/optional-skills/productivity/memento-flashcards/scripts/youtube_quiz.py +88 -0
  631. package/optional-skills/productivity/shop-app/SKILL.md +340 -0
  632. package/optional-skills/productivity/shopify/SKILL.md +373 -0
  633. package/optional-skills/productivity/siyuan/SKILL.md +298 -0
  634. package/optional-skills/productivity/telephony/SKILL.md +418 -0
  635. package/optional-skills/productivity/telephony/scripts/telephony.py +1343 -0
  636. package/optional-skills/research/bioinformatics/SKILL.md +235 -0
  637. package/optional-skills/research/darwinian-evolver/SKILL.md +199 -0
  638. package/optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py +218 -0
  639. package/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +69 -0
  640. package/optional-skills/research/darwinian-evolver/templates/custom_problem_template.py +240 -0
  641. package/optional-skills/research/domain-intel/SKILL.md +97 -0
  642. package/optional-skills/research/domain-intel/scripts/domain_intel.py +397 -0
  643. package/optional-skills/research/drug-discovery/SKILL.md +227 -0
  644. package/optional-skills/research/drug-discovery/references/ADMET_REFERENCE.md +66 -0
  645. package/optional-skills/research/drug-discovery/scripts/chembl_target.py +53 -0
  646. package/optional-skills/research/drug-discovery/scripts/ro5_screen.py +44 -0
  647. package/optional-skills/research/duckduckgo-search/SKILL.md +238 -0
  648. package/optional-skills/research/duckduckgo-search/scripts/duckduckgo.sh +28 -0
  649. package/optional-skills/research/gitnexus-explorer/SKILL.md +214 -0
  650. package/optional-skills/research/gitnexus-explorer/scripts/proxy.mjs +92 -0
  651. package/optional-skills/research/osint-investigation/SKILL.md +277 -0
  652. package/optional-skills/research/osint-investigation/references/sources/courtlistener.md +98 -0
  653. package/optional-skills/research/osint-investigation/references/sources/gdelt.md +104 -0
  654. package/optional-skills/research/osint-investigation/references/sources/icij-offshore.md +104 -0
  655. package/optional-skills/research/osint-investigation/references/sources/nyc-acris.md +90 -0
  656. package/optional-skills/research/osint-investigation/references/sources/ofac-sdn.md +92 -0
  657. package/optional-skills/research/osint-investigation/references/sources/opencorporates.md +103 -0
  658. package/optional-skills/research/osint-investigation/references/sources/sec-edgar.md +83 -0
  659. package/optional-skills/research/osint-investigation/references/sources/senate-ld.md +89 -0
  660. package/optional-skills/research/osint-investigation/references/sources/usaspending.md +97 -0
  661. package/optional-skills/research/osint-investigation/references/sources/wayback.md +93 -0
  662. package/optional-skills/research/osint-investigation/references/sources/wikipedia.md +107 -0
  663. package/optional-skills/research/osint-investigation/scripts/_http.py +82 -0
  664. package/optional-skills/research/osint-investigation/scripts/_normalize.py +67 -0
  665. package/optional-skills/research/osint-investigation/scripts/build_findings.py +221 -0
  666. package/optional-skills/research/osint-investigation/scripts/entity_resolution.py +228 -0
  667. package/optional-skills/research/osint-investigation/scripts/fetch_courtlistener.py +149 -0
  668. package/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +162 -0
  669. package/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +234 -0
  670. package/optional-skills/research/osint-investigation/scripts/fetch_nyc_acris.py +203 -0
  671. package/optional-skills/research/osint-investigation/scripts/fetch_ofac_sdn.py +175 -0
  672. package/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +192 -0
  673. package/optional-skills/research/osint-investigation/scripts/fetch_sec_edgar.py +184 -0
  674. package/optional-skills/research/osint-investigation/scripts/fetch_senate_ld.py +146 -0
  675. package/optional-skills/research/osint-investigation/scripts/fetch_usaspending.py +170 -0
  676. package/optional-skills/research/osint-investigation/scripts/fetch_wayback.py +142 -0
  677. package/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +267 -0
  678. package/optional-skills/research/osint-investigation/scripts/timing_analysis.py +253 -0
  679. package/optional-skills/research/osint-investigation/templates/source-template.md +59 -0
  680. package/optional-skills/research/parallel-cli/SKILL.md +391 -0
  681. package/optional-skills/research/qmd/SKILL.md +441 -0
  682. package/optional-skills/research/scrapling/SKILL.md +336 -0
  683. package/optional-skills/research/searxng-search/SKILL.md +212 -0
  684. package/optional-skills/research/searxng-search/scripts/searxng.sh +22 -0
  685. package/optional-skills/security/1password/SKILL.md +163 -0
  686. package/optional-skills/security/1password/references/cli-examples.md +31 -0
  687. package/optional-skills/security/1password/references/get-started.md +21 -0
  688. package/optional-skills/security/DESCRIPTION.md +3 -0
  689. package/optional-skills/security/oss-forensics/SKILL.md +423 -0
  690. package/optional-skills/security/oss-forensics/references/evidence-types.md +89 -0
  691. package/optional-skills/security/oss-forensics/references/github-archive-guide.md +184 -0
  692. package/optional-skills/security/oss-forensics/references/investigation-templates.md +131 -0
  693. package/optional-skills/security/oss-forensics/references/recovery-techniques.md +164 -0
  694. package/optional-skills/security/oss-forensics/scripts/evidence-store.py +313 -0
  695. package/optional-skills/security/oss-forensics/templates/forensic-report.md +151 -0
  696. package/optional-skills/security/oss-forensics/templates/malicious-package-report.md +43 -0
  697. package/optional-skills/security/sherlock/SKILL.md +193 -0
  698. package/optional-skills/software-development/rest-graphql-debug/SKILL.md +514 -0
  699. package/optional-skills/web-development/DESCRIPTION.md +5 -0
  700. package/optional-skills/web-development/page-agent/SKILL.md +190 -0
  701. package/package.json +78 -0
  702. package/plugins/__init__.py +1 -0
  703. package/plugins/__pycache__/__init__.cpython-312.pyc +0 -0
  704. package/plugins/context_engine/__init__.py +219 -0
  705. package/plugins/disk-cleanup/README.md +51 -0
  706. package/plugins/disk-cleanup/__init__.py +316 -0
  707. package/plugins/disk-cleanup/disk_cleanup.py +497 -0
  708. package/plugins/disk-cleanup/plugin.yaml +7 -0
  709. package/plugins/example-dashboard/dashboard/manifest.json +14 -0
  710. package/plugins/example-dashboard/dashboard/plugin_api.py +17 -0
  711. package/plugins/google_meet/README.md +131 -0
  712. package/plugins/google_meet/SKILL.md +148 -0
  713. package/plugins/google_meet/__init__.py +103 -0
  714. package/plugins/google_meet/audio_bridge.py +244 -0
  715. package/plugins/google_meet/cli.py +479 -0
  716. package/plugins/google_meet/meet_bot.py +852 -0
  717. package/plugins/google_meet/node/__init__.py +54 -0
  718. package/plugins/google_meet/node/cli.py +125 -0
  719. package/plugins/google_meet/node/client.py +107 -0
  720. package/plugins/google_meet/node/protocol.py +124 -0
  721. package/plugins/google_meet/node/registry.py +113 -0
  722. package/plugins/google_meet/node/server.py +201 -0
  723. package/plugins/google_meet/plugin.yaml +16 -0
  724. package/plugins/google_meet/process_manager.py +324 -0
  725. package/plugins/google_meet/realtime/__init__.py +10 -0
  726. package/plugins/google_meet/realtime/openai_client.py +332 -0
  727. package/plugins/google_meet/tools.py +348 -0
  728. package/plugins/hermes-achievements/LICENSE +21 -0
  729. package/plugins/hermes-achievements/README.md +150 -0
  730. package/plugins/hermes-achievements/dashboard/dist/index.js +732 -0
  731. package/plugins/hermes-achievements/dashboard/dist/style.css +146 -0
  732. package/plugins/hermes-achievements/dashboard/manifest.json +11 -0
  733. package/plugins/hermes-achievements/dashboard/plugin_api.py +1062 -0
  734. package/plugins/hermes-achievements/docs/achievements-performance-implementation-plan.md +157 -0
  735. package/plugins/hermes-achievements/docs/achievements-performance-implementation-spec.md +219 -0
  736. package/plugins/hermes-achievements/docs/achievements-performance-spec.md +174 -0
  737. package/plugins/hermes-achievements/docs/assets/achievements-dashboard-hd.png +0 -0
  738. package/plugins/hermes-achievements/docs/assets/achievements-tier-showcase-hd.png +0 -0
  739. package/plugins/hermes-achievements/tests/test_achievement_engine.py +156 -0
  740. package/plugins/image_gen/openai/__init__.py +303 -0
  741. package/plugins/image_gen/openai/__pycache__/__init__.cpython-312.pyc +0 -0
  742. package/plugins/image_gen/openai/plugin.yaml +7 -0
  743. package/plugins/image_gen/openai-codex/__init__.py +378 -0
  744. package/plugins/image_gen/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  745. package/plugins/image_gen/openai-codex/plugin.yaml +5 -0
  746. package/plugins/image_gen/xai/__init__.py +316 -0
  747. package/plugins/image_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  748. package/plugins/image_gen/xai/plugin.yaml +7 -0
  749. package/plugins/kanban/dashboard/dist/index.js +3143 -0
  750. package/plugins/kanban/dashboard/dist/style.css +1500 -0
  751. package/plugins/kanban/dashboard/manifest.json +14 -0
  752. package/plugins/kanban/dashboard/plugin_api.py +1612 -0
  753. package/plugins/kanban/systemd/hermes-kanban-dispatcher.service +32 -0
  754. package/plugins/memory/__init__.py +408 -0
  755. package/plugins/memory/byterover/README.md +41 -0
  756. package/plugins/memory/byterover/__init__.py +384 -0
  757. package/plugins/memory/byterover/plugin.yaml +9 -0
  758. package/plugins/memory/hindsight/README.md +138 -0
  759. package/plugins/memory/hindsight/__init__.py +1758 -0
  760. package/plugins/memory/hindsight/plugin.yaml +8 -0
  761. package/plugins/memory/holographic/README.md +36 -0
  762. package/plugins/memory/holographic/__init__.py +409 -0
  763. package/plugins/memory/holographic/holographic.py +203 -0
  764. package/plugins/memory/holographic/plugin.yaml +5 -0
  765. package/plugins/memory/holographic/retrieval.py +593 -0
  766. package/plugins/memory/holographic/store.py +579 -0
  767. package/plugins/memory/honcho/README.md +328 -0
  768. package/plugins/memory/honcho/__init__.py +1329 -0
  769. package/plugins/memory/honcho/cli.py +1452 -0
  770. package/plugins/memory/honcho/client.py +784 -0
  771. package/plugins/memory/honcho/plugin.yaml +7 -0
  772. package/plugins/memory/honcho/session.py +1255 -0
  773. package/plugins/memory/mem0/README.md +38 -0
  774. package/plugins/memory/mem0/__init__.py +374 -0
  775. package/plugins/memory/mem0/plugin.yaml +5 -0
  776. package/plugins/memory/openviking/README.md +40 -0
  777. package/plugins/memory/openviking/__init__.py +945 -0
  778. package/plugins/memory/openviking/plugin.yaml +9 -0
  779. package/plugins/memory/retaindb/README.md +40 -0
  780. package/plugins/memory/retaindb/__init__.py +767 -0
  781. package/plugins/memory/retaindb/plugin.yaml +7 -0
  782. package/plugins/memory/supermemory/README.md +99 -0
  783. package/plugins/memory/supermemory/__init__.py +792 -0
  784. package/plugins/memory/supermemory/plugin.yaml +5 -0
  785. package/plugins/model-providers/README.md +70 -0
  786. package/plugins/model-providers/ai-gateway/__init__.py +43 -0
  787. package/plugins/model-providers/ai-gateway/__pycache__/__init__.cpython-312.pyc +0 -0
  788. package/plugins/model-providers/ai-gateway/plugin.yaml +5 -0
  789. package/plugins/model-providers/alibaba/__init__.py +13 -0
  790. package/plugins/model-providers/alibaba/__pycache__/__init__.cpython-312.pyc +0 -0
  791. package/plugins/model-providers/alibaba/plugin.yaml +5 -0
  792. package/plugins/model-providers/alibaba-coding-plan/__init__.py +21 -0
  793. package/plugins/model-providers/alibaba-coding-plan/__pycache__/__init__.cpython-312.pyc +0 -0
  794. package/plugins/model-providers/alibaba-coding-plan/plugin.yaml +5 -0
  795. package/plugins/model-providers/anthropic/__init__.py +52 -0
  796. package/plugins/model-providers/anthropic/__pycache__/__init__.cpython-312.pyc +0 -0
  797. package/plugins/model-providers/anthropic/plugin.yaml +5 -0
  798. package/plugins/model-providers/arcee/__init__.py +13 -0
  799. package/plugins/model-providers/arcee/__pycache__/__init__.cpython-312.pyc +0 -0
  800. package/plugins/model-providers/arcee/plugin.yaml +5 -0
  801. package/plugins/model-providers/azure-foundry/__init__.py +21 -0
  802. package/plugins/model-providers/azure-foundry/__pycache__/__init__.cpython-312.pyc +0 -0
  803. package/plugins/model-providers/azure-foundry/plugin.yaml +5 -0
  804. package/plugins/model-providers/bedrock/__init__.py +29 -0
  805. package/plugins/model-providers/bedrock/__pycache__/__init__.cpython-312.pyc +0 -0
  806. package/plugins/model-providers/bedrock/plugin.yaml +5 -0
  807. package/plugins/model-providers/copilot/__init__.py +58 -0
  808. package/plugins/model-providers/copilot/__pycache__/__init__.cpython-312.pyc +0 -0
  809. package/plugins/model-providers/copilot/plugin.yaml +5 -0
  810. package/plugins/model-providers/copilot-acp/__init__.py +34 -0
  811. package/plugins/model-providers/copilot-acp/__pycache__/__init__.cpython-312.pyc +0 -0
  812. package/plugins/model-providers/copilot-acp/plugin.yaml +5 -0
  813. package/plugins/model-providers/custom/__init__.py +68 -0
  814. package/plugins/model-providers/custom/__pycache__/__init__.cpython-312.pyc +0 -0
  815. package/plugins/model-providers/custom/plugin.yaml +5 -0
  816. package/plugins/model-providers/deepseek/__init__.py +99 -0
  817. package/plugins/model-providers/deepseek/__pycache__/__init__.cpython-312.pyc +0 -0
  818. package/plugins/model-providers/deepseek/plugin.yaml +5 -0
  819. package/plugins/model-providers/gemini/__init__.py +72 -0
  820. package/plugins/model-providers/gemini/__pycache__/__init__.cpython-312.pyc +0 -0
  821. package/plugins/model-providers/gemini/plugin.yaml +5 -0
  822. package/plugins/model-providers/gmi/__init__.py +31 -0
  823. package/plugins/model-providers/gmi/__pycache__/__init__.cpython-312.pyc +0 -0
  824. package/plugins/model-providers/gmi/plugin.yaml +5 -0
  825. package/plugins/model-providers/huggingface/__init__.py +20 -0
  826. package/plugins/model-providers/huggingface/__pycache__/__init__.cpython-312.pyc +0 -0
  827. package/plugins/model-providers/huggingface/plugin.yaml +5 -0
  828. package/plugins/model-providers/kilocode/__init__.py +14 -0
  829. package/plugins/model-providers/kilocode/__pycache__/__init__.cpython-312.pyc +0 -0
  830. package/plugins/model-providers/kilocode/plugin.yaml +5 -0
  831. package/plugins/model-providers/kimi-coding/__init__.py +71 -0
  832. package/plugins/model-providers/kimi-coding/__pycache__/__init__.cpython-312.pyc +0 -0
  833. package/plugins/model-providers/kimi-coding/plugin.yaml +5 -0
  834. package/plugins/model-providers/minimax/__init__.py +45 -0
  835. package/plugins/model-providers/minimax/__pycache__/__init__.cpython-312.pyc +0 -0
  836. package/plugins/model-providers/minimax/plugin.yaml +5 -0
  837. package/plugins/model-providers/nous/__init__.py +54 -0
  838. package/plugins/model-providers/nous/__pycache__/__init__.cpython-312.pyc +0 -0
  839. package/plugins/model-providers/nous/plugin.yaml +5 -0
  840. package/plugins/model-providers/novita/__init__.py +27 -0
  841. package/plugins/model-providers/novita/__pycache__/__init__.cpython-312.pyc +0 -0
  842. package/plugins/model-providers/novita/plugin.yaml +5 -0
  843. package/plugins/model-providers/nvidia/__init__.py +21 -0
  844. package/plugins/model-providers/nvidia/__pycache__/__init__.cpython-312.pyc +0 -0
  845. package/plugins/model-providers/nvidia/plugin.yaml +5 -0
  846. package/plugins/model-providers/ollama-cloud/__init__.py +14 -0
  847. package/plugins/model-providers/ollama-cloud/__pycache__/__init__.cpython-312.pyc +0 -0
  848. package/plugins/model-providers/ollama-cloud/plugin.yaml +5 -0
  849. package/plugins/model-providers/openai-codex/__init__.py +15 -0
  850. package/plugins/model-providers/openai-codex/__pycache__/__init__.cpython-312.pyc +0 -0
  851. package/plugins/model-providers/openai-codex/plugin.yaml +5 -0
  852. package/plugins/model-providers/opencode-zen/__init__.py +30 -0
  853. package/plugins/model-providers/opencode-zen/__pycache__/__init__.cpython-312.pyc +0 -0
  854. package/plugins/model-providers/opencode-zen/plugin.yaml +5 -0
  855. package/plugins/model-providers/openrouter/__init__.py +115 -0
  856. package/plugins/model-providers/openrouter/__pycache__/__init__.cpython-312.pyc +0 -0
  857. package/plugins/model-providers/openrouter/plugin.yaml +5 -0
  858. package/plugins/model-providers/qwen-oauth/__init__.py +82 -0
  859. package/plugins/model-providers/qwen-oauth/__pycache__/__init__.cpython-312.pyc +0 -0
  860. package/plugins/model-providers/qwen-oauth/plugin.yaml +5 -0
  861. package/plugins/model-providers/stepfun/__init__.py +14 -0
  862. package/plugins/model-providers/stepfun/__pycache__/__init__.cpython-312.pyc +0 -0
  863. package/plugins/model-providers/stepfun/plugin.yaml +5 -0
  864. package/plugins/model-providers/xai/__init__.py +15 -0
  865. package/plugins/model-providers/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  866. package/plugins/model-providers/xai/plugin.yaml +5 -0
  867. package/plugins/model-providers/xiaomi/__init__.py +14 -0
  868. package/plugins/model-providers/xiaomi/__pycache__/__init__.cpython-312.pyc +0 -0
  869. package/plugins/model-providers/xiaomi/plugin.yaml +5 -0
  870. package/plugins/model-providers/zai/__init__.py +21 -0
  871. package/plugins/model-providers/zai/__pycache__/__init__.cpython-312.pyc +0 -0
  872. package/plugins/model-providers/zai/plugin.yaml +5 -0
  873. package/plugins/observability/langfuse/README.md +53 -0
  874. package/plugins/observability/langfuse/__init__.py +1004 -0
  875. package/plugins/observability/langfuse/plugin.yaml +14 -0
  876. package/plugins/platforms/google_chat/__init__.py +3 -0
  877. package/plugins/platforms/google_chat/__pycache__/__init__.cpython-312.pyc +0 -0
  878. package/plugins/platforms/google_chat/__pycache__/adapter.cpython-312.pyc +0 -0
  879. package/plugins/platforms/google_chat/adapter.py +3343 -0
  880. package/plugins/platforms/google_chat/oauth.py +639 -0
  881. package/plugins/platforms/google_chat/plugin.yaml +39 -0
  882. package/plugins/platforms/irc/__init__.py +3 -0
  883. package/plugins/platforms/irc/__pycache__/__init__.cpython-312.pyc +0 -0
  884. package/plugins/platforms/irc/__pycache__/adapter.cpython-312.pyc +0 -0
  885. package/plugins/platforms/irc/adapter.py +969 -0
  886. package/plugins/platforms/irc/plugin.yaml +54 -0
  887. package/plugins/platforms/line/__init__.py +3 -0
  888. package/plugins/platforms/line/__pycache__/__init__.cpython-312.pyc +0 -0
  889. package/plugins/platforms/line/__pycache__/adapter.cpython-312.pyc +0 -0
  890. package/plugins/platforms/line/adapter.py +1639 -0
  891. package/plugins/platforms/line/plugin.yaml +65 -0
  892. package/plugins/platforms/simplex/__init__.py +3 -0
  893. package/plugins/platforms/simplex/__pycache__/__init__.cpython-312.pyc +0 -0
  894. package/plugins/platforms/simplex/__pycache__/adapter.cpython-312.pyc +0 -0
  895. package/plugins/platforms/simplex/adapter.py +746 -0
  896. package/plugins/platforms/simplex/plugin.yaml +37 -0
  897. package/plugins/platforms/teams/__init__.py +3 -0
  898. package/plugins/platforms/teams/__pycache__/__init__.cpython-312.pyc +0 -0
  899. package/plugins/platforms/teams/__pycache__/adapter.cpython-312.pyc +0 -0
  900. package/plugins/platforms/teams/adapter.py +1188 -0
  901. package/plugins/platforms/teams/plugin.yaml +48 -0
  902. package/plugins/spotify/__init__.py +66 -0
  903. package/plugins/spotify/__pycache__/__init__.cpython-312.pyc +0 -0
  904. package/plugins/spotify/__pycache__/client.cpython-312.pyc +0 -0
  905. package/plugins/spotify/__pycache__/tools.cpython-312.pyc +0 -0
  906. package/plugins/spotify/client.py +435 -0
  907. package/plugins/spotify/plugin.yaml +13 -0
  908. package/plugins/spotify/tools.py +454 -0
  909. package/plugins/teams_pipeline/__init__.py +23 -0
  910. package/plugins/teams_pipeline/cli.py +463 -0
  911. package/plugins/teams_pipeline/meetings.py +333 -0
  912. package/plugins/teams_pipeline/models.py +350 -0
  913. package/plugins/teams_pipeline/pipeline.py +692 -0
  914. package/plugins/teams_pipeline/plugin.yaml +9 -0
  915. package/plugins/teams_pipeline/runtime.py +135 -0
  916. package/plugins/teams_pipeline/store.py +194 -0
  917. package/plugins/teams_pipeline/subscriptions.py +249 -0
  918. package/plugins/video_gen/fal/__init__.py +523 -0
  919. package/plugins/video_gen/fal/__pycache__/__init__.cpython-312.pyc +0 -0
  920. package/plugins/video_gen/fal/plugin.yaml +7 -0
  921. package/plugins/video_gen/xai/__init__.py +441 -0
  922. package/plugins/video_gen/xai/__pycache__/__init__.cpython-312.pyc +0 -0
  923. package/plugins/video_gen/xai/plugin.yaml +7 -0
  924. package/plugins/web/__init__.py +7 -0
  925. package/plugins/web/__pycache__/__init__.cpython-312.pyc +0 -0
  926. package/plugins/web/brave_free/__init__.py +14 -0
  927. package/plugins/web/brave_free/__pycache__/__init__.cpython-312.pyc +0 -0
  928. package/plugins/web/brave_free/__pycache__/provider.cpython-312.pyc +0 -0
  929. package/plugins/web/brave_free/plugin.yaml +7 -0
  930. package/plugins/web/brave_free/provider.py +137 -0
  931. package/plugins/web/ddgs/__init__.py +15 -0
  932. package/plugins/web/ddgs/__pycache__/__init__.cpython-312.pyc +0 -0
  933. package/plugins/web/ddgs/__pycache__/provider.cpython-312.pyc +0 -0
  934. package/plugins/web/ddgs/plugin.yaml +7 -0
  935. package/plugins/web/ddgs/provider.py +104 -0
  936. package/plugins/web/exa/__init__.py +15 -0
  937. package/plugins/web/exa/__pycache__/__init__.cpython-312.pyc +0 -0
  938. package/plugins/web/exa/__pycache__/provider.cpython-312.pyc +0 -0
  939. package/plugins/web/exa/plugin.yaml +7 -0
  940. package/plugins/web/exa/provider.py +212 -0
  941. package/plugins/web/firecrawl/__init__.py +28 -0
  942. package/plugins/web/firecrawl/__pycache__/__init__.cpython-312.pyc +0 -0
  943. package/plugins/web/firecrawl/__pycache__/provider.cpython-312.pyc +0 -0
  944. package/plugins/web/firecrawl/plugin.yaml +7 -0
  945. package/plugins/web/firecrawl/provider.py +773 -0
  946. package/plugins/web/parallel/__init__.py +16 -0
  947. package/plugins/web/parallel/__pycache__/__init__.cpython-312.pyc +0 -0
  948. package/plugins/web/parallel/__pycache__/provider.cpython-312.pyc +0 -0
  949. package/plugins/web/parallel/plugin.yaml +7 -0
  950. package/plugins/web/parallel/provider.py +291 -0
  951. package/plugins/web/searxng/__init__.py +15 -0
  952. package/plugins/web/searxng/__pycache__/__init__.cpython-312.pyc +0 -0
  953. package/plugins/web/searxng/__pycache__/provider.cpython-312.pyc +0 -0
  954. package/plugins/web/searxng/plugin.yaml +7 -0
  955. package/plugins/web/searxng/provider.py +140 -0
  956. package/plugins/web/tavily/__init__.py +15 -0
  957. package/plugins/web/tavily/__pycache__/__init__.cpython-312.pyc +0 -0
  958. package/plugins/web/tavily/__pycache__/provider.cpython-312.pyc +0 -0
  959. package/plugins/web/tavily/plugin.yaml +7 -0
  960. package/plugins/web/tavily/provider.py +285 -0
  961. package/providers/README.md +78 -0
  962. package/providers/__init__.py +192 -0
  963. package/providers/__pycache__/__init__.cpython-312.pyc +0 -0
  964. package/providers/__pycache__/base.cpython-312.pyc +0 -0
  965. package/providers/base.py +184 -0
  966. package/pyproject.toml +255 -0
  967. package/run_agent.py +16409 -0
  968. package/scripts/benchmark_browser_eval.py +138 -0
  969. package/scripts/build_model_catalog.py +95 -0
  970. package/scripts/build_skills_index.py +325 -0
  971. package/scripts/check-windows-footguns.py +624 -0
  972. package/scripts/contributor_audit.py +473 -0
  973. package/scripts/discord-voice-doctor.py +396 -0
  974. package/scripts/hermes-gateway +416 -0
  975. package/scripts/install.cmd +28 -0
  976. package/scripts/install.ps1 +1611 -0
  977. package/scripts/install.sh +2007 -0
  978. package/scripts/install_psutil_android.py +117 -0
  979. package/scripts/keystroke_diagnostic.py +81 -0
  980. package/scripts/kill_modal.sh +34 -0
  981. package/scripts/lib/node-bootstrap.sh +238 -0
  982. package/scripts/lint_diff.py +207 -0
  983. package/scripts/postinstall.js +150 -0
  984. package/scripts/profile-tui.py +626 -0
  985. package/scripts/release.py +1680 -0
  986. package/scripts/run_tests.sh +129 -0
  987. package/scripts/sample_and_compress.py +409 -0
  988. package/scripts/setup_open_webui.sh +349 -0
  989. package/scripts/whatsapp-bridge/allowlist.js +88 -0
  990. package/scripts/whatsapp-bridge/allowlist.test.mjs +80 -0
  991. package/scripts/whatsapp-bridge/bridge.js +729 -0
  992. package/scripts/whatsapp-bridge/package-lock.json +2141 -0
  993. package/scripts/whatsapp-bridge/package.json +19 -0
  994. package/skills/apple/DESCRIPTION.md +2 -0
  995. package/skills/apple/apple-notes/SKILL.md +90 -0
  996. package/skills/apple/apple-reminders/SKILL.md +98 -0
  997. package/skills/apple/findmy/SKILL.md +131 -0
  998. package/skills/apple/imessage/SKILL.md +102 -0
  999. package/skills/apple/macos-computer-use/SKILL.md +201 -0
  1000. package/skills/autonomous-ai-agents/DESCRIPTION.md +3 -0
  1001. package/skills/autonomous-ai-agents/claude-code/SKILL.md +745 -0
  1002. package/skills/autonomous-ai-agents/codex/SKILL.md +130 -0
  1003. package/skills/autonomous-ai-agents/hermes-agent/SKILL.md +1014 -0
  1004. package/skills/autonomous-ai-agents/opencode/SKILL.md +219 -0
  1005. package/skills/creative/DESCRIPTION.md +3 -0
  1006. package/skills/creative/architecture-diagram/SKILL.md +148 -0
  1007. package/skills/creative/architecture-diagram/templates/template.html +319 -0
  1008. package/skills/creative/ascii-art/SKILL.md +322 -0
  1009. package/skills/creative/ascii-video/README.md +290 -0
  1010. package/skills/creative/ascii-video/SKILL.md +241 -0
  1011. package/skills/creative/ascii-video/references/architecture.md +802 -0
  1012. package/skills/creative/ascii-video/references/composition.md +892 -0
  1013. package/skills/creative/ascii-video/references/effects.md +1865 -0
  1014. package/skills/creative/ascii-video/references/inputs.md +685 -0
  1015. package/skills/creative/ascii-video/references/optimization.md +688 -0
  1016. package/skills/creative/ascii-video/references/scenes.md +1011 -0
  1017. package/skills/creative/ascii-video/references/shaders.md +1385 -0
  1018. package/skills/creative/ascii-video/references/troubleshooting.md +367 -0
  1019. package/skills/creative/baoyu-comic/PORT_NOTES.md +77 -0
  1020. package/skills/creative/baoyu-comic/SKILL.md +247 -0
  1021. package/skills/creative/baoyu-comic/references/analysis-framework.md +176 -0
  1022. package/skills/creative/baoyu-comic/references/art-styles/chalk.md +101 -0
  1023. package/skills/creative/baoyu-comic/references/art-styles/ink-brush.md +97 -0
  1024. package/skills/creative/baoyu-comic/references/art-styles/ligne-claire.md +75 -0
  1025. package/skills/creative/baoyu-comic/references/art-styles/manga.md +93 -0
  1026. package/skills/creative/baoyu-comic/references/art-styles/minimalist.md +84 -0
  1027. package/skills/creative/baoyu-comic/references/art-styles/realistic.md +89 -0
  1028. package/skills/creative/baoyu-comic/references/auto-selection.md +71 -0
  1029. package/skills/creative/baoyu-comic/references/base-prompt.md +98 -0
  1030. package/skills/creative/baoyu-comic/references/character-template.md +180 -0
  1031. package/skills/creative/baoyu-comic/references/layouts/cinematic.md +23 -0
  1032. package/skills/creative/baoyu-comic/references/layouts/dense.md +23 -0
  1033. package/skills/creative/baoyu-comic/references/layouts/four-panel.md +40 -0
  1034. package/skills/creative/baoyu-comic/references/layouts/mixed.md +23 -0
  1035. package/skills/creative/baoyu-comic/references/layouts/splash.md +23 -0
  1036. package/skills/creative/baoyu-comic/references/layouts/standard.md +23 -0
  1037. package/skills/creative/baoyu-comic/references/layouts/webtoon.md +30 -0
  1038. package/skills/creative/baoyu-comic/references/ohmsha-guide.md +85 -0
  1039. package/skills/creative/baoyu-comic/references/partial-workflows.md +106 -0
  1040. package/skills/creative/baoyu-comic/references/presets/concept-story.md +121 -0
  1041. package/skills/creative/baoyu-comic/references/presets/four-panel.md +107 -0
  1042. package/skills/creative/baoyu-comic/references/presets/ohmsha.md +114 -0
  1043. package/skills/creative/baoyu-comic/references/presets/shoujo.md +116 -0
  1044. package/skills/creative/baoyu-comic/references/presets/wuxia.md +110 -0
  1045. package/skills/creative/baoyu-comic/references/storyboard-template.md +143 -0
  1046. package/skills/creative/baoyu-comic/references/tones/action.md +110 -0
  1047. package/skills/creative/baoyu-comic/references/tones/dramatic.md +95 -0
  1048. package/skills/creative/baoyu-comic/references/tones/energetic.md +105 -0
  1049. package/skills/creative/baoyu-comic/references/tones/neutral.md +63 -0
  1050. package/skills/creative/baoyu-comic/references/tones/romantic.md +100 -0
  1051. package/skills/creative/baoyu-comic/references/tones/vintage.md +104 -0
  1052. package/skills/creative/baoyu-comic/references/tones/warm.md +94 -0
  1053. package/skills/creative/baoyu-comic/references/workflow.md +401 -0
  1054. package/skills/creative/baoyu-infographic/PORT_NOTES.md +43 -0
  1055. package/skills/creative/baoyu-infographic/SKILL.md +237 -0
  1056. package/skills/creative/baoyu-infographic/references/analysis-framework.md +182 -0
  1057. package/skills/creative/baoyu-infographic/references/base-prompt.md +43 -0
  1058. package/skills/creative/baoyu-infographic/references/layouts/bento-grid.md +41 -0
  1059. package/skills/creative/baoyu-infographic/references/layouts/binary-comparison.md +48 -0
  1060. package/skills/creative/baoyu-infographic/references/layouts/bridge.md +41 -0
  1061. package/skills/creative/baoyu-infographic/references/layouts/circular-flow.md +41 -0
  1062. package/skills/creative/baoyu-infographic/references/layouts/comic-strip.md +41 -0
  1063. package/skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md +41 -0
  1064. package/skills/creative/baoyu-infographic/references/layouts/dashboard.md +41 -0
  1065. package/skills/creative/baoyu-infographic/references/layouts/dense-modules.md +72 -0
  1066. package/skills/creative/baoyu-infographic/references/layouts/funnel.md +41 -0
  1067. package/skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md +48 -0
  1068. package/skills/creative/baoyu-infographic/references/layouts/hub-spoke.md +41 -0
  1069. package/skills/creative/baoyu-infographic/references/layouts/iceberg.md +41 -0
  1070. package/skills/creative/baoyu-infographic/references/layouts/isometric-map.md +41 -0
  1071. package/skills/creative/baoyu-infographic/references/layouts/jigsaw.md +41 -0
  1072. package/skills/creative/baoyu-infographic/references/layouts/linear-progression.md +48 -0
  1073. package/skills/creative/baoyu-infographic/references/layouts/periodic-table.md +41 -0
  1074. package/skills/creative/baoyu-infographic/references/layouts/story-mountain.md +41 -0
  1075. package/skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md +48 -0
  1076. package/skills/creative/baoyu-infographic/references/layouts/tree-branching.md +41 -0
  1077. package/skills/creative/baoyu-infographic/references/layouts/venn-diagram.md +41 -0
  1078. package/skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md +41 -0
  1079. package/skills/creative/baoyu-infographic/references/structured-content-template.md +244 -0
  1080. package/skills/creative/baoyu-infographic/references/styles/aged-academia.md +36 -0
  1081. package/skills/creative/baoyu-infographic/references/styles/bold-graphic.md +36 -0
  1082. package/skills/creative/baoyu-infographic/references/styles/chalkboard.md +61 -0
  1083. package/skills/creative/baoyu-infographic/references/styles/claymation.md +29 -0
  1084. package/skills/creative/baoyu-infographic/references/styles/corporate-memphis.md +29 -0
  1085. package/skills/creative/baoyu-infographic/references/styles/craft-handmade.md +44 -0
  1086. package/skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md +29 -0
  1087. package/skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md +63 -0
  1088. package/skills/creative/baoyu-infographic/references/styles/ikea-manual.md +29 -0
  1089. package/skills/creative/baoyu-infographic/references/styles/kawaii.md +29 -0
  1090. package/skills/creative/baoyu-infographic/references/styles/knolling.md +29 -0
  1091. package/skills/creative/baoyu-infographic/references/styles/lego-brick.md +29 -0
  1092. package/skills/creative/baoyu-infographic/references/styles/morandi-journal.md +60 -0
  1093. package/skills/creative/baoyu-infographic/references/styles/origami.md +29 -0
  1094. package/skills/creative/baoyu-infographic/references/styles/pixel-art.md +29 -0
  1095. package/skills/creative/baoyu-infographic/references/styles/pop-laboratory.md +48 -0
  1096. package/skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md +47 -0
  1097. package/skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md +29 -0
  1098. package/skills/creative/baoyu-infographic/references/styles/subway-map.md +29 -0
  1099. package/skills/creative/baoyu-infographic/references/styles/technical-schematic.md +36 -0
  1100. package/skills/creative/baoyu-infographic/references/styles/ui-wireframe.md +29 -0
  1101. package/skills/creative/claude-design/SKILL.md +591 -0
  1102. package/skills/creative/comfyui/SKILL.md +612 -0
  1103. package/skills/creative/comfyui/references/official-cli.md +255 -0
  1104. package/skills/creative/comfyui/references/rest-api.md +312 -0
  1105. package/skills/creative/comfyui/references/template-integrity.md +243 -0
  1106. package/skills/creative/comfyui/references/workflow-format.md +226 -0
  1107. package/skills/creative/comfyui/scripts/_common.py +835 -0
  1108. package/skills/creative/comfyui/scripts/auto_fix_deps.py +225 -0
  1109. package/skills/creative/comfyui/scripts/check_deps.py +437 -0
  1110. package/skills/creative/comfyui/scripts/comfyui_setup.sh +286 -0
  1111. package/skills/creative/comfyui/scripts/extract_schema.py +315 -0
  1112. package/skills/creative/comfyui/scripts/fetch_logs.py +158 -0
  1113. package/skills/creative/comfyui/scripts/hardware_check.py +497 -0
  1114. package/skills/creative/comfyui/scripts/health_check.py +223 -0
  1115. package/skills/creative/comfyui/scripts/run_batch.py +243 -0
  1116. package/skills/creative/comfyui/scripts/run_workflow.py +796 -0
  1117. package/skills/creative/comfyui/scripts/ws_monitor.py +267 -0
  1118. package/skills/creative/comfyui/tests/README.md +50 -0
  1119. package/skills/creative/comfyui/tests/conftest.py +64 -0
  1120. package/skills/creative/comfyui/tests/pytest.ini +5 -0
  1121. package/skills/creative/comfyui/tests/test_check_deps.py +68 -0
  1122. package/skills/creative/comfyui/tests/test_cloud_integration.py +95 -0
  1123. package/skills/creative/comfyui/tests/test_common.py +447 -0
  1124. package/skills/creative/comfyui/tests/test_extract_schema.py +185 -0
  1125. package/skills/creative/comfyui/tests/test_run_workflow.py +213 -0
  1126. package/skills/creative/comfyui/workflows/README.md +86 -0
  1127. package/skills/creative/comfyui/workflows/animatediff_video.json +64 -0
  1128. package/skills/creative/comfyui/workflows/flux_dev_txt2img.json +78 -0
  1129. package/skills/creative/comfyui/workflows/sd15_txt2img.json +49 -0
  1130. package/skills/creative/comfyui/workflows/sdxl_img2img.json +54 -0
  1131. package/skills/creative/comfyui/workflows/sdxl_inpaint.json +59 -0
  1132. package/skills/creative/comfyui/workflows/sdxl_txt2img.json +49 -0
  1133. package/skills/creative/comfyui/workflows/upscale_4x.json +27 -0
  1134. package/skills/creative/comfyui/workflows/wan_video_t2v.json +69 -0
  1135. package/skills/creative/creative-ideation/SKILL.md +152 -0
  1136. package/skills/creative/creative-ideation/references/full-prompt-library.md +110 -0
  1137. package/skills/creative/design-md/SKILL.md +199 -0
  1138. package/skills/creative/design-md/templates/starter.md +99 -0
  1139. package/skills/creative/excalidraw/SKILL.md +199 -0
  1140. package/skills/creative/excalidraw/references/colors.md +44 -0
  1141. package/skills/creative/excalidraw/references/dark-mode.md +68 -0
  1142. package/skills/creative/excalidraw/references/examples.md +141 -0
  1143. package/skills/creative/excalidraw/scripts/upload.py +133 -0
  1144. package/skills/creative/humanizer/LICENSE +21 -0
  1145. package/skills/creative/humanizer/SKILL.md +578 -0
  1146. package/skills/creative/manim-video/README.md +23 -0
  1147. package/skills/creative/manim-video/SKILL.md +269 -0
  1148. package/skills/creative/manim-video/references/animation-design-thinking.md +161 -0
  1149. package/skills/creative/manim-video/references/animations.md +282 -0
  1150. package/skills/creative/manim-video/references/camera-and-3d.md +135 -0
  1151. package/skills/creative/manim-video/references/decorations.md +202 -0
  1152. package/skills/creative/manim-video/references/equations.md +216 -0
  1153. package/skills/creative/manim-video/references/graphs-and-data.md +163 -0
  1154. package/skills/creative/manim-video/references/mobjects.md +333 -0
  1155. package/skills/creative/manim-video/references/paper-explainer.md +255 -0
  1156. package/skills/creative/manim-video/references/production-quality.md +190 -0
  1157. package/skills/creative/manim-video/references/rendering.md +185 -0
  1158. package/skills/creative/manim-video/references/scene-planning.md +118 -0
  1159. package/skills/creative/manim-video/references/troubleshooting.md +135 -0
  1160. package/skills/creative/manim-video/references/updaters-and-trackers.md +260 -0
  1161. package/skills/creative/manim-video/references/visual-design.md +124 -0
  1162. package/skills/creative/manim-video/scripts/setup.sh +14 -0
  1163. package/skills/creative/p5js/README.md +64 -0
  1164. package/skills/creative/p5js/SKILL.md +556 -0
  1165. package/skills/creative/p5js/references/animation.md +439 -0
  1166. package/skills/creative/p5js/references/color-systems.md +352 -0
  1167. package/skills/creative/p5js/references/core-api.md +410 -0
  1168. package/skills/creative/p5js/references/export-pipeline.md +566 -0
  1169. package/skills/creative/p5js/references/interaction.md +398 -0
  1170. package/skills/creative/p5js/references/shapes-and-geometry.md +300 -0
  1171. package/skills/creative/p5js/references/troubleshooting.md +532 -0
  1172. package/skills/creative/p5js/references/typography.md +302 -0
  1173. package/skills/creative/p5js/references/visual-effects.md +895 -0
  1174. package/skills/creative/p5js/references/webgl-and-3d.md +423 -0
  1175. package/skills/creative/p5js/scripts/export-frames.js +179 -0
  1176. package/skills/creative/p5js/scripts/render.sh +108 -0
  1177. package/skills/creative/p5js/scripts/serve.sh +28 -0
  1178. package/skills/creative/p5js/scripts/setup.sh +87 -0
  1179. package/skills/creative/p5js/templates/viewer.html +395 -0
  1180. package/skills/creative/pixel-art/ATTRIBUTION.md +54 -0
  1181. package/skills/creative/pixel-art/SKILL.md +218 -0
  1182. package/skills/creative/pixel-art/references/palettes.md +49 -0
  1183. package/skills/creative/pixel-art/scripts/__init__.py +0 -0
  1184. package/skills/creative/pixel-art/scripts/palettes.py +167 -0
  1185. package/skills/creative/pixel-art/scripts/pixel_art.py +162 -0
  1186. package/skills/creative/pixel-art/scripts/pixel_art_video.py +345 -0
  1187. package/skills/creative/popular-web-designs/SKILL.md +214 -0
  1188. package/skills/creative/popular-web-designs/templates/airbnb.md +259 -0
  1189. package/skills/creative/popular-web-designs/templates/airtable.md +102 -0
  1190. package/skills/creative/popular-web-designs/templates/apple.md +326 -0
  1191. package/skills/creative/popular-web-designs/templates/bmw.md +193 -0
  1192. package/skills/creative/popular-web-designs/templates/cal.md +272 -0
  1193. package/skills/creative/popular-web-designs/templates/claude.md +325 -0
  1194. package/skills/creative/popular-web-designs/templates/clay.md +317 -0
  1195. package/skills/creative/popular-web-designs/templates/clickhouse.md +294 -0
  1196. package/skills/creative/popular-web-designs/templates/cohere.md +279 -0
  1197. package/skills/creative/popular-web-designs/templates/coinbase.md +142 -0
  1198. package/skills/creative/popular-web-designs/templates/composio.md +320 -0
  1199. package/skills/creative/popular-web-designs/templates/cursor.md +322 -0
  1200. package/skills/creative/popular-web-designs/templates/elevenlabs.md +278 -0
  1201. package/skills/creative/popular-web-designs/templates/expo.md +294 -0
  1202. package/skills/creative/popular-web-designs/templates/figma.md +233 -0
  1203. package/skills/creative/popular-web-designs/templates/framer.md +259 -0
  1204. package/skills/creative/popular-web-designs/templates/hashicorp.md +291 -0
  1205. package/skills/creative/popular-web-designs/templates/ibm.md +345 -0
  1206. package/skills/creative/popular-web-designs/templates/intercom.md +159 -0
  1207. package/skills/creative/popular-web-designs/templates/kraken.md +138 -0
  1208. package/skills/creative/popular-web-designs/templates/linear.app.md +380 -0
  1209. package/skills/creative/popular-web-designs/templates/lovable.md +311 -0
  1210. package/skills/creative/popular-web-designs/templates/minimax.md +270 -0
  1211. package/skills/creative/popular-web-designs/templates/mintlify.md +339 -0
  1212. package/skills/creative/popular-web-designs/templates/miro.md +121 -0
  1213. package/skills/creative/popular-web-designs/templates/mistral.ai.md +274 -0
  1214. package/skills/creative/popular-web-designs/templates/mongodb.md +279 -0
  1215. package/skills/creative/popular-web-designs/templates/notion.md +322 -0
  1216. package/skills/creative/popular-web-designs/templates/nvidia.md +306 -0
  1217. package/skills/creative/popular-web-designs/templates/ollama.md +280 -0
  1218. package/skills/creative/popular-web-designs/templates/opencode.ai.md +294 -0
  1219. package/skills/creative/popular-web-designs/templates/pinterest.md +243 -0
  1220. package/skills/creative/popular-web-designs/templates/posthog.md +269 -0
  1221. package/skills/creative/popular-web-designs/templates/raycast.md +281 -0
  1222. package/skills/creative/popular-web-designs/templates/replicate.md +274 -0
  1223. package/skills/creative/popular-web-designs/templates/resend.md +316 -0
  1224. package/skills/creative/popular-web-designs/templates/revolut.md +198 -0
  1225. package/skills/creative/popular-web-designs/templates/runwayml.md +257 -0
  1226. package/skills/creative/popular-web-designs/templates/sanity.md +370 -0
  1227. package/skills/creative/popular-web-designs/templates/sentry.md +275 -0
  1228. package/skills/creative/popular-web-designs/templates/spacex.md +207 -0
  1229. package/skills/creative/popular-web-designs/templates/spotify.md +259 -0
  1230. package/skills/creative/popular-web-designs/templates/stripe.md +335 -0
  1231. package/skills/creative/popular-web-designs/templates/supabase.md +268 -0
  1232. package/skills/creative/popular-web-designs/templates/superhuman.md +265 -0
  1233. package/skills/creative/popular-web-designs/templates/together.ai.md +276 -0
  1234. package/skills/creative/popular-web-designs/templates/uber.md +308 -0
  1235. package/skills/creative/popular-web-designs/templates/vercel.md +323 -0
  1236. package/skills/creative/popular-web-designs/templates/voltagent.md +336 -0
  1237. package/skills/creative/popular-web-designs/templates/warp.md +266 -0
  1238. package/skills/creative/popular-web-designs/templates/webflow.md +105 -0
  1239. package/skills/creative/popular-web-designs/templates/wise.md +186 -0
  1240. package/skills/creative/popular-web-designs/templates/x.ai.md +270 -0
  1241. package/skills/creative/popular-web-designs/templates/zapier.md +341 -0
  1242. package/skills/creative/pretext/SKILL.md +220 -0
  1243. package/skills/creative/pretext/references/patterns.md +258 -0
  1244. package/skills/creative/pretext/templates/donut-orbit.html +1468 -0
  1245. package/skills/creative/pretext/templates/hello-orb-flow.html +95 -0
  1246. package/skills/creative/sketch/SKILL.md +218 -0
  1247. package/skills/creative/songwriting-and-ai-music/SKILL.md +287 -0
  1248. package/skills/creative/touchdesigner-mcp/SKILL.md +356 -0
  1249. package/skills/creative/touchdesigner-mcp/references/3d-scene.md +275 -0
  1250. package/skills/creative/touchdesigner-mcp/references/animation.md +221 -0
  1251. package/skills/creative/touchdesigner-mcp/references/audio-reactive.md +175 -0
  1252. package/skills/creative/touchdesigner-mcp/references/dat-scripting.md +352 -0
  1253. package/skills/creative/touchdesigner-mcp/references/external-data.md +322 -0
  1254. package/skills/creative/touchdesigner-mcp/references/geometry-comp.md +121 -0
  1255. package/skills/creative/touchdesigner-mcp/references/glsl.md +151 -0
  1256. package/skills/creative/touchdesigner-mcp/references/layout-compositor.md +131 -0
  1257. package/skills/creative/touchdesigner-mcp/references/mcp-tools.md +382 -0
  1258. package/skills/creative/touchdesigner-mcp/references/midi-osc.md +211 -0
  1259. package/skills/creative/touchdesigner-mcp/references/network-patterns.md +966 -0
  1260. package/skills/creative/touchdesigner-mcp/references/operator-tips.md +106 -0
  1261. package/skills/creative/touchdesigner-mcp/references/operators.md +239 -0
  1262. package/skills/creative/touchdesigner-mcp/references/panel-ui.md +281 -0
  1263. package/skills/creative/touchdesigner-mcp/references/particles.md +245 -0
  1264. package/skills/creative/touchdesigner-mcp/references/pitfalls.md +704 -0
  1265. package/skills/creative/touchdesigner-mcp/references/postfx.md +183 -0
  1266. package/skills/creative/touchdesigner-mcp/references/projection-mapping.md +211 -0
  1267. package/skills/creative/touchdesigner-mcp/references/python-api.md +463 -0
  1268. package/skills/creative/touchdesigner-mcp/references/replicator.md +198 -0
  1269. package/skills/creative/touchdesigner-mcp/references/troubleshooting.md +244 -0
  1270. package/skills/creative/touchdesigner-mcp/scripts/setup.sh +115 -0
  1271. package/skills/data-science/DESCRIPTION.md +3 -0
  1272. package/skills/data-science/jupyter-live-kernel/SKILL.md +167 -0
  1273. package/skills/devops/kanban-orchestrator/SKILL.md +189 -0
  1274. package/skills/devops/kanban-worker/SKILL.md +184 -0
  1275. package/skills/devops/webhook-subscriptions/SKILL.md +204 -0
  1276. package/skills/diagramming/DESCRIPTION.md +3 -0
  1277. package/skills/dogfood/SKILL.md +162 -0
  1278. package/skills/dogfood/references/issue-taxonomy.md +109 -0
  1279. package/skills/dogfood/templates/dogfood-report-template.md +86 -0
  1280. package/skills/domain/DESCRIPTION.md +24 -0
  1281. package/skills/email/DESCRIPTION.md +3 -0
  1282. package/skills/email/himalaya/SKILL.md +299 -0
  1283. package/skills/email/himalaya/references/configuration.md +227 -0
  1284. package/skills/email/himalaya/references/message-composition.md +199 -0
  1285. package/skills/gaming/DESCRIPTION.md +3 -0
  1286. package/skills/gaming/minecraft-modpack-server/SKILL.md +187 -0
  1287. package/skills/gaming/pokemon-player/SKILL.md +216 -0
  1288. package/skills/gifs/DESCRIPTION.md +3 -0
  1289. package/skills/github/DESCRIPTION.md +3 -0
  1290. package/skills/github/codebase-inspection/SKILL.md +116 -0
  1291. package/skills/github/github-auth/SKILL.md +247 -0
  1292. package/skills/github/github-auth/scripts/gh-env.sh +66 -0
  1293. package/skills/github/github-code-review/SKILL.md +481 -0
  1294. package/skills/github/github-code-review/references/review-output-template.md +74 -0
  1295. package/skills/github/github-issues/SKILL.md +370 -0
  1296. package/skills/github/github-issues/templates/bug-report.md +35 -0
  1297. package/skills/github/github-issues/templates/feature-request.md +31 -0
  1298. package/skills/github/github-pr-workflow/SKILL.md +367 -0
  1299. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +183 -0
  1300. package/skills/github/github-pr-workflow/references/conventional-commits.md +71 -0
  1301. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +35 -0
  1302. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +33 -0
  1303. package/skills/github/github-repo-management/SKILL.md +516 -0
  1304. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +161 -0
  1305. package/skills/index-cache/anthropics_skills_skills_.json +1 -0
  1306. package/skills/index-cache/claude_marketplace_anthropics_skills.json +1 -0
  1307. package/skills/index-cache/lobehub_index.json +1 -0
  1308. package/skills/index-cache/openai_skills_skills_.json +1 -0
  1309. package/skills/inference-sh/DESCRIPTION.md +19 -0
  1310. package/skills/mcp/DESCRIPTION.md +3 -0
  1311. package/skills/mcp/native-mcp/SKILL.md +357 -0
  1312. package/skills/media/DESCRIPTION.md +3 -0
  1313. package/skills/media/gif-search/SKILL.md +91 -0
  1314. package/skills/media/heartmula/SKILL.md +171 -0
  1315. package/skills/media/songsee/SKILL.md +83 -0
  1316. package/skills/media/spotify/SKILL.md +135 -0
  1317. package/skills/media/youtube-content/SKILL.md +73 -0
  1318. package/skills/media/youtube-content/references/output-formats.md +56 -0
  1319. package/skills/media/youtube-content/scripts/fetch_transcript.py +124 -0
  1320. package/skills/mlops/DESCRIPTION.md +3 -0
  1321. package/skills/mlops/evaluation/DESCRIPTION.md +3 -0
  1322. package/skills/mlops/evaluation/lm-evaluation-harness/SKILL.md +498 -0
  1323. package/skills/mlops/evaluation/lm-evaluation-harness/references/api-evaluation.md +490 -0
  1324. package/skills/mlops/evaluation/lm-evaluation-harness/references/benchmark-guide.md +488 -0
  1325. package/skills/mlops/evaluation/lm-evaluation-harness/references/custom-tasks.md +602 -0
  1326. package/skills/mlops/evaluation/lm-evaluation-harness/references/distributed-eval.md +519 -0
  1327. package/skills/mlops/evaluation/weights-and-biases/SKILL.md +594 -0
  1328. package/skills/mlops/evaluation/weights-and-biases/references/artifacts.md +584 -0
  1329. package/skills/mlops/evaluation/weights-and-biases/references/integrations.md +700 -0
  1330. package/skills/mlops/evaluation/weights-and-biases/references/sweeps.md +847 -0
  1331. package/skills/mlops/huggingface-hub/SKILL.md +81 -0
  1332. package/skills/mlops/inference/DESCRIPTION.md +3 -0
  1333. package/skills/mlops/inference/llama-cpp/SKILL.md +249 -0
  1334. package/skills/mlops/inference/llama-cpp/references/advanced-usage.md +504 -0
  1335. package/skills/mlops/inference/llama-cpp/references/hub-discovery.md +168 -0
  1336. package/skills/mlops/inference/llama-cpp/references/optimization.md +89 -0
  1337. package/skills/mlops/inference/llama-cpp/references/quantization.md +243 -0
  1338. package/skills/mlops/inference/llama-cpp/references/server.md +150 -0
  1339. package/skills/mlops/inference/llama-cpp/references/troubleshooting.md +442 -0
  1340. package/skills/mlops/inference/obliteratus/SKILL.md +342 -0
  1341. package/skills/mlops/inference/obliteratus/references/analysis-modules.md +166 -0
  1342. package/skills/mlops/inference/obliteratus/references/methods-guide.md +141 -0
  1343. package/skills/mlops/inference/obliteratus/templates/abliteration-config.yaml +33 -0
  1344. package/skills/mlops/inference/obliteratus/templates/analysis-study.yaml +40 -0
  1345. package/skills/mlops/inference/obliteratus/templates/batch-abliteration.yaml +41 -0
  1346. package/skills/mlops/inference/vllm/SKILL.md +372 -0
  1347. package/skills/mlops/inference/vllm/references/optimization.md +226 -0
  1348. package/skills/mlops/inference/vllm/references/quantization.md +284 -0
  1349. package/skills/mlops/inference/vllm/references/server-deployment.md +255 -0
  1350. package/skills/mlops/inference/vllm/references/troubleshooting.md +447 -0
  1351. package/skills/mlops/models/DESCRIPTION.md +3 -0
  1352. package/skills/mlops/models/audiocraft/SKILL.md +568 -0
  1353. package/skills/mlops/models/audiocraft/references/advanced-usage.md +666 -0
  1354. package/skills/mlops/models/audiocraft/references/troubleshooting.md +504 -0
  1355. package/skills/mlops/models/segment-anything/SKILL.md +506 -0
  1356. package/skills/mlops/models/segment-anything/references/advanced-usage.md +589 -0
  1357. package/skills/mlops/models/segment-anything/references/troubleshooting.md +484 -0
  1358. package/skills/mlops/research/DESCRIPTION.md +3 -0
  1359. package/skills/mlops/research/dspy/SKILL.md +594 -0
  1360. package/skills/mlops/research/dspy/references/examples.md +663 -0
  1361. package/skills/mlops/research/dspy/references/modules.md +475 -0
  1362. package/skills/mlops/research/dspy/references/optimizers.md +566 -0
  1363. package/skills/mlops/training/DESCRIPTION.md +3 -0
  1364. package/skills/mlops/vector-databases/DESCRIPTION.md +3 -0
  1365. package/skills/note-taking/DESCRIPTION.md +3 -0
  1366. package/skills/note-taking/obsidian/SKILL.md +61 -0
  1367. package/skills/productivity/DESCRIPTION.md +3 -0
  1368. package/skills/productivity/airtable/SKILL.md +229 -0
  1369. package/skills/productivity/google-workspace/SKILL.md +335 -0
  1370. package/skills/productivity/google-workspace/references/gmail-search-syntax.md +63 -0
  1371. package/skills/productivity/google-workspace/scripts/_hermes_home.py +43 -0
  1372. package/skills/productivity/google-workspace/scripts/google_api.py +1221 -0
  1373. package/skills/productivity/google-workspace/scripts/gws_bridge.py +108 -0
  1374. package/skills/productivity/google-workspace/scripts/setup.py +454 -0
  1375. package/skills/productivity/linear/SKILL.md +380 -0
  1376. package/skills/productivity/linear/scripts/linear_api.py +445 -0
  1377. package/skills/productivity/maps/SKILL.md +195 -0
  1378. package/skills/productivity/maps/scripts/maps_client.py +1298 -0
  1379. package/skills/productivity/nano-pdf/SKILL.md +52 -0
  1380. package/skills/productivity/notion/SKILL.md +448 -0
  1381. package/skills/productivity/notion/references/block-types.md +112 -0
  1382. package/skills/productivity/ocr-and-documents/DESCRIPTION.md +3 -0
  1383. package/skills/productivity/ocr-and-documents/SKILL.md +172 -0
  1384. package/skills/productivity/ocr-and-documents/scripts/extract_marker.py +87 -0
  1385. package/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +98 -0
  1386. package/skills/productivity/powerpoint/LICENSE.txt +30 -0
  1387. package/skills/productivity/powerpoint/SKILL.md +237 -0
  1388. package/skills/productivity/powerpoint/editing.md +205 -0
  1389. package/skills/productivity/powerpoint/pptxgenjs.md +420 -0
  1390. package/skills/productivity/powerpoint/scripts/__init__.py +0 -0
  1391. package/skills/productivity/powerpoint/scripts/add_slide.py +195 -0
  1392. package/skills/productivity/powerpoint/scripts/clean.py +286 -0
  1393. package/skills/productivity/powerpoint/scripts/office/helpers/__init__.py +0 -0
  1394. package/skills/productivity/powerpoint/scripts/office/helpers/merge_runs.py +199 -0
  1395. package/skills/productivity/powerpoint/scripts/office/helpers/simplify_redlines.py +197 -0
  1396. package/skills/productivity/powerpoint/scripts/office/pack.py +159 -0
  1397. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  1398. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  1399. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  1400. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  1401. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  1402. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  1403. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  1404. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  1405. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  1406. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  1407. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  1408. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  1409. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  1410. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  1411. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  1412. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  1413. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  1414. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  1415. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  1416. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  1417. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  1418. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  1419. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  1420. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  1421. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  1422. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  1423. package/skills/productivity/powerpoint/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  1424. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-contentTypes.xsd +42 -0
  1425. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-coreProperties.xsd +50 -0
  1426. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-digSig.xsd +49 -0
  1427. package/skills/productivity/powerpoint/scripts/office/schemas/ecma/fourth-edition/opc-relationships.xsd +33 -0
  1428. package/skills/productivity/powerpoint/scripts/office/schemas/mce/mc.xsd +75 -0
  1429. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  1430. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  1431. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  1432. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  1433. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  1434. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  1435. package/skills/productivity/powerpoint/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  1436. package/skills/productivity/teams-meeting-pipeline/SKILL.md +116 -0
  1437. package/skills/red-teaming/godmode/SKILL.md +404 -0
  1438. package/skills/red-teaming/godmode/references/jailbreak-templates.md +128 -0
  1439. package/skills/red-teaming/godmode/references/refusal-detection.md +142 -0
  1440. package/skills/red-teaming/godmode/scripts/auto_jailbreak.py +769 -0
  1441. package/skills/red-teaming/godmode/scripts/godmode_race.py +530 -0
  1442. package/skills/red-teaming/godmode/scripts/load_godmode.py +45 -0
  1443. package/skills/red-teaming/godmode/scripts/parseltongue.py +550 -0
  1444. package/skills/red-teaming/godmode/templates/prefill-subtle.json +10 -0
  1445. package/skills/red-teaming/godmode/templates/prefill.json +18 -0
  1446. package/skills/research/DESCRIPTION.md +3 -0
  1447. package/skills/research/arxiv/SKILL.md +282 -0
  1448. package/skills/research/arxiv/scripts/search_arxiv.py +114 -0
  1449. package/skills/research/blogwatcher/SKILL.md +137 -0
  1450. package/skills/research/llm-wiki/SKILL.md +507 -0
  1451. package/skills/research/polymarket/SKILL.md +77 -0
  1452. package/skills/research/polymarket/references/api-endpoints.md +220 -0
  1453. package/skills/research/polymarket/scripts/polymarket.py +284 -0
  1454. package/skills/research/research-paper-writing/SKILL.md +2377 -0
  1455. package/skills/research/research-paper-writing/references/autoreason-methodology.md +394 -0
  1456. package/skills/research/research-paper-writing/references/checklists.md +434 -0
  1457. package/skills/research/research-paper-writing/references/citation-workflow.md +564 -0
  1458. package/skills/research/research-paper-writing/references/experiment-patterns.md +728 -0
  1459. package/skills/research/research-paper-writing/references/human-evaluation.md +476 -0
  1460. package/skills/research/research-paper-writing/references/paper-types.md +481 -0
  1461. package/skills/research/research-paper-writing/references/reviewer-guidelines.md +433 -0
  1462. package/skills/research/research-paper-writing/references/sources.md +191 -0
  1463. package/skills/research/research-paper-writing/references/writing-guide.md +474 -0
  1464. package/skills/research/research-paper-writing/templates/README.md +251 -0
  1465. package/skills/research/research-paper-writing/templates/aaai2026/README.md +534 -0
  1466. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-supp.tex +144 -0
  1467. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026-unified-template.tex +952 -0
  1468. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bib +111 -0
  1469. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.bst +1493 -0
  1470. package/skills/research/research-paper-writing/templates/aaai2026/aaai2026.sty +315 -0
  1471. package/skills/research/research-paper-writing/templates/acl/README.md +50 -0
  1472. package/skills/research/research-paper-writing/templates/acl/acl.sty +312 -0
  1473. package/skills/research/research-paper-writing/templates/acl/acl_latex.tex +377 -0
  1474. package/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +101 -0
  1475. package/skills/research/research-paper-writing/templates/acl/acl_natbib.bst +1940 -0
  1476. package/skills/research/research-paper-writing/templates/acl/anthology.bib.txt +26 -0
  1477. package/skills/research/research-paper-writing/templates/acl/custom.bib +70 -0
  1478. package/skills/research/research-paper-writing/templates/acl/formatting.md +326 -0
  1479. package/skills/research/research-paper-writing/templates/colm2025/README.md +3 -0
  1480. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bib +11 -0
  1481. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.bst +1440 -0
  1482. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.pdf +0 -0
  1483. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.sty +218 -0
  1484. package/skills/research/research-paper-writing/templates/colm2025/colm2025_conference.tex +305 -0
  1485. package/skills/research/research-paper-writing/templates/colm2025/fancyhdr.sty +485 -0
  1486. package/skills/research/research-paper-writing/templates/colm2025/math_commands.tex +508 -0
  1487. package/skills/research/research-paper-writing/templates/colm2025/natbib.sty +1246 -0
  1488. package/skills/research/research-paper-writing/templates/iclr2026/fancyhdr.sty +485 -0
  1489. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bib +24 -0
  1490. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.bst +1440 -0
  1491. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.pdf +0 -0
  1492. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.sty +246 -0
  1493. package/skills/research/research-paper-writing/templates/iclr2026/iclr2026_conference.tex +414 -0
  1494. package/skills/research/research-paper-writing/templates/iclr2026/math_commands.tex +508 -0
  1495. package/skills/research/research-paper-writing/templates/iclr2026/natbib.sty +1246 -0
  1496. package/skills/research/research-paper-writing/templates/icml2026/algorithm.sty +79 -0
  1497. package/skills/research/research-paper-writing/templates/icml2026/algorithmic.sty +201 -0
  1498. package/skills/research/research-paper-writing/templates/icml2026/example_paper.bib +75 -0
  1499. package/skills/research/research-paper-writing/templates/icml2026/example_paper.pdf +0 -0
  1500. package/skills/research/research-paper-writing/templates/icml2026/example_paper.tex +662 -0
  1501. package/skills/research/research-paper-writing/templates/icml2026/fancyhdr.sty +864 -0
  1502. package/skills/research/research-paper-writing/templates/icml2026/icml2026.bst +1443 -0
  1503. package/skills/research/research-paper-writing/templates/icml2026/icml2026.sty +767 -0
  1504. package/skills/research/research-paper-writing/templates/icml2026/icml_numpapers.pdf +0 -0
  1505. package/skills/research/research-paper-writing/templates/neurips2025/Makefile +36 -0
  1506. package/skills/research/research-paper-writing/templates/neurips2025/extra_pkgs.tex +53 -0
  1507. package/skills/research/research-paper-writing/templates/neurips2025/main.tex +38 -0
  1508. package/skills/research/research-paper-writing/templates/neurips2025/neurips.sty +382 -0
  1509. package/skills/smart-home/DESCRIPTION.md +3 -0
  1510. package/skills/smart-home/openhue/SKILL.md +109 -0
  1511. package/skills/social-media/DESCRIPTION.md +3 -0
  1512. package/skills/social-media/xurl/SKILL.md +414 -0
  1513. package/skills/software-development/debugging-hermes-tui-commands/SKILL.md +152 -0
  1514. package/skills/software-development/hermes-agent-skill-authoring/SKILL.md +165 -0
  1515. package/skills/software-development/node-inspect-debugger/SKILL.md +319 -0
  1516. package/skills/software-development/plan/SKILL.md +58 -0
  1517. package/skills/software-development/python-debugpy/SKILL.md +375 -0
  1518. package/skills/software-development/requesting-code-review/SKILL.md +280 -0
  1519. package/skills/software-development/spike/SKILL.md +197 -0
  1520. package/skills/software-development/subagent-driven-development/SKILL.md +352 -0
  1521. package/skills/software-development/subagent-driven-development/references/context-budget-discipline.md +53 -0
  1522. package/skills/software-development/subagent-driven-development/references/gates-taxonomy.md +93 -0
  1523. package/skills/software-development/systematic-debugging/SKILL.md +367 -0
  1524. package/skills/software-development/test-driven-development/SKILL.md +343 -0
  1525. package/skills/software-development/writing-plans/SKILL.md +297 -0
  1526. package/skills/yuanbao/SKILL.md +108 -0
  1527. package/tools/__init__.py +25 -0
  1528. package/tools/__pycache__/__init__.cpython-312.pyc +0 -0
  1529. package/tools/__pycache__/approval.cpython-312.pyc +0 -0
  1530. package/tools/__pycache__/binary_extensions.cpython-312.pyc +0 -0
  1531. package/tools/__pycache__/browser_camofox.cpython-312.pyc +0 -0
  1532. package/tools/__pycache__/browser_camofox_state.cpython-312.pyc +0 -0
  1533. package/tools/__pycache__/browser_cdp_tool.cpython-312.pyc +0 -0
  1534. package/tools/__pycache__/browser_dialog_tool.cpython-312.pyc +0 -0
  1535. package/tools/__pycache__/browser_supervisor.cpython-312.pyc +0 -0
  1536. package/tools/__pycache__/browser_tool.cpython-312.pyc +0 -0
  1537. package/tools/__pycache__/budget_config.cpython-312.pyc +0 -0
  1538. package/tools/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  1539. package/tools/__pycache__/clarify_gateway.cpython-312.pyc +0 -0
  1540. package/tools/__pycache__/clarify_tool.cpython-312.pyc +0 -0
  1541. package/tools/__pycache__/code_execution_tool.cpython-312.pyc +0 -0
  1542. package/tools/__pycache__/computer_use_tool.cpython-312.pyc +0 -0
  1543. package/tools/__pycache__/cronjob_tools.cpython-312.pyc +0 -0
  1544. package/tools/__pycache__/debug_helpers.cpython-312.pyc +0 -0
  1545. package/tools/__pycache__/delegate_tool.cpython-312.pyc +0 -0
  1546. package/tools/__pycache__/discord_tool.cpython-312.pyc +0 -0
  1547. package/tools/__pycache__/feishu_doc_tool.cpython-312.pyc +0 -0
  1548. package/tools/__pycache__/feishu_drive_tool.cpython-312.pyc +0 -0
  1549. package/tools/__pycache__/file_operations.cpython-312.pyc +0 -0
  1550. package/tools/__pycache__/file_state.cpython-312.pyc +0 -0
  1551. package/tools/__pycache__/file_tools.cpython-312.pyc +0 -0
  1552. package/tools/__pycache__/homeassistant_tool.cpython-312.pyc +0 -0
  1553. package/tools/__pycache__/image_generation_tool.cpython-312.pyc +0 -0
  1554. package/tools/__pycache__/interrupt.cpython-312.pyc +0 -0
  1555. package/tools/__pycache__/kanban_tools.cpython-312.pyc +0 -0
  1556. package/tools/__pycache__/lazy_deps.cpython-312.pyc +0 -0
  1557. package/tools/__pycache__/managed_tool_gateway.cpython-312.pyc +0 -0
  1558. package/tools/__pycache__/mcp_tool.cpython-312.pyc +0 -0
  1559. package/tools/__pycache__/memory_tool.cpython-312.pyc +0 -0
  1560. package/tools/__pycache__/mixture_of_agents_tool.cpython-312.pyc +0 -0
  1561. package/tools/__pycache__/openrouter_client.cpython-312.pyc +0 -0
  1562. package/tools/__pycache__/process_registry.cpython-312.pyc +0 -0
  1563. package/tools/__pycache__/registry.cpython-312.pyc +0 -0
  1564. package/tools/__pycache__/schema_sanitizer.cpython-312.pyc +0 -0
  1565. package/tools/__pycache__/send_message_tool.cpython-312.pyc +0 -0
  1566. package/tools/__pycache__/session_search_tool.cpython-312.pyc +0 -0
  1567. package/tools/__pycache__/skill_manager_tool.cpython-312.pyc +0 -0
  1568. package/tools/__pycache__/skill_provenance.cpython-312.pyc +0 -0
  1569. package/tools/__pycache__/skill_usage.cpython-312.pyc +0 -0
  1570. package/tools/__pycache__/skills_guard.cpython-312.pyc +0 -0
  1571. package/tools/__pycache__/skills_sync.cpython-312.pyc +0 -0
  1572. package/tools/__pycache__/skills_tool.cpython-312.pyc +0 -0
  1573. package/tools/__pycache__/slash_confirm.cpython-312.pyc +0 -0
  1574. package/tools/__pycache__/terminal_tool.cpython-312.pyc +0 -0
  1575. package/tools/__pycache__/tirith_security.cpython-312.pyc +0 -0
  1576. package/tools/__pycache__/todo_tool.cpython-312.pyc +0 -0
  1577. package/tools/__pycache__/tool_backend_helpers.cpython-312.pyc +0 -0
  1578. package/tools/__pycache__/tool_result_storage.cpython-312.pyc +0 -0
  1579. package/tools/__pycache__/tts_tool.cpython-312.pyc +0 -0
  1580. package/tools/__pycache__/url_safety.cpython-312.pyc +0 -0
  1581. package/tools/__pycache__/video_generation_tool.cpython-312.pyc +0 -0
  1582. package/tools/__pycache__/vision_tools.cpython-312.pyc +0 -0
  1583. package/tools/__pycache__/voice_mode.cpython-312.pyc +0 -0
  1584. package/tools/__pycache__/web_tools.cpython-312.pyc +0 -0
  1585. package/tools/__pycache__/website_policy.cpython-312.pyc +0 -0
  1586. package/tools/__pycache__/x_search_tool.cpython-312.pyc +0 -0
  1587. package/tools/__pycache__/xai_http.cpython-312.pyc +0 -0
  1588. package/tools/__pycache__/yuanbao_tools.cpython-312.pyc +0 -0
  1589. package/tools/ansi_strip.py +44 -0
  1590. package/tools/approval.py +1392 -0
  1591. package/tools/binary_extensions.py +42 -0
  1592. package/tools/browser_camofox.py +700 -0
  1593. package/tools/browser_camofox_state.py +48 -0
  1594. package/tools/browser_cdp_tool.py +569 -0
  1595. package/tools/browser_dialog_tool.py +148 -0
  1596. package/tools/browser_providers/__init__.py +10 -0
  1597. package/tools/browser_providers/__pycache__/__init__.cpython-312.pyc +0 -0
  1598. package/tools/browser_providers/__pycache__/base.cpython-312.pyc +0 -0
  1599. package/tools/browser_providers/__pycache__/browser_use.cpython-312.pyc +0 -0
  1600. package/tools/browser_providers/__pycache__/browserbase.cpython-312.pyc +0 -0
  1601. package/tools/browser_providers/__pycache__/firecrawl.cpython-312.pyc +0 -0
  1602. package/tools/browser_providers/base.py +59 -0
  1603. package/tools/browser_providers/browser_use.py +225 -0
  1604. package/tools/browser_providers/browserbase.py +222 -0
  1605. package/tools/browser_providers/firecrawl.py +112 -0
  1606. package/tools/browser_supervisor.py +1457 -0
  1607. package/tools/browser_tool.py +3676 -0
  1608. package/tools/budget_config.py +51 -0
  1609. package/tools/checkpoint_manager.py +1639 -0
  1610. package/tools/clarify_gateway.py +278 -0
  1611. package/tools/clarify_tool.py +141 -0
  1612. package/tools/code_execution_tool.py +1782 -0
  1613. package/tools/computer_use/__init__.py +43 -0
  1614. package/tools/computer_use/__pycache__/__init__.cpython-312.pyc +0 -0
  1615. package/tools/computer_use/__pycache__/backend.cpython-312.pyc +0 -0
  1616. package/tools/computer_use/__pycache__/schema.cpython-312.pyc +0 -0
  1617. package/tools/computer_use/__pycache__/tool.cpython-312.pyc +0 -0
  1618. package/tools/computer_use/backend.py +150 -0
  1619. package/tools/computer_use/cua_backend.py +682 -0
  1620. package/tools/computer_use/schema.py +191 -0
  1621. package/tools/computer_use/tool.py +521 -0
  1622. package/tools/computer_use_tool.py +39 -0
  1623. package/tools/credential_files.py +437 -0
  1624. package/tools/cronjob_tools.py +719 -0
  1625. package/tools/debug_helpers.py +106 -0
  1626. package/tools/delegate_tool.py +2797 -0
  1627. package/tools/discord_tool.py +959 -0
  1628. package/tools/env_passthrough.py +145 -0
  1629. package/tools/environments/__init__.py +14 -0
  1630. package/tools/environments/__pycache__/__init__.cpython-312.pyc +0 -0
  1631. package/tools/environments/__pycache__/base.cpython-312.pyc +0 -0
  1632. package/tools/environments/__pycache__/docker.cpython-312.pyc +0 -0
  1633. package/tools/environments/__pycache__/file_sync.cpython-312.pyc +0 -0
  1634. package/tools/environments/__pycache__/local.cpython-312.pyc +0 -0
  1635. package/tools/environments/__pycache__/managed_modal.cpython-312.pyc +0 -0
  1636. package/tools/environments/__pycache__/modal.cpython-312.pyc +0 -0
  1637. package/tools/environments/__pycache__/modal_utils.cpython-312.pyc +0 -0
  1638. package/tools/environments/__pycache__/singularity.cpython-312.pyc +0 -0
  1639. package/tools/environments/__pycache__/ssh.cpython-312.pyc +0 -0
  1640. package/tools/environments/base.py +844 -0
  1641. package/tools/environments/daytona.py +270 -0
  1642. package/tools/environments/docker.py +656 -0
  1643. package/tools/environments/file_sync.py +400 -0
  1644. package/tools/environments/local.py +658 -0
  1645. package/tools/environments/managed_modal.py +282 -0
  1646. package/tools/environments/modal.py +479 -0
  1647. package/tools/environments/modal_utils.py +199 -0
  1648. package/tools/environments/singularity.py +263 -0
  1649. package/tools/environments/ssh.py +295 -0
  1650. package/tools/environments/vercel_sandbox.py +655 -0
  1651. package/tools/feishu_doc_tool.py +138 -0
  1652. package/tools/feishu_drive_tool.py +431 -0
  1653. package/tools/file_operations.py +1825 -0
  1654. package/tools/file_state.py +332 -0
  1655. package/tools/file_tools.py +1172 -0
  1656. package/tools/fuzzy_match.py +703 -0
  1657. package/tools/homeassistant_tool.py +513 -0
  1658. package/tools/image_generation_tool.py +1098 -0
  1659. package/tools/interrupt.py +98 -0
  1660. package/tools/kanban_tools.py +1139 -0
  1661. package/tools/lazy_deps.py +608 -0
  1662. package/tools/managed_tool_gateway.py +168 -0
  1663. package/tools/mcp_oauth.py +633 -0
  1664. package/tools/mcp_oauth_manager.py +607 -0
  1665. package/tools/mcp_tool.py +3483 -0
  1666. package/tools/memory_tool.py +584 -0
  1667. package/tools/microsoft_graph_auth.py +245 -0
  1668. package/tools/microsoft_graph_client.py +408 -0
  1669. package/tools/mixture_of_agents_tool.py +542 -0
  1670. package/tools/neutts_samples/jo.txt +1 -0
  1671. package/tools/neutts_samples/jo.wav +0 -0
  1672. package/tools/neutts_synth.py +104 -0
  1673. package/tools/openrouter_client.py +33 -0
  1674. package/tools/osv_check.py +155 -0
  1675. package/tools/patch_parser.py +592 -0
  1676. package/tools/path_security.py +43 -0
  1677. package/tools/process_registry.py +1534 -0
  1678. package/tools/registry.py +589 -0
  1679. package/tools/schema_sanitizer.py +370 -0
  1680. package/tools/send_message_tool.py +1900 -0
  1681. package/tools/session_search_tool.py +613 -0
  1682. package/tools/skill_manager_tool.py +932 -0
  1683. package/tools/skill_provenance.py +78 -0
  1684. package/tools/skill_usage.py +610 -0
  1685. package/tools/skills_guard.py +932 -0
  1686. package/tools/skills_hub.py +3263 -0
  1687. package/tools/skills_sync.py +432 -0
  1688. package/tools/skills_tool.py +1569 -0
  1689. package/tools/slash_confirm.py +167 -0
  1690. package/tools/terminal_tool.py +2376 -0
  1691. package/tools/tirith_security.py +775 -0
  1692. package/tools/todo_tool.py +277 -0
  1693. package/tools/tool_backend_helpers.py +144 -0
  1694. package/tools/tool_output_limits.py +92 -0
  1695. package/tools/tool_result_storage.py +232 -0
  1696. package/tools/transcription_tools.py +936 -0
  1697. package/tools/tts_tool.py +2285 -0
  1698. package/tools/url_safety.py +330 -0
  1699. package/tools/video_generation_tool.py +561 -0
  1700. package/tools/vision_tools.py +1422 -0
  1701. package/tools/voice_mode.py +1019 -0
  1702. package/tools/web_tools.py +1551 -0
  1703. package/tools/website_policy.py +283 -0
  1704. package/tools/x_search_tool.py +424 -0
  1705. package/tools/xai_http.py +83 -0
  1706. package/tools/yuanbao_tools.py +736 -0
  1707. package/toolset_distributions.py +364 -0
  1708. package/toolsets.py +866 -0
  1709. package/trajectory_compressor.py +1509 -0
  1710. package/tui_gateway/__init__.py +0 -0
  1711. package/tui_gateway/entry.py +251 -0
  1712. package/tui_gateway/event_publisher.py +126 -0
  1713. package/tui_gateway/render.py +49 -0
  1714. package/tui_gateway/server.py +6623 -0
  1715. package/tui_gateway/slash_worker.py +76 -0
  1716. package/tui_gateway/transport.py +219 -0
  1717. package/tui_gateway/ws.py +178 -0
  1718. package/utils.py +361 -0
@@ -0,0 +1,4840 @@
1
+ """SQLite-backed Kanban board for multi-profile, multi-project collaboration.
2
+
3
+ In a fresh install the board lives at ``<root>/kanban.db`` where
4
+ ``<root>`` is the **shared Hermes root** (the parent of any active
5
+ profile). Profiles intentionally collapse onto a shared board: it IS
6
+ the cross-profile coordination primitive. A worker spawned with
7
+ ``hermes -p <profile>`` joins the same board as the dispatcher that
8
+ claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
9
+ ``<root>/kanban/logs/``.
10
+
11
+ **Multiple boards (projects):** users can create additional boards to
12
+ separate unrelated streams of work (e.g. one per project / repo / domain).
13
+ Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
14
+ its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
15
+ the profile's Hermes home but are otherwise isolated: a worker spawned
16
+ for a task on board ``atm10-server`` sees only that board's tasks,
17
+ cannot enumerate other boards, and its dispatcher ticks don't touch
18
+ other boards' DBs.
19
+
20
+ The first (and for single-project users, only) board is ``default``.
21
+ For back-compat its on-disk DB is ``<root>/kanban.db`` (not
22
+ ``boards/default/kanban.db``), so installs that predate the boards
23
+ feature keep working with zero migration. See :func:`kanban_db_path`.
24
+
25
+ Board resolution order (highest precedence first, all optional):
26
+
27
+ * ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
28
+ (explicit — used by the CLI ``--board`` flag and the dashboard
29
+ ``?board=...`` query param).
30
+ * ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
31
+ to the board their task lives on — workers cannot see other boards).
32
+ * ``HERMES_KANBAN_DB`` env var (pins the DB file path directly — legacy
33
+ override still honoured; highest precedence when the file path itself
34
+ is what the caller wants to force).
35
+ * ``<root>/kanban/current`` — a one-line text file holding the slug of
36
+ the "currently selected" board. Written by ``hermes kanban boards
37
+ switch <slug>``. When absent, the active board is ``default``.
38
+
39
+ In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
40
+ deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
41
+ ``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
42
+ overrides still work:
43
+
44
+ * ``HERMES_KANBAN_DB`` — pin the database file path directly.
45
+ * ``HERMES_KANBAN_WORKSPACES_ROOT`` — pin the workspaces root directly.
46
+ * ``HERMES_KANBAN_HOME`` — pin the umbrella root that anchors kanban
47
+ paths. Useful for tests and unusual deployments.
48
+
49
+ The dispatcher injects ``HERMES_KANBAN_DB``,
50
+ ``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
51
+ worker subprocess env so workers converge on the exact DB the
52
+ dispatcher used to claim their task — even under unusual symlink or
53
+ Docker layouts.
54
+
55
+ Schema is intentionally small: tasks, task_links, task_comments,
56
+ task_events. The ``workspace_kind`` field decouples coordination from git
57
+ worktrees so that research / ops / digital-twin workloads work alongside
58
+ coding workloads. See ``docs/hermes-kanban-v1-spec.pdf`` for the full
59
+ design specification.
60
+
61
+ Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write
62
+ transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
63
+ ``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
64
+ most one claimer can win any given task. Losers observe zero affected
65
+ rows and move on -- no retry loops, no distributed-lock machinery.
66
+ The CAS coordination is **per-board** — each board is a separate DB,
67
+ so multi-board installs get the same atomicity guarantees without any
68
+ new locking.
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import contextlib
74
+ import json
75
+ import os
76
+ import re
77
+ import secrets
78
+ import sqlite3
79
+ import subprocess
80
+ import sys
81
+ import time
82
+ from dataclasses import dataclass, field
83
+ from pathlib import Path
84
+ from typing import Any, Iterable, Optional
85
+
86
+ from toolsets import get_toolset_names
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Constants
91
+ # ---------------------------------------------------------------------------
92
+
93
+ VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"}
94
+ VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
95
+ KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
96
+
97
+ # A running task's claim is valid for 15 minutes; after that the next
98
+ # dispatcher tick reclaims it. Workers that outlive this window should call
99
+ # ``heartbeat_claim(task_id)`` periodically. In practice most kanban
100
+ # workloads either finish within 15m or set a longer claim explicitly.
101
+ DEFAULT_CLAIM_TTL_SECONDS = 15 * 60
102
+
103
+
104
+ # Worker-context caps so build_worker_context() stays bounded on
105
+ # pathological boards (retry-heavy tasks, comment storms, giant
106
+ # summaries). Values chosen to fit a typical 100k-char LLM prompt with
107
+ # plenty of headroom. Each constant is tuned independently so users
108
+ # who need to relax one don't have to relax all of them.
109
+ _CTX_MAX_PRIOR_ATTEMPTS = 10 # most recent N prior runs shown in full
110
+ _CTX_MAX_COMMENTS = 30 # most recent N comments shown in full
111
+ _CTX_MAX_FIELD_BYTES = 4 * 1024 # 4 KB per summary/error/metadata/result
112
+ _CTX_MAX_BODY_BYTES = 8 * 1024 # 8 KB per task.body (opening post)
113
+ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Paths
118
+ # ---------------------------------------------------------------------------
119
+
120
+ DEFAULT_BOARD = "default"
121
+
122
+ # Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars.
123
+ # Strict enough to stop traversal (`..`) and embedded path separators, loose
124
+ # enough that kebab-case names like ``atm10-server`` or ``hermes-agent``
125
+ # pass without fuss. Board names with display formatting (spaces, emoji)
126
+ # live in ``board.json``; the slug is just the directory name.
127
+ _BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
128
+
129
+
130
+ def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
131
+ """Lowercase + strip a slug; validate; return ``None`` for empty."""
132
+ if slug is None:
133
+ return None
134
+ s = str(slug).strip().lower()
135
+ if not s:
136
+ return None
137
+ if not _BOARD_SLUG_RE.match(s):
138
+ raise ValueError(
139
+ f"invalid board slug {slug!r}: must be 1-64 chars, lowercase "
140
+ f"alphanumerics / hyphens / underscores, not starting with '-' or '_'"
141
+ )
142
+ return s
143
+
144
+
145
+ def kanban_home() -> Path:
146
+ """Return the shared Hermes root that anchors the kanban board.
147
+
148
+ Resolution order:
149
+
150
+ 1. ``HERMES_KANBAN_HOME`` env var when set and non-empty (explicit
151
+ override for tests and unusual deployments).
152
+ 2. ``get_default_hermes_root()``, which already returns ``<root>``
153
+ when ``HERMES_HOME`` is ``<root>/profiles/<name>``, and returns
154
+ ``HERMES_HOME`` directly for Docker / custom deployments.
155
+
156
+ The kanban board is shared across profiles **by design** (see the
157
+ module docstring). Resolving the kanban paths through the active
158
+ profile's ``HERMES_HOME`` would silently fork the board per profile,
159
+ which breaks the dispatcher / worker handoff.
160
+ """
161
+ override = os.environ.get("HERMES_KANBAN_HOME", "").strip()
162
+ if override:
163
+ return Path(override).expanduser()
164
+ from calvyn_constants import get_default_hermes_root
165
+ return get_default_hermes_root()
166
+
167
+
168
+ def boards_root() -> Path:
169
+ """Return ``<root>/kanban/boards`` — the parent of non-default board dirs.
170
+
171
+ ``default`` is intentionally NOT under this directory — its DB lives at
172
+ ``<root>/kanban.db`` for back-compat with pre-boards installs. This
173
+ function returns the directory where *additional* named boards live,
174
+ used by :func:`list_boards` to enumerate them.
175
+ """
176
+ return kanban_home() / "kanban" / "boards"
177
+
178
+
179
+ def current_board_path() -> Path:
180
+ """Return the path to ``<root>/kanban/current``.
181
+
182
+ One-line text file written by ``hermes kanban boards switch <slug>``
183
+ to persist the user's board selection across CLI invocations. Absent
184
+ by default (meaning: active board is ``default``).
185
+ """
186
+ return kanban_home() / "kanban" / "current"
187
+
188
+
189
+ def get_current_board() -> str:
190
+ """Return the active board slug, honouring the resolution chain.
191
+
192
+ Order (highest precedence first):
193
+
194
+ 1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
195
+ spawn, or manually for ad-hoc overrides).
196
+ 2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
197
+ switch``), but only when that board still exists.
198
+ 3. ``DEFAULT_BOARD`` (``"default"``).
199
+
200
+ A malformed or stale slug at any step falls through to the next layer
201
+ with a best-effort warning — the dispatcher must never crash because a
202
+ user hand-edited a file or removed a board directory.
203
+ """
204
+ env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
205
+ if env:
206
+ try:
207
+ normed = _normalize_board_slug(env)
208
+ if normed:
209
+ return normed
210
+ except ValueError:
211
+ pass
212
+ try:
213
+ f = current_board_path()
214
+ if f.exists():
215
+ val = f.read_text(encoding="utf-8").strip()
216
+ if val:
217
+ try:
218
+ normed = _normalize_board_slug(val)
219
+ if normed and board_exists(normed):
220
+ return normed
221
+ except ValueError:
222
+ pass
223
+ except OSError:
224
+ pass
225
+ return DEFAULT_BOARD
226
+
227
+
228
+ def set_current_board(slug: str) -> Path:
229
+ """Persist ``slug`` as the active board. Returns the file written.
230
+
231
+ Writes ``<root>/kanban/current``. The caller should validate the slug
232
+ exists first (via :func:`board_exists`) — this function does not —
233
+ so that ``hermes kanban boards switch <typo>`` returns an error
234
+ instead of silently pointing at nothing.
235
+ """
236
+ normed = _normalize_board_slug(slug)
237
+ if not normed:
238
+ raise ValueError("board slug is required")
239
+ path = current_board_path()
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ path.write_text(normed + "\n", encoding="utf-8")
242
+ return path
243
+
244
+
245
+ def clear_current_board() -> None:
246
+ """Remove ``<root>/kanban/current`` so the active board reverts to ``default``."""
247
+ try:
248
+ current_board_path().unlink()
249
+ except FileNotFoundError:
250
+ pass
251
+
252
+
253
+ def board_dir(board: Optional[str] = None) -> Path:
254
+ """Return the on-disk directory for ``board``.
255
+
256
+ ``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
257
+ (board.json + workspaces/ + logs/). Its DB file stays at
258
+ ``<root>/kanban.db`` for back-compat — see :func:`kanban_db_path`.
259
+
260
+ All other boards live at ``<root>/kanban/boards/<slug>/`` with
261
+ everything inside that directory including the ``kanban.db``.
262
+ """
263
+ slug = _normalize_board_slug(board) or DEFAULT_BOARD
264
+ return boards_root() / slug
265
+
266
+
267
+ def board_exists(board: Optional[str] = None) -> bool:
268
+ """Return True if the board has a DB or a metadata dir on disk.
269
+
270
+ ``default`` is considered to always exist — its DB is created
271
+ on first :func:`connect` and there's no way for it to be missing
272
+ in a configuration where the kanban feature is usable at all.
273
+ """
274
+ slug = _normalize_board_slug(board) or DEFAULT_BOARD
275
+ if slug == DEFAULT_BOARD:
276
+ return True
277
+ d = board_dir(slug)
278
+ return d.is_dir() or (d / "kanban.db").exists()
279
+
280
+
281
+ def kanban_db_path(board: Optional[str] = None) -> Path:
282
+ """Return the path to the ``kanban.db`` for ``board``.
283
+
284
+ Resolution (highest precedence first):
285
+
286
+ 1. ``HERMES_KANBAN_DB`` env var — pins the path directly. Honoured for
287
+ back-compat and for the dispatcher→worker handoff (defense in
288
+ depth: dispatcher injects this into worker env so workers are
289
+ immune to any path-resolution disagreement).
290
+ 2. When ``board`` arg is None, the active board from
291
+ :func:`get_current_board` is used.
292
+ 3. Board ``default`` → ``<root>/kanban.db`` (back-compat path).
293
+ Other boards → ``<root>/kanban/boards/<slug>/kanban.db``.
294
+ """
295
+ override = os.environ.get("HERMES_KANBAN_DB", "").strip()
296
+ if override:
297
+ return Path(override).expanduser()
298
+ slug = _normalize_board_slug(board)
299
+ if slug is None:
300
+ slug = get_current_board()
301
+ if slug == DEFAULT_BOARD:
302
+ return kanban_home() / "kanban.db"
303
+ return board_dir(slug) / "kanban.db"
304
+
305
+
306
+ def workspaces_root(board: Optional[str] = None) -> Path:
307
+ """Return the directory under which ``scratch`` workspaces are created.
308
+
309
+ Anchored per-board so workspaces don't leak between projects.
310
+ ``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
311
+ precedence) — the dispatcher injects this into worker env.
312
+
313
+ ``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
314
+ that existing scratch workspaces from before the boards feature are
315
+ preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
316
+ """
317
+ override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
318
+ if override:
319
+ return Path(override).expanduser()
320
+ slug = _normalize_board_slug(board)
321
+ if slug is None:
322
+ slug = get_current_board()
323
+ if slug == DEFAULT_BOARD:
324
+ return kanban_home() / "kanban" / "workspaces"
325
+ return board_dir(slug) / "workspaces"
326
+
327
+
328
+ def worker_logs_dir(board: Optional[str] = None) -> Path:
329
+ """Return the directory under which per-task worker logs are written.
330
+
331
+ ``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
332
+ boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
333
+ board — makes ``hermes kanban log`` unambiguous even when multiple
334
+ boards have tasks with the same id.
335
+ """
336
+ slug = _normalize_board_slug(board)
337
+ if slug is None:
338
+ slug = get_current_board()
339
+ if slug == DEFAULT_BOARD:
340
+ return kanban_home() / "kanban" / "logs"
341
+ return board_dir(slug) / "logs"
342
+
343
+
344
+ def board_metadata_path(board: Optional[str] = None) -> Path:
345
+ """Return the path to ``board.json`` for ``board``.
346
+
347
+ Stores display metadata (display name, description, icon, color,
348
+ created_at). The on-disk slug is the canonical identity; this file
349
+ is purely for presentation in the CLI / dashboard.
350
+ """
351
+ slug = _normalize_board_slug(board) or DEFAULT_BOARD
352
+ return board_dir(slug) / "board.json"
353
+
354
+
355
+ def _default_board_display_name(slug: str) -> str:
356
+ """Turn a slug into a reasonable default display name.
357
+
358
+ ``atm10-server`` → ``Atm10 Server``. Users can override via
359
+ ``board.json`` but the default should look presentable in the
360
+ dashboard without any follow-up editing.
361
+ """
362
+ return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug
363
+
364
+
365
+ def read_board_metadata(board: Optional[str] = None) -> dict:
366
+ """Return ``board.json`` contents (or synthesized defaults).
367
+
368
+ Never raises — a missing / malformed ``board.json`` falls back to a
369
+ synthesised entry so the dashboard always has something to render.
370
+ Includes the canonical ``slug`` and ``db_path`` so the caller
371
+ doesn't need to reconstruct them.
372
+ """
373
+ slug = _normalize_board_slug(board) or DEFAULT_BOARD
374
+ meta: dict[str, Any] = {
375
+ "slug": slug,
376
+ "name": _default_board_display_name(slug),
377
+ "description": "",
378
+ "icon": "",
379
+ "color": "",
380
+ "created_at": None,
381
+ "archived": False,
382
+ }
383
+ try:
384
+ p = board_metadata_path(slug)
385
+ if p.exists():
386
+ raw = json.loads(p.read_text(encoding="utf-8"))
387
+ if isinstance(raw, dict):
388
+ # Never let the metadata file claim a different slug than
389
+ # its directory — trust the filesystem.
390
+ raw["slug"] = slug
391
+ meta.update(raw)
392
+ except (OSError, json.JSONDecodeError):
393
+ pass
394
+ meta["db_path"] = str(kanban_db_path(slug))
395
+ return meta
396
+
397
+
398
+ def write_board_metadata(
399
+ board: Optional[str],
400
+ *,
401
+ name: Optional[str] = None,
402
+ description: Optional[str] = None,
403
+ icon: Optional[str] = None,
404
+ color: Optional[str] = None,
405
+ archived: Optional[bool] = None,
406
+ ) -> dict:
407
+ """Create / update ``board.json`` for ``board``.
408
+
409
+ Preserves any existing fields not mentioned in the call. Sets
410
+ ``created_at`` on first write. Returns the resulting metadata dict.
411
+ """
412
+ slug = _normalize_board_slug(board) or DEFAULT_BOARD
413
+ meta = read_board_metadata(slug)
414
+ # Preserve existing DB-derived fields — they get re-computed each
415
+ # read but shouldn't be written into board.json.
416
+ meta.pop("db_path", None)
417
+ if name is not None:
418
+ meta["name"] = str(name).strip() or _default_board_display_name(slug)
419
+ if description is not None:
420
+ meta["description"] = str(description)
421
+ if icon is not None:
422
+ meta["icon"] = str(icon)
423
+ if color is not None:
424
+ meta["color"] = str(color)
425
+ if archived is not None:
426
+ meta["archived"] = bool(archived)
427
+ if not meta.get("created_at"):
428
+ meta["created_at"] = int(time.time())
429
+ path = board_metadata_path(slug)
430
+ path.parent.mkdir(parents=True, exist_ok=True)
431
+ path.write_text(
432
+ json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
433
+ encoding="utf-8",
434
+ )
435
+ meta["db_path"] = str(kanban_db_path(slug))
436
+ return meta
437
+
438
+
439
+ def create_board(
440
+ slug: str,
441
+ *,
442
+ name: Optional[str] = None,
443
+ description: Optional[str] = None,
444
+ icon: Optional[str] = None,
445
+ color: Optional[str] = None,
446
+ ) -> dict:
447
+ """Create a new board directory + DB + metadata. Idempotent.
448
+
449
+ Returns the resulting metadata. Raises :class:`ValueError` for a
450
+ malformed slug; returns the existing metadata (not an error) if the
451
+ board already exists — matching ``mkdir -p`` semantics.
452
+ """
453
+ normed = _normalize_board_slug(slug)
454
+ if not normed:
455
+ raise ValueError("board slug is required")
456
+ meta = write_board_metadata(
457
+ normed,
458
+ name=name,
459
+ description=description,
460
+ icon=icon,
461
+ color=color,
462
+ )
463
+ # Touch the DB so list_boards() sees it immediately.
464
+ init_db(board=normed)
465
+ return meta
466
+
467
+
468
+ def list_boards(*, include_archived: bool = True) -> list[dict]:
469
+ """Enumerate all boards that exist on disk.
470
+
471
+ Always includes ``default`` (even when the ``boards/default/``
472
+ metadata dir doesn't exist, because its DB is at the legacy path).
473
+ Other boards are discovered by scanning ``boards/`` for subdirectories
474
+ that either contain a ``kanban.db`` or a ``board.json``.
475
+
476
+ Returns a list of metadata dicts, sorted with ``default`` first and
477
+ the rest alphabetically.
478
+ """
479
+ entries: list[dict] = []
480
+ seen: set[str] = set()
481
+
482
+ # Default board is always first.
483
+ entries.append(read_board_metadata(DEFAULT_BOARD))
484
+ seen.add(DEFAULT_BOARD)
485
+
486
+ root = boards_root()
487
+ if root.is_dir():
488
+ for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
489
+ if not child.is_dir():
490
+ continue
491
+ slug = child.name
492
+ # Keep slug normalisation soft for discovery — but skip dirs
493
+ # that don't parse as valid slugs so we don't surface junk.
494
+ try:
495
+ normed = _normalize_board_slug(slug)
496
+ except ValueError:
497
+ continue
498
+ if not normed or normed in seen:
499
+ continue
500
+ has_db = (child / "kanban.db").exists()
501
+ has_meta = (child / "board.json").exists()
502
+ if not (has_db or has_meta):
503
+ continue
504
+ meta = read_board_metadata(normed)
505
+ if meta.get("archived") and not include_archived:
506
+ continue
507
+ entries.append(meta)
508
+ seen.add(normed)
509
+ return entries
510
+
511
+
512
+ def remove_board(slug: str, *, archive: bool = True) -> dict:
513
+ """Remove or archive a board.
514
+
515
+ ``archive=True`` (default) moves the board's directory to
516
+ ``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
517
+ is recoverable. ``archive=False`` deletes the directory outright.
518
+
519
+ The ``default`` board cannot be removed — raises :class:`ValueError`.
520
+ Returns a summary dict describing what happened (``{"slug", "action",
521
+ "new_path"}``).
522
+ """
523
+ normed = _normalize_board_slug(slug)
524
+ if not normed:
525
+ raise ValueError("board slug is required")
526
+ if normed == DEFAULT_BOARD:
527
+ raise ValueError("the 'default' board cannot be removed")
528
+ d = board_dir(normed)
529
+ if not d.exists():
530
+ raise ValueError(f"board {normed!r} does not exist")
531
+
532
+ # If the user removed the currently-active board, revert to default.
533
+ if get_current_board() == normed:
534
+ clear_current_board()
535
+
536
+ if archive:
537
+ archive_root = boards_root() / "_archived"
538
+ archive_root.mkdir(parents=True, exist_ok=True)
539
+ ts = int(time.time())
540
+ target = archive_root / f"{normed}-{ts}"
541
+ # Avoid collision on rapid double-archives.
542
+ suffix = 1
543
+ while target.exists():
544
+ target = archive_root / f"{normed}-{ts}-{suffix}"
545
+ suffix += 1
546
+ d.rename(target)
547
+ return {"slug": normed, "action": "archived", "new_path": str(target)}
548
+ else:
549
+ import shutil
550
+ shutil.rmtree(d)
551
+ return {"slug": normed, "action": "deleted", "new_path": ""}
552
+
553
+
554
+ # ---------------------------------------------------------------------------
555
+ # Data classes
556
+ # ---------------------------------------------------------------------------
557
+
558
+ @dataclass
559
+ class Task:
560
+ """In-memory view of a row from the ``tasks`` table."""
561
+
562
+ id: str
563
+ title: str
564
+ body: Optional[str]
565
+ assignee: Optional[str]
566
+ status: str
567
+ priority: int
568
+ created_by: Optional[str]
569
+ created_at: int
570
+ started_at: Optional[int]
571
+ completed_at: Optional[int]
572
+ workspace_kind: str
573
+ workspace_path: Optional[str]
574
+ claim_lock: Optional[str]
575
+ claim_expires: Optional[int]
576
+ tenant: Optional[str]
577
+ result: Optional[str] = None
578
+ idempotency_key: Optional[str] = None
579
+ # Unified non-success counter. Incremented on any of:
580
+ # * spawn failure (dispatcher couldn't launch the worker)
581
+ # * timed_out outcome (worker exceeded max_runtime_seconds)
582
+ # * crashed outcome (worker PID vanished)
583
+ # Reset to 0 only on a successful completion. See
584
+ # ``_record_task_failure`` for the circuit-breaker trip rule.
585
+ # (Pre-rename column: ``spawn_failures``.)
586
+ consecutive_failures: int = 0
587
+ worker_pid: Optional[int] = None
588
+ # Short excerpt of the last failure's error text (any outcome, not
589
+ # just spawn). Pre-rename column: ``last_spawn_error``.
590
+ last_failure_error: Optional[str] = None
591
+ max_runtime_seconds: Optional[int] = None
592
+ last_heartbeat_at: Optional[int] = None
593
+ current_run_id: Optional[int] = None
594
+ workflow_template_id: Optional[str] = None
595
+ current_step_key: Optional[str] = None
596
+ # Force-loaded skills for the worker on this task (appended to the
597
+ # dispatcher's built-in `kanban-worker` via --skills). Stored as a
598
+ # JSON array of skill names. None = use only the defaults; empty
599
+ # list = explicitly no extra skills.
600
+ skills: Optional[list] = None
601
+ # Per-task override for the consecutive-failure circuit breaker.
602
+ # The value is the failure count at which the breaker trips — e.g.
603
+ # ``max_retries=1`` blocks on the first failure (zero retries),
604
+ # ``max_retries=3`` blocks on the third (two retries allowed).
605
+ # ``None`` (the common case) falls through to the dispatcher-level
606
+ # ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
607
+ # Name matches the ``--max-retries`` CLI flag on ``kanban create``.
608
+ max_retries: Optional[int] = None
609
+
610
+ @classmethod
611
+ def from_row(cls, row: sqlite3.Row) -> "Task":
612
+ keys = set(row.keys())
613
+ # Parse skills JSON blob if present
614
+ skills_value: Optional[list] = None
615
+ if "skills" in keys and row["skills"]:
616
+ try:
617
+ parsed = json.loads(row["skills"])
618
+ if isinstance(parsed, list):
619
+ skills_value = [str(s) for s in parsed if s]
620
+ except Exception:
621
+ skills_value = None
622
+ return cls(
623
+ id=row["id"],
624
+ title=row["title"],
625
+ body=row["body"],
626
+ assignee=row["assignee"],
627
+ status=row["status"],
628
+ priority=row["priority"],
629
+ created_by=row["created_by"],
630
+ created_at=row["created_at"],
631
+ started_at=row["started_at"],
632
+ completed_at=row["completed_at"],
633
+ workspace_kind=row["workspace_kind"],
634
+ workspace_path=row["workspace_path"],
635
+ claim_lock=row["claim_lock"],
636
+ claim_expires=row["claim_expires"],
637
+ tenant=row["tenant"] if "tenant" in keys else None,
638
+ result=row["result"] if "result" in keys else None,
639
+ idempotency_key=row["idempotency_key"] if "idempotency_key" in keys else None,
640
+ consecutive_failures=(
641
+ row["consecutive_failures"] if "consecutive_failures" in keys
642
+ # Pre-migration fallback: ``_migrate_add_optional_columns`` always
643
+ # adds ``consecutive_failures`` now, so this branch is only reachable
644
+ # on a DB that was never opened since pre-#20410 code ran. Keep for
645
+ # belt-and-suspenders safety; in practice it is dead code post-migration.
646
+ else (row["spawn_failures"] if "spawn_failures" in keys else 0)
647
+ ),
648
+ worker_pid=row["worker_pid"] if "worker_pid" in keys else None,
649
+ last_failure_error=(
650
+ row["last_failure_error"] if "last_failure_error" in keys
651
+ # Same belt-and-suspenders fallback as consecutive_failures above.
652
+ else (row["last_spawn_error"] if "last_spawn_error" in keys else None)
653
+ ),
654
+ max_runtime_seconds=(
655
+ row["max_runtime_seconds"] if "max_runtime_seconds" in keys else None
656
+ ),
657
+ last_heartbeat_at=(
658
+ row["last_heartbeat_at"] if "last_heartbeat_at" in keys else None
659
+ ),
660
+ current_run_id=(
661
+ row["current_run_id"] if "current_run_id" in keys else None
662
+ ),
663
+ workflow_template_id=(
664
+ row["workflow_template_id"] if "workflow_template_id" in keys else None
665
+ ),
666
+ current_step_key=(
667
+ row["current_step_key"] if "current_step_key" in keys else None
668
+ ),
669
+ skills=skills_value,
670
+ max_retries=(
671
+ row["max_retries"] if "max_retries" in keys else None
672
+ ),
673
+ )
674
+
675
+
676
+ @dataclass
677
+ class Run:
678
+ """In-memory view of a ``task_runs`` row.
679
+
680
+ A run is one attempt to execute a task — created on claim, closed
681
+ on complete/block/crash/timeout/spawn_failure/reclaim. Multiple runs
682
+ per task when retries happen. Carries the claim machinery, PID,
683
+ heartbeat, and the structured handoff summary that downstream workers
684
+ read via ``build_worker_context``.
685
+ """
686
+
687
+ id: int
688
+ task_id: str
689
+ profile: Optional[str]
690
+ step_key: Optional[str]
691
+ status: str
692
+ claim_lock: Optional[str]
693
+ claim_expires: Optional[int]
694
+ worker_pid: Optional[int]
695
+ max_runtime_seconds: Optional[int]
696
+ last_heartbeat_at: Optional[int]
697
+ started_at: int
698
+ ended_at: Optional[int]
699
+ outcome: Optional[str]
700
+ summary: Optional[str]
701
+ metadata: Optional[dict]
702
+ error: Optional[str]
703
+
704
+ @classmethod
705
+ def from_row(cls, row: sqlite3.Row) -> "Run":
706
+ try:
707
+ meta = json.loads(row["metadata"]) if row["metadata"] else None
708
+ except Exception:
709
+ meta = None
710
+ return cls(
711
+ id=int(row["id"]),
712
+ task_id=row["task_id"],
713
+ profile=row["profile"],
714
+ step_key=row["step_key"],
715
+ status=row["status"],
716
+ claim_lock=row["claim_lock"],
717
+ claim_expires=row["claim_expires"],
718
+ worker_pid=row["worker_pid"],
719
+ max_runtime_seconds=row["max_runtime_seconds"],
720
+ last_heartbeat_at=row["last_heartbeat_at"],
721
+ started_at=int(row["started_at"]),
722
+ ended_at=(int(row["ended_at"]) if row["ended_at"] is not None else None),
723
+ outcome=row["outcome"],
724
+ summary=row["summary"],
725
+ metadata=meta,
726
+ error=row["error"],
727
+ )
728
+
729
+
730
+ @dataclass
731
+ class Comment:
732
+ id: int
733
+ task_id: str
734
+ author: str
735
+ body: str
736
+ created_at: int
737
+
738
+
739
+ @dataclass
740
+ class Event:
741
+ id: int
742
+ task_id: str
743
+ kind: str
744
+ payload: Optional[dict]
745
+ created_at: int
746
+ run_id: Optional[int] = None
747
+
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Schema
751
+ # ---------------------------------------------------------------------------
752
+
753
+ SCHEMA_SQL = """
754
+ CREATE TABLE IF NOT EXISTS tasks (
755
+ id TEXT PRIMARY KEY,
756
+ title TEXT NOT NULL,
757
+ body TEXT,
758
+ assignee TEXT,
759
+ status TEXT NOT NULL,
760
+ priority INTEGER DEFAULT 0,
761
+ created_by TEXT,
762
+ created_at INTEGER NOT NULL,
763
+ started_at INTEGER,
764
+ completed_at INTEGER,
765
+ workspace_kind TEXT NOT NULL DEFAULT 'scratch',
766
+ workspace_path TEXT,
767
+ claim_lock TEXT,
768
+ claim_expires INTEGER,
769
+ tenant TEXT,
770
+ result TEXT,
771
+ idempotency_key TEXT,
772
+ -- Unified consecutive-failure counter. Incremented on spawn
773
+ -- failure, timeout, or crash; reset only on successful completion.
774
+ -- The circuit breaker in _record_task_failure trips when this
775
+ -- exceeds DEFAULT_FAILURE_LIMIT consecutive non-successes.
776
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
777
+ worker_pid INTEGER,
778
+ -- Short excerpt of the most recent failure's error text.
779
+ last_failure_error TEXT,
780
+ max_runtime_seconds INTEGER,
781
+ last_heartbeat_at INTEGER,
782
+ -- Pointer into task_runs for the currently-active run (NULL if no
783
+ -- run is in-flight). Denormalised for cheap reads.
784
+ current_run_id INTEGER,
785
+ -- Forward-compat for v2 workflow routing. In v1 the kernel writes
786
+ -- these when the task is opted into a template but otherwise ignores
787
+ -- them; the dispatcher doesn't consult them for routing yet.
788
+ workflow_template_id TEXT,
789
+ current_step_key TEXT,
790
+ -- Force-loaded skills for the worker on this task, stored as JSON.
791
+ -- Appended to the dispatcher's built-in `--skills kanban-worker`.
792
+ -- NULL or empty array = no extras.
793
+ skills TEXT,
794
+ -- Per-task override for the consecutive-failure circuit breaker.
795
+ -- The value is the failure count at which the breaker trips — e.g.
796
+ -- ``max_retries=1`` blocks on the first failure. NULL (the common
797
+ -- case) falls through to the dispatcher-level ``kanban.failure_limit``
798
+ -- config and then ``DEFAULT_FAILURE_LIMIT``.
799
+ max_retries INTEGER
800
+ );
801
+
802
+ CREATE TABLE IF NOT EXISTS task_links (
803
+ parent_id TEXT NOT NULL,
804
+ child_id TEXT NOT NULL,
805
+ PRIMARY KEY (parent_id, child_id)
806
+ );
807
+
808
+ CREATE TABLE IF NOT EXISTS task_comments (
809
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
810
+ task_id TEXT NOT NULL,
811
+ author TEXT NOT NULL,
812
+ body TEXT NOT NULL,
813
+ created_at INTEGER NOT NULL
814
+ );
815
+
816
+ CREATE TABLE IF NOT EXISTS task_events (
817
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
818
+ task_id TEXT NOT NULL,
819
+ run_id INTEGER,
820
+ kind TEXT NOT NULL,
821
+ payload TEXT,
822
+ created_at INTEGER NOT NULL
823
+ );
824
+
825
+ -- Historical attempt record. Each time the dispatcher claims a task, a
826
+ -- new row is created here; claim state, PID, heartbeat, runtime cap,
827
+ -- and structured summary all live on the run, not the task. Multiple
828
+ -- rows per task id when the task was retried after crash/timeout/block.
829
+ -- v2 of the kanban schema will use ``step_key`` to drive per-stage
830
+ -- workflow routing; in v1 the column is nullable and unused (kernel
831
+ -- ignores it).
832
+ CREATE TABLE IF NOT EXISTS task_runs (
833
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
834
+ task_id TEXT NOT NULL,
835
+ profile TEXT,
836
+ step_key TEXT,
837
+ status TEXT NOT NULL,
838
+ -- status: running | done | blocked | crashed | timed_out | failed | released
839
+ claim_lock TEXT,
840
+ claim_expires INTEGER,
841
+ worker_pid INTEGER,
842
+ max_runtime_seconds INTEGER,
843
+ last_heartbeat_at INTEGER,
844
+ started_at INTEGER NOT NULL,
845
+ ended_at INTEGER,
846
+ outcome TEXT,
847
+ -- outcome: completed | blocked | crashed | timed_out | spawn_failed |
848
+ -- gave_up | reclaimed | (null while still running)
849
+ summary TEXT,
850
+ metadata TEXT,
851
+ error TEXT
852
+ );
853
+
854
+ -- Subscription from a gateway source (platform + chat + thread) to a
855
+ -- task. The gateway's kanban-notifier watcher tails task_events and
856
+ -- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
857
+ -- the original requester so human-in-the-loop workflows close the loop.
858
+ CREATE TABLE IF NOT EXISTS kanban_notify_subs (
859
+ task_id TEXT NOT NULL,
860
+ platform TEXT NOT NULL,
861
+ chat_id TEXT NOT NULL,
862
+ thread_id TEXT NOT NULL DEFAULT '',
863
+ user_id TEXT,
864
+ notifier_profile TEXT,
865
+ created_at INTEGER NOT NULL,
866
+ last_event_id INTEGER NOT NULL DEFAULT 0,
867
+ PRIMARY KEY (task_id, platform, chat_id, thread_id)
868
+ );
869
+
870
+ CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status);
871
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
872
+ CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant);
873
+ CREATE INDEX IF NOT EXISTS idx_tasks_idempotency ON tasks(idempotency_key);
874
+ CREATE INDEX IF NOT EXISTS idx_links_child ON task_links(child_id);
875
+ CREATE INDEX IF NOT EXISTS idx_links_parent ON task_links(parent_id);
876
+ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, created_at);
877
+ CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
878
+ CREATE INDEX IF NOT EXISTS idx_events_run ON task_events(run_id, id);
879
+ CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
880
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
881
+ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
882
+ """
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # Connection helpers
887
+ # ---------------------------------------------------------------------------
888
+
889
+ _INITIALIZED_PATHS: set[str] = set()
890
+
891
+
892
+ def connect(
893
+ db_path: Optional[Path] = None,
894
+ *,
895
+ board: Optional[str] = None,
896
+ ) -> sqlite3.Connection:
897
+ """Open (and initialize if needed) the kanban DB.
898
+
899
+ WAL mode is enabled on every connection; it's a no-op after the first
900
+ time but keeps the code robust if the DB file is ever re-created.
901
+
902
+ The first connection to a given path auto-runs :func:`init_db` so
903
+ fresh installs and test harnesses that construct `connect()`
904
+ directly don't have to remember a separate init step. Subsequent
905
+ connections skip the schema check via a module-level path cache.
906
+
907
+ Path resolution:
908
+
909
+ * ``db_path`` explicit → used as-is (legacy callers, tests).
910
+ * ``board`` explicit → resolves to that board's DB.
911
+ * Neither → :func:`kanban_db_path` resolves via
912
+ ``HERMES_KANBAN_DB`` env → ``HERMES_KANBAN_BOARD`` env →
913
+ ``<root>/kanban/current`` → ``default``.
914
+ """
915
+ if db_path is not None:
916
+ path = db_path
917
+ else:
918
+ path = kanban_db_path(board=board)
919
+ path.parent.mkdir(parents=True, exist_ok=True)
920
+ resolved = str(path.resolve())
921
+ needs_init = resolved not in _INITIALIZED_PATHS
922
+ conn = sqlite3.connect(str(path), isolation_level=None, timeout=30)
923
+ conn.row_factory = sqlite3.Row
924
+ # WAL doesn't work on network filesystems (NFS/SMB/FUSE). Shared helper
925
+ # falls back to DELETE with one WARNING so kanban stays usable there.
926
+ # See calvyn_state._WAL_INCOMPAT_MARKERS for detection logic.
927
+ from calvyn_state import apply_wal_with_fallback
928
+ apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
929
+ conn.execute("PRAGMA synchronous=NORMAL")
930
+ conn.execute("PRAGMA foreign_keys=ON")
931
+ if needs_init:
932
+ # Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive
933
+ # migrations. Cached so subsequent connect() calls in the same
934
+ # process are cheap.
935
+ conn.executescript(SCHEMA_SQL)
936
+ _migrate_add_optional_columns(conn)
937
+ _INITIALIZED_PATHS.add(resolved)
938
+ return conn
939
+
940
+
941
+ def init_db(
942
+ db_path: Optional[Path] = None,
943
+ *,
944
+ board: Optional[str] = None,
945
+ ) -> Path:
946
+ """Create the schema if it doesn't exist; return the path used.
947
+
948
+ Kept as a public entry point so CLI ``hermes kanban init`` and the
949
+ daemon have something explicit to call. Unlike :func:`connect`'s
950
+ first-time auto-init (which caches by path), ``init_db`` always
951
+ re-runs the migration pass. Callers that know the on-disk schema
952
+ may have drifted — tests that write legacy event kinds directly,
953
+ external tools that upgrade an old DB file — can call this to
954
+ force re-migration.
955
+ """
956
+ if db_path is not None:
957
+ path = db_path
958
+ else:
959
+ path = kanban_db_path(board=board)
960
+ path.parent.mkdir(parents=True, exist_ok=True)
961
+ resolved = str(path.resolve())
962
+ # Clear the cache entry so the underlying connect() re-runs the
963
+ # schema + migration pass unconditionally.
964
+ _INITIALIZED_PATHS.discard(resolved)
965
+ with contextlib.closing(connect(path)):
966
+ pass
967
+ return path
968
+
969
+
970
+ def _add_column_if_missing(
971
+ conn: sqlite3.Connection, table: str, column: str, ddl: str
972
+ ) -> bool:
973
+ """Run ``ALTER TABLE <table> ADD COLUMN <ddl>``, idempotent across races.
974
+
975
+ Returns ``True`` when the column was actually added by this call.
976
+ Swallows ``duplicate column name`` errors so a concurrent connection
977
+ that ran the same migration first does not crash the dispatcher tick
978
+ (issue #21708).
979
+ """
980
+ try:
981
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
982
+ return True
983
+ except sqlite3.OperationalError as exc:
984
+ if "duplicate column name" in str(exc).lower():
985
+ return False
986
+ raise
987
+
988
+
989
+ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
990
+ """Add columns that were introduced after v1 release to legacy DBs.
991
+
992
+ Called by ``init_db`` so opening an old DB is always safe.
993
+ """
994
+ cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")}
995
+ if "tenant" not in cols:
996
+ _add_column_if_missing(conn, "tasks", "tenant", "tenant TEXT")
997
+ if "result" not in cols:
998
+ _add_column_if_missing(conn, "tasks", "result", "result TEXT")
999
+ if "idempotency_key" not in cols:
1000
+ _add_column_if_missing(
1001
+ conn, "tasks", "idempotency_key", "idempotency_key TEXT"
1002
+ )
1003
+ conn.execute(
1004
+ "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency "
1005
+ "ON tasks(idempotency_key)"
1006
+ )
1007
+ # Legacy column migration: ``spawn_failures`` → ``consecutive_failures``
1008
+ # and ``last_spawn_error`` → ``last_failure_error``.
1009
+ #
1010
+ # Avoid ``ALTER TABLE ... RENAME COLUMN`` for two reasons:
1011
+ # 1. Primary: very old DBs may never have had ``spawn_failures`` at
1012
+ # all, so RENAME raises OperationalError: no such column (the crash
1013
+ # reported in issue #20842 after the #20410 update).
1014
+ # 2. Secondary: SQLite reparses the whole schema on any RENAME, which
1015
+ # fails if related objects (views, triggers) reference the old name.
1016
+ #
1017
+ # ADD-first-then-copy is tolerant of both shapes and preserves
1018
+ # historical counter values when the legacy columns do exist.
1019
+ #
1020
+ # NOTE: ``cols`` reflects the schema at entry to this function and is
1021
+ # not refreshed between ALTER TABLE calls. Every guard below checks
1022
+ # the *original* snapshot; this is intentional and safe as long as
1023
+ # no step depends on a column added by a previous step in the same call.
1024
+ if "consecutive_failures" not in cols:
1025
+ added = _add_column_if_missing(
1026
+ conn,
1027
+ "tasks",
1028
+ "consecutive_failures",
1029
+ "consecutive_failures INTEGER NOT NULL DEFAULT 0",
1030
+ )
1031
+ if added and "spawn_failures" in cols:
1032
+ conn.execute(
1033
+ "UPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)"
1034
+ )
1035
+ if "worker_pid" not in cols:
1036
+ _add_column_if_missing(conn, "tasks", "worker_pid", "worker_pid INTEGER")
1037
+ if "last_failure_error" not in cols:
1038
+ added = _add_column_if_missing(
1039
+ conn, "tasks", "last_failure_error", "last_failure_error TEXT"
1040
+ )
1041
+ if added and "last_spawn_error" in cols:
1042
+ conn.execute(
1043
+ "UPDATE tasks SET last_failure_error = last_spawn_error"
1044
+ )
1045
+ if "max_runtime_seconds" not in cols:
1046
+ _add_column_if_missing(
1047
+ conn, "tasks", "max_runtime_seconds", "max_runtime_seconds INTEGER"
1048
+ )
1049
+ if "last_heartbeat_at" not in cols:
1050
+ _add_column_if_missing(
1051
+ conn, "tasks", "last_heartbeat_at", "last_heartbeat_at INTEGER"
1052
+ )
1053
+ if "current_run_id" not in cols:
1054
+ _add_column_if_missing(
1055
+ conn, "tasks", "current_run_id", "current_run_id INTEGER"
1056
+ )
1057
+ if "workflow_template_id" not in cols:
1058
+ _add_column_if_missing(
1059
+ conn, "tasks", "workflow_template_id", "workflow_template_id TEXT"
1060
+ )
1061
+ if "current_step_key" not in cols:
1062
+ _add_column_if_missing(
1063
+ conn, "tasks", "current_step_key", "current_step_key TEXT"
1064
+ )
1065
+ if "skills" not in cols:
1066
+ # JSON array of skill names the dispatcher force-loads into the
1067
+ # worker (additive to the built-in `kanban-worker`). NULL is fine
1068
+ # for existing rows.
1069
+ _add_column_if_missing(conn, "tasks", "skills", "skills TEXT")
1070
+
1071
+ if "max_retries" not in cols:
1072
+ # Per-task override for the consecutive-failure circuit breaker.
1073
+ # NULL = fall through to the dispatcher-level ``kanban.failure_limit``
1074
+ # config, then ``DEFAULT_FAILURE_LIMIT``. Existing rows get NULL,
1075
+ # which is the correct default (they keep the global behaviour
1076
+ # they were getting before the column existed).
1077
+ _add_column_if_missing(conn, "tasks", "max_retries", "max_retries INTEGER")
1078
+
1079
+ # task_events gained a run_id column; back-fill it as NULL for
1080
+ # historical events (they predate runs and can't be attributed).
1081
+ ev_cols = {row["name"] for row in conn.execute("PRAGMA table_info(task_events)")}
1082
+ if "run_id" not in ev_cols:
1083
+ _add_column_if_missing(conn, "task_events", "run_id", "run_id INTEGER")
1084
+ conn.execute(
1085
+ "CREATE INDEX IF NOT EXISTS idx_events_run "
1086
+ "ON task_events(run_id, id)"
1087
+ )
1088
+
1089
+ notify_table_exists = conn.execute(
1090
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'"
1091
+ ).fetchone() is not None
1092
+ if notify_table_exists:
1093
+ notify_cols = {
1094
+ row["name"] for row in conn.execute("PRAGMA table_info(kanban_notify_subs)")
1095
+ }
1096
+ if "notifier_profile" not in notify_cols:
1097
+ _add_column_if_missing(
1098
+ conn, "kanban_notify_subs", "notifier_profile", "notifier_profile TEXT"
1099
+ )
1100
+
1101
+ # One-shot backfill: any task that is 'running' before runs existed
1102
+ # had its claim_lock / claim_expires / worker_pid on the task row.
1103
+ # Synthesize a matching task_runs row so subsequent end-run / heartbeat
1104
+ # calls have something to write to. Wrapped in write_txn to serialize
1105
+ # against any concurrent dispatcher, and the per-row UPDATE uses
1106
+ # ``current_run_id IS NULL`` as a CAS guard so a racing claim can't
1107
+ # produce an orphaned row if it interleaves with the backfill pass.
1108
+ runs_exist = conn.execute(
1109
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='task_runs'"
1110
+ ).fetchone() is not None
1111
+ if runs_exist:
1112
+ with write_txn(conn):
1113
+ inflight = conn.execute(
1114
+ "SELECT id, assignee, claim_lock, claim_expires, worker_pid, "
1115
+ " max_runtime_seconds, last_heartbeat_at, started_at "
1116
+ "FROM tasks "
1117
+ "WHERE status = 'running' AND current_run_id IS NULL"
1118
+ ).fetchall()
1119
+ for row in inflight:
1120
+ started = row["started_at"] or int(time.time())
1121
+ cur = conn.execute(
1122
+ """
1123
+ INSERT INTO task_runs (
1124
+ task_id, profile, status,
1125
+ claim_lock, claim_expires, worker_pid,
1126
+ max_runtime_seconds, last_heartbeat_at,
1127
+ started_at
1128
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?)
1129
+ """,
1130
+ (
1131
+ row["id"], row["assignee"], row["claim_lock"],
1132
+ row["claim_expires"], row["worker_pid"],
1133
+ row["max_runtime_seconds"], row["last_heartbeat_at"],
1134
+ started,
1135
+ ),
1136
+ )
1137
+ # CAS: only install the pointer if nothing else claimed
1138
+ # the task between our SELECT and here (shouldn't happen
1139
+ # under the write_txn, but belt-and-suspenders). If the
1140
+ # CAS fails we've got an orphan run_row — mark it
1141
+ # reclaimed so it doesn't look in-flight.
1142
+ upd = conn.execute(
1143
+ "UPDATE tasks SET current_run_id = ? "
1144
+ "WHERE id = ? AND current_run_id IS NULL",
1145
+ (cur.lastrowid, row["id"]),
1146
+ )
1147
+ if upd.rowcount != 1:
1148
+ conn.execute(
1149
+ "UPDATE task_runs SET status = 'reclaimed', "
1150
+ " outcome = 'reclaimed', ended_at = ? "
1151
+ "WHERE id = ?",
1152
+ (int(time.time()), cur.lastrowid),
1153
+ )
1154
+
1155
+ # One-shot event-kind rename pass. The old names ("ready", "priority",
1156
+ # "spawn_auto_blocked") still worked but were awkward on the wire;
1157
+ # rename them in-place so existing DBs migrate cleanly. Fires once
1158
+ # per DB because after the UPDATE no rows match the old kinds.
1159
+ _EVENT_RENAMES = (
1160
+ # (old, new)
1161
+ ("ready", "promoted"),
1162
+ ("priority", "reprioritized"),
1163
+ ("spawn_auto_blocked", "gave_up"),
1164
+ )
1165
+ for old, new in _EVENT_RENAMES:
1166
+ conn.execute(
1167
+ "UPDATE task_events SET kind = ? WHERE kind = ?",
1168
+ (new, old),
1169
+ )
1170
+
1171
+
1172
+ @contextlib.contextmanager
1173
+ def write_txn(conn: sqlite3.Connection):
1174
+ """Context manager for an IMMEDIATE write transaction.
1175
+
1176
+ Use for any multi-statement write (creating a task + link, claiming a
1177
+ task + recording an event, etc.). A claim CAS inside this context is
1178
+ atomic -- at most one concurrent writer can succeed.
1179
+ """
1180
+ conn.execute("BEGIN IMMEDIATE")
1181
+ try:
1182
+ yield conn
1183
+ except Exception:
1184
+ conn.execute("ROLLBACK")
1185
+ raise
1186
+ else:
1187
+ conn.execute("COMMIT")
1188
+
1189
+
1190
+ # ---------------------------------------------------------------------------
1191
+ # ID generation
1192
+ # ---------------------------------------------------------------------------
1193
+
1194
+ def _new_task_id() -> str:
1195
+ """Generate a short, URL-safe task id.
1196
+
1197
+ 4 hex bytes = ~4.3B possibilities. At 10k tasks the collision
1198
+ probability is ~1.2e-5; at 100k it's ~1.2e-3. Previously we used 2
1199
+ hex bytes (65k possibilities) which hit the birthday paradox hard:
1200
+ ~5% collision probability at 1k tasks, ~50% at 10k. Callers that
1201
+ care about idempotency should pass ``idempotency_key`` to
1202
+ :func:`create_task` rather than rely on id uniqueness.
1203
+ """
1204
+ return "t_" + secrets.token_hex(4)
1205
+
1206
+
1207
+ def _claimer_id() -> str:
1208
+ """Return a ``host:pid`` string that identifies this claimer."""
1209
+ import socket
1210
+ try:
1211
+ host = socket.gethostname() or "unknown"
1212
+ except Exception:
1213
+ host = "unknown"
1214
+ return f"{host}:{os.getpid()}"
1215
+
1216
+
1217
+ # ---------------------------------------------------------------------------
1218
+ # Task creation / mutation
1219
+ # ---------------------------------------------------------------------------
1220
+
1221
+ def _canonical_assignee(assignee: Optional[str]) -> Optional[str]:
1222
+ """Lowercase-assignee normalization for Kanban rows (dashboard/CLI parity)."""
1223
+ if assignee is None:
1224
+ return None
1225
+ from hermes_cli.profiles import normalize_profile_name
1226
+
1227
+ return normalize_profile_name(assignee)
1228
+
1229
+
1230
+ def create_task(
1231
+ conn: sqlite3.Connection,
1232
+ *,
1233
+ title: str,
1234
+ body: Optional[str] = None,
1235
+ assignee: Optional[str] = None,
1236
+ created_by: Optional[str] = None,
1237
+ workspace_kind: str = "scratch",
1238
+ workspace_path: Optional[str] = None,
1239
+ tenant: Optional[str] = None,
1240
+ priority: int = 0,
1241
+ parents: Iterable[str] = (),
1242
+ triage: bool = False,
1243
+ idempotency_key: Optional[str] = None,
1244
+ max_runtime_seconds: Optional[int] = None,
1245
+ skills: Optional[Iterable[str]] = None,
1246
+ max_retries: Optional[int] = None,
1247
+ ) -> str:
1248
+ """Create a new task and optionally link it under parent tasks.
1249
+
1250
+ Returns the new task id. Status is ``ready`` when there are no
1251
+ parents (or all parents already ``done``), otherwise ``todo``.
1252
+ If ``triage=True``, status is forced to ``triage`` regardless of
1253
+ parents — a specifier/triager is expected to promote the task to
1254
+ ``todo`` once the spec is fleshed out.
1255
+
1256
+ If ``idempotency_key`` is provided and a non-archived task with the
1257
+ same key already exists, returns the existing task's id instead of
1258
+ creating a duplicate. Useful for retried webhooks / automation that
1259
+ should not double-write.
1260
+
1261
+ ``max_runtime_seconds`` caps how long a worker may run before the
1262
+ dispatcher SIGTERMs (then SIGKILLs after a grace window) and
1263
+ re-queues the task. ``None`` means no cap (default).
1264
+
1265
+ ``skills`` is an optional list of skill names to force-load into
1266
+ the worker when dispatched. Stored as JSON; the dispatcher passes
1267
+ each name to ``hermes --skills ...`` alongside the built-in
1268
+ ``kanban-worker``. Use this to pin a task to a specialist skill
1269
+ (e.g. ``skills=["translation"]`` so the worker loads the
1270
+ translation skill regardless of the profile's default config).
1271
+ """
1272
+ assignee = _canonical_assignee(assignee)
1273
+ if not title or not title.strip():
1274
+ raise ValueError("title is required")
1275
+ if workspace_kind not in VALID_WORKSPACE_KINDS:
1276
+ raise ValueError(
1277
+ f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, "
1278
+ f"got {workspace_kind!r}"
1279
+ )
1280
+ parents = tuple(p for p in parents if p)
1281
+
1282
+ # Normalise + validate skills: strip whitespace, drop empties, dedupe
1283
+ # (preserving order). Refuse commas inside a single name so we don't
1284
+ # invisibly splatter a comma-joined string into one argv slot — the
1285
+ # `hermes --skills X,Y` comma syntax is handled in the dispatcher,
1286
+ # not here.
1287
+ skills_list: Optional[list[str]] = None
1288
+ if skills is not None:
1289
+ cleaned: list[str] = []
1290
+ seen: set[str] = set()
1291
+ # Collect all toolset-name confusions up front so the user sees the
1292
+ # whole list at once. Raising on the first hit is friendly when the
1293
+ # input has one mistake, but agents that confuse skills with toolsets
1294
+ # usually pass several at once (`skills=["web", "browser", "terminal"]`)
1295
+ # and serial-correcting one per failure round-trips wastes tokens.
1296
+ toolset_typos: list[str] = []
1297
+ for s in skills:
1298
+ if not s:
1299
+ continue
1300
+ name = str(s).strip()
1301
+ if not name:
1302
+ continue
1303
+ if "," in name:
1304
+ raise ValueError(
1305
+ f"skill name cannot contain comma: {name!r} "
1306
+ f"(pass a list of separate names instead of a comma-joined string)"
1307
+ )
1308
+ if name.casefold() in KNOWN_TOOLSET_NAMES:
1309
+ toolset_typos.append(name)
1310
+ continue
1311
+ if name in seen:
1312
+ continue
1313
+ seen.add(name)
1314
+ cleaned.append(name)
1315
+ if toolset_typos:
1316
+ quoted = ", ".join(repr(n) for n in toolset_typos)
1317
+ noun = "is a toolset name" if len(toolset_typos) == 1 else "are toolset names"
1318
+ raise ValueError(
1319
+ f"{quoted} {noun}, not skill name(s). "
1320
+ "Put toolsets in the assignee profile's `toolsets:` config "
1321
+ "instead of per-task skills. Skills are named skill bundles "
1322
+ "(e.g. `kanban-worker`, `blogwatcher`); toolsets are runtime "
1323
+ "capabilities (e.g. `web`, `browser`, `terminal`)."
1324
+ )
1325
+ skills_list = cleaned
1326
+
1327
+ # Idempotency check — return the existing task instead of creating a
1328
+ # duplicate. Done BEFORE entering write_txn to keep the fast path fast
1329
+ # and to avoid holding a write lock during the lookup. Race is
1330
+ # acceptable: two concurrent creators with the same key might both
1331
+ # insert, at which point both rows exist but the next lookup stabilises.
1332
+ if idempotency_key:
1333
+ row = conn.execute(
1334
+ "SELECT id FROM tasks WHERE idempotency_key = ? "
1335
+ "AND status != 'archived' "
1336
+ "ORDER BY created_at DESC LIMIT 1",
1337
+ (idempotency_key,),
1338
+ ).fetchone()
1339
+ if row:
1340
+ return row["id"]
1341
+
1342
+ now = int(time.time())
1343
+
1344
+ # Retry once on the extremely unlikely id collision.
1345
+ for attempt in range(2):
1346
+ task_id = _new_task_id()
1347
+ try:
1348
+ with write_txn(conn):
1349
+ # Determine initial status from parent status, unless the
1350
+ # caller is parking this task in triage for a specifier.
1351
+ if triage:
1352
+ initial_status = "triage"
1353
+ else:
1354
+ initial_status = "ready"
1355
+ if parents:
1356
+ missing = _find_missing_parents(conn, parents)
1357
+ if missing:
1358
+ raise ValueError(f"unknown parent task(s): {', '.join(missing)}")
1359
+ # If any parent is not yet done, we're todo.
1360
+ rows = conn.execute(
1361
+ "SELECT status FROM tasks WHERE id IN "
1362
+ "(" + ",".join("?" * len(parents)) + ")",
1363
+ parents,
1364
+ ).fetchall()
1365
+ if any(r["status"] != "done" for r in rows):
1366
+ initial_status = "todo"
1367
+ # Even in triage mode we still need to validate parent ids
1368
+ # so the eventual link rows don't dangle.
1369
+ if triage and parents:
1370
+ missing = _find_missing_parents(conn, parents)
1371
+ if missing:
1372
+ raise ValueError(f"unknown parent task(s): {', '.join(missing)}")
1373
+
1374
+ conn.execute(
1375
+ """
1376
+ INSERT INTO tasks (
1377
+ id, title, body, assignee, status, priority,
1378
+ created_by, created_at, workspace_kind, workspace_path,
1379
+ tenant, idempotency_key, max_runtime_seconds, skills,
1380
+ max_retries
1381
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1382
+ """,
1383
+ (
1384
+ task_id,
1385
+ title.strip(),
1386
+ body,
1387
+ assignee,
1388
+ initial_status,
1389
+ priority,
1390
+ created_by,
1391
+ now,
1392
+ workspace_kind,
1393
+ workspace_path,
1394
+ tenant,
1395
+ idempotency_key,
1396
+ int(max_runtime_seconds) if max_runtime_seconds else None,
1397
+ json.dumps(skills_list) if skills_list is not None else None,
1398
+ int(max_retries) if max_retries is not None else None,
1399
+ ),
1400
+ )
1401
+ for pid in parents:
1402
+ conn.execute(
1403
+ "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)",
1404
+ (pid, task_id),
1405
+ )
1406
+ _append_event(
1407
+ conn,
1408
+ task_id,
1409
+ "created",
1410
+ {
1411
+ "assignee": assignee,
1412
+ "status": initial_status,
1413
+ "parents": list(parents),
1414
+ "tenant": tenant,
1415
+ "skills": list(skills_list) if skills_list else None,
1416
+ },
1417
+ )
1418
+ return task_id
1419
+ except sqlite3.IntegrityError:
1420
+ if attempt == 1:
1421
+ raise
1422
+ # Retry with a fresh id.
1423
+ continue
1424
+ raise RuntimeError("unreachable")
1425
+
1426
+
1427
+ def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]:
1428
+ parents = list(parents)
1429
+ if not parents:
1430
+ return []
1431
+ placeholders = ",".join("?" * len(parents))
1432
+ rows = conn.execute(
1433
+ f"SELECT id FROM tasks WHERE id IN ({placeholders})",
1434
+ parents,
1435
+ ).fetchall()
1436
+ present = {r["id"] for r in rows}
1437
+ return [p for p in parents if p not in present]
1438
+
1439
+
1440
+ def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]:
1441
+ row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
1442
+ return Task.from_row(row) if row else None
1443
+
1444
+
1445
+ def list_tasks(
1446
+ conn: sqlite3.Connection,
1447
+ *,
1448
+ assignee: Optional[str] = None,
1449
+ status: Optional[str] = None,
1450
+ tenant: Optional[str] = None,
1451
+ include_archived: bool = False,
1452
+ limit: Optional[int] = None,
1453
+ ) -> list[Task]:
1454
+ query = "SELECT * FROM tasks WHERE 1=1"
1455
+ params: list[Any] = []
1456
+ if assignee is not None:
1457
+ query += " AND assignee = ?"
1458
+ params.append(_canonical_assignee(assignee))
1459
+ if status is not None:
1460
+ if status not in VALID_STATUSES:
1461
+ raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}")
1462
+ query += " AND status = ?"
1463
+ params.append(status)
1464
+ if tenant is not None:
1465
+ query += " AND tenant = ?"
1466
+ params.append(tenant)
1467
+ if not include_archived and status != "archived":
1468
+ query += " AND status != 'archived'"
1469
+ query += " ORDER BY priority DESC, created_at ASC"
1470
+ if limit:
1471
+ query += f" LIMIT {int(limit)}"
1472
+ rows = conn.execute(query, params).fetchall()
1473
+ return [Task.from_row(r) for r in rows]
1474
+
1475
+
1476
+ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) -> bool:
1477
+ """Assign or reassign a task. Returns True on success.
1478
+
1479
+ Refuses to reassign a task that's currently running (claim_lock set).
1480
+ Reassign after the current run completes if needed.
1481
+ """
1482
+ profile = _canonical_assignee(profile)
1483
+ with write_txn(conn):
1484
+ row = conn.execute(
1485
+ "SELECT status, claim_lock, assignee FROM tasks WHERE id = ?", (task_id,)
1486
+ ).fetchone()
1487
+ if not row:
1488
+ return False
1489
+ if row["claim_lock"] is not None and row["status"] == "running":
1490
+ raise RuntimeError(
1491
+ f"cannot reassign {task_id}: currently running (claimed). "
1492
+ "Wait for completion or reclaim the stale lock first."
1493
+ )
1494
+ if row["assignee"] != profile:
1495
+ # The retry guard is scoped to the task/profile combination. A
1496
+ # human reassigning the task is an explicit recovery action, so the
1497
+ # new profile should not inherit the previous profile's streak.
1498
+ conn.execute(
1499
+ "UPDATE tasks SET assignee = ?, consecutive_failures = 0, "
1500
+ "last_failure_error = NULL WHERE id = ?",
1501
+ (profile, task_id),
1502
+ )
1503
+ else:
1504
+ conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id))
1505
+ _append_event(conn, task_id, "assigned", {"assignee": profile})
1506
+ return True
1507
+
1508
+
1509
+ # ---------------------------------------------------------------------------
1510
+ # Links
1511
+ # ---------------------------------------------------------------------------
1512
+
1513
+ def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None:
1514
+ if parent_id == child_id:
1515
+ raise ValueError("a task cannot depend on itself")
1516
+ with write_txn(conn):
1517
+ missing = _find_missing_parents(conn, [parent_id, child_id])
1518
+ if missing:
1519
+ raise ValueError(f"unknown task(s): {', '.join(missing)}")
1520
+ if _would_cycle(conn, parent_id, child_id):
1521
+ raise ValueError(
1522
+ f"linking {parent_id} -> {child_id} would create a cycle"
1523
+ )
1524
+ conn.execute(
1525
+ "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)",
1526
+ (parent_id, child_id),
1527
+ )
1528
+ # If child was ready but parent is not yet done, demote child to todo.
1529
+ parent_status = conn.execute(
1530
+ "SELECT status FROM tasks WHERE id = ?", (parent_id,)
1531
+ ).fetchone()["status"]
1532
+ if parent_status != "done":
1533
+ conn.execute(
1534
+ "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'",
1535
+ (child_id,),
1536
+ )
1537
+ _append_event(
1538
+ conn, child_id, "linked",
1539
+ {"parent": parent_id, "child": child_id},
1540
+ )
1541
+
1542
+
1543
+ def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool:
1544
+ """Return True if adding parent->child creates a cycle.
1545
+
1546
+ A cycle exists iff ``parent_id`` is already a descendant of
1547
+ ``child_id`` via existing parent->child links. We walk downward
1548
+ from ``child_id`` and check whether we reach ``parent_id``.
1549
+ """
1550
+ seen = set()
1551
+ stack = [child_id]
1552
+ while stack:
1553
+ node = stack.pop()
1554
+ if node == parent_id:
1555
+ return True
1556
+ if node in seen:
1557
+ continue
1558
+ seen.add(node)
1559
+ rows = conn.execute(
1560
+ "SELECT child_id FROM task_links WHERE parent_id = ?", (node,)
1561
+ ).fetchall()
1562
+ stack.extend(r["child_id"] for r in rows)
1563
+ return False
1564
+
1565
+
1566
+ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool:
1567
+ with write_txn(conn):
1568
+ cur = conn.execute(
1569
+ "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?",
1570
+ (parent_id, child_id),
1571
+ )
1572
+ if cur.rowcount:
1573
+ _append_event(
1574
+ conn, child_id, "unlinked",
1575
+ {"parent": parent_id, "child": child_id},
1576
+ )
1577
+ removed = cur.rowcount > 0
1578
+ if removed:
1579
+ # Dependency edge removed — re-evaluate promotion eligibility for the
1580
+ # child immediately. Matches the contract of complete_task and
1581
+ # unblock_task; without this the child stays stuck in todo until the
1582
+ # next dispatcher tick or a manual `hermes kanban recompute` (issue #22459).
1583
+ recompute_ready(conn)
1584
+ return removed
1585
+
1586
+
1587
+ def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]:
1588
+ rows = conn.execute(
1589
+ "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id",
1590
+ (task_id,),
1591
+ ).fetchall()
1592
+ return [r["parent_id"] for r in rows]
1593
+
1594
+
1595
+ def child_ids(conn: sqlite3.Connection, task_id: str) -> list[str]:
1596
+ rows = conn.execute(
1597
+ "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id",
1598
+ (task_id,),
1599
+ ).fetchall()
1600
+ return [r["child_id"] for r in rows]
1601
+
1602
+
1603
+ def parent_results(conn: sqlite3.Connection, task_id: str) -> list[tuple[str, Optional[str]]]:
1604
+ """Return ``(parent_id, result)`` for every done parent of ``task_id``."""
1605
+ rows = conn.execute(
1606
+ """
1607
+ SELECT t.id AS id, t.result AS result
1608
+ FROM tasks t
1609
+ JOIN task_links l ON l.parent_id = t.id
1610
+ WHERE l.child_id = ? AND t.status = 'done'
1611
+ ORDER BY t.completed_at ASC
1612
+ """,
1613
+ (task_id,),
1614
+ ).fetchall()
1615
+ return [(r["id"], r["result"]) for r in rows]
1616
+
1617
+
1618
+ # ---------------------------------------------------------------------------
1619
+ # Comments & events
1620
+ # ---------------------------------------------------------------------------
1621
+
1622
+ def add_comment(
1623
+ conn: sqlite3.Connection, task_id: str, author: str, body: str
1624
+ ) -> int:
1625
+ if not body or not body.strip():
1626
+ raise ValueError("comment body is required")
1627
+ if not author or not author.strip():
1628
+ raise ValueError("comment author is required")
1629
+ now = int(time.time())
1630
+ with write_txn(conn):
1631
+ if not conn.execute(
1632
+ "SELECT 1 FROM tasks WHERE id = ?", (task_id,)
1633
+ ).fetchone():
1634
+ raise ValueError(f"unknown task {task_id}")
1635
+ cur = conn.execute(
1636
+ "INSERT INTO task_comments (task_id, author, body, created_at) "
1637
+ "VALUES (?, ?, ?, ?)",
1638
+ (task_id, author.strip(), body.strip(), now),
1639
+ )
1640
+ _append_event(conn, task_id, "commented", {"author": author, "len": len(body)})
1641
+ return int(cur.lastrowid or 0)
1642
+
1643
+
1644
+ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
1645
+ rows = conn.execute(
1646
+ "SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at ASC",
1647
+ (task_id,),
1648
+ ).fetchall()
1649
+ return [
1650
+ Comment(
1651
+ id=r["id"],
1652
+ task_id=r["task_id"],
1653
+ author=r["author"],
1654
+ body=r["body"],
1655
+ created_at=r["created_at"],
1656
+ )
1657
+ for r in rows
1658
+ ]
1659
+
1660
+
1661
+ def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
1662
+ rows = conn.execute(
1663
+ "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
1664
+ (task_id,),
1665
+ ).fetchall()
1666
+ out = []
1667
+ for r in rows:
1668
+ try:
1669
+ payload = json.loads(r["payload"]) if r["payload"] else None
1670
+ except Exception:
1671
+ payload = None
1672
+ out.append(
1673
+ Event(
1674
+ id=r["id"],
1675
+ task_id=r["task_id"],
1676
+ kind=r["kind"],
1677
+ payload=payload,
1678
+ created_at=r["created_at"],
1679
+ run_id=(int(r["run_id"]) if "run_id" in r.keys() and r["run_id"] is not None else None),
1680
+ )
1681
+ )
1682
+ return out
1683
+
1684
+
1685
+ def _append_event(
1686
+ conn: sqlite3.Connection,
1687
+ task_id: str,
1688
+ kind: str,
1689
+ payload: Optional[dict] = None,
1690
+ *,
1691
+ run_id: Optional[int] = None,
1692
+ ) -> None:
1693
+ """Record an event row. Called from within an already-open txn.
1694
+
1695
+ ``run_id`` is optional: pass the current run id so UIs can group
1696
+ events by attempt. For events that aren't scoped to a single run
1697
+ (task created/edited/archived, dependency promotion) leave it None
1698
+ and the row carries NULL.
1699
+ """
1700
+ now = int(time.time())
1701
+ pl = json.dumps(payload, ensure_ascii=False) if payload else None
1702
+ conn.execute(
1703
+ "INSERT INTO task_events (task_id, run_id, kind, payload, created_at) "
1704
+ "VALUES (?, ?, ?, ?, ?)",
1705
+ (task_id, run_id, kind, pl, now),
1706
+ )
1707
+
1708
+
1709
+ def _end_run(
1710
+ conn: sqlite3.Connection,
1711
+ task_id: str,
1712
+ *,
1713
+ outcome: str,
1714
+ summary: Optional[str] = None,
1715
+ error: Optional[str] = None,
1716
+ metadata: Optional[dict] = None,
1717
+ status: Optional[str] = None,
1718
+ ) -> Optional[int]:
1719
+ """Close the currently-active run for ``task_id`` and clear the pointer.
1720
+
1721
+ ``outcome`` is the semantic result (completed / blocked / crashed /
1722
+ timed_out / spawn_failed / gave_up / reclaimed). ``status`` is the
1723
+ run-row status (usually just ``outcome``, but callers can pass it
1724
+ explicitly). Returns the closed run_id or ``None`` if no active run
1725
+ existed (e.g. a CLI user calling ``hermes kanban complete`` on a
1726
+ task that was never claimed).
1727
+ """
1728
+ now = int(time.time())
1729
+ row = conn.execute(
1730
+ "SELECT current_run_id FROM tasks WHERE id = ?", (task_id,),
1731
+ ).fetchone()
1732
+ if not row or not row["current_run_id"]:
1733
+ return None
1734
+ run_id = int(row["current_run_id"])
1735
+ conn.execute(
1736
+ """
1737
+ UPDATE task_runs
1738
+ SET status = ?,
1739
+ outcome = ?,
1740
+ summary = ?,
1741
+ error = ?,
1742
+ metadata = ?,
1743
+ ended_at = ?,
1744
+ claim_lock = NULL,
1745
+ claim_expires = NULL,
1746
+ worker_pid = NULL
1747
+ WHERE id = ?
1748
+ AND ended_at IS NULL
1749
+ """,
1750
+ (
1751
+ status or outcome,
1752
+ outcome,
1753
+ summary,
1754
+ error,
1755
+ json.dumps(metadata, ensure_ascii=False) if metadata else None,
1756
+ now,
1757
+ run_id,
1758
+ ),
1759
+ )
1760
+ conn.execute(
1761
+ "UPDATE tasks SET current_run_id = NULL WHERE id = ?", (task_id,),
1762
+ )
1763
+ return run_id
1764
+
1765
+
1766
+ def _current_run_id(conn: sqlite3.Connection, task_id: str) -> Optional[int]:
1767
+ row = conn.execute(
1768
+ "SELECT current_run_id FROM tasks WHERE id = ?", (task_id,),
1769
+ ).fetchone()
1770
+ return int(row["current_run_id"]) if row and row["current_run_id"] else None
1771
+
1772
+
1773
+ def _synthesize_ended_run(
1774
+ conn: sqlite3.Connection,
1775
+ task_id: str,
1776
+ *,
1777
+ outcome: str,
1778
+ summary: Optional[str] = None,
1779
+ error: Optional[str] = None,
1780
+ metadata: Optional[dict] = None,
1781
+ ) -> int:
1782
+ """Insert a zero-duration, already-closed run row.
1783
+
1784
+ Used when a terminal transition happens on a task that was never
1785
+ claimed (CLI user calling ``hermes kanban complete <ready-task>
1786
+ --summary X``, or dashboard "mark done" on a ready task). Without
1787
+ this, the handoff fields (summary / metadata / error) would be
1788
+ silently dropped: ``_end_run`` is a no-op because there's no
1789
+ current run.
1790
+
1791
+ The synthetic run has ``started_at == ended_at == now`` so it
1792
+ shows up in attempt history as "instant" and doesn't skew elapsed
1793
+ stats. Caller is responsible for leaving ``current_run_id`` NULL
1794
+ (or for clearing it elsewhere in the same txn) since this
1795
+ function does NOT touch the tasks row.
1796
+ """
1797
+ now = int(time.time())
1798
+ trow = conn.execute(
1799
+ "SELECT assignee, current_step_key FROM tasks WHERE id = ?",
1800
+ (task_id,),
1801
+ ).fetchone()
1802
+ profile = trow["assignee"] if trow else None
1803
+ step_key = trow["current_step_key"] if trow else None
1804
+ cur = conn.execute(
1805
+ """
1806
+ INSERT INTO task_runs (
1807
+ task_id, profile, step_key,
1808
+ status, outcome,
1809
+ summary, error, metadata,
1810
+ started_at, ended_at
1811
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1812
+ """,
1813
+ (
1814
+ task_id, profile, step_key,
1815
+ outcome, outcome,
1816
+ summary, error,
1817
+ json.dumps(metadata, ensure_ascii=False) if metadata else None,
1818
+ now, now,
1819
+ ),
1820
+ )
1821
+ return int(cur.lastrowid or 0)
1822
+
1823
+
1824
+ # ---------------------------------------------------------------------------
1825
+ # Dependency resolution (todo -> ready)
1826
+ # ---------------------------------------------------------------------------
1827
+
1828
+ def recompute_ready(conn: sqlite3.Connection) -> int:
1829
+ """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
1830
+
1831
+ Returns the number of tasks promoted. Safe to call inside or outside
1832
+ an existing transaction; it opens its own IMMEDIATE txn.
1833
+ """
1834
+ promoted = 0
1835
+ with write_txn(conn):
1836
+ todo_rows = conn.execute(
1837
+ "SELECT id FROM tasks WHERE status = 'todo'"
1838
+ ).fetchall()
1839
+ for row in todo_rows:
1840
+ task_id = row["id"]
1841
+ parents = conn.execute(
1842
+ "SELECT t.status FROM tasks t "
1843
+ "JOIN task_links l ON l.parent_id = t.id "
1844
+ "WHERE l.child_id = ?",
1845
+ (task_id,),
1846
+ ).fetchall()
1847
+ if all(p["status"] in {"done", "archived"} for p in parents):
1848
+ conn.execute(
1849
+ "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'",
1850
+ (task_id,),
1851
+ )
1852
+ _append_event(conn, task_id, "promoted", None)
1853
+ promoted += 1
1854
+ return promoted
1855
+
1856
+
1857
+ # ---------------------------------------------------------------------------
1858
+ # Claim / complete / block
1859
+ # ---------------------------------------------------------------------------
1860
+
1861
+ def claim_task(
1862
+ conn: sqlite3.Connection,
1863
+ task_id: str,
1864
+ *,
1865
+ ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS,
1866
+ claimer: Optional[str] = None,
1867
+ ) -> Optional[Task]:
1868
+ """Atomically transition ``ready -> running``.
1869
+
1870
+ Returns the claimed ``Task`` on success, ``None`` if the task was
1871
+ already claimed (or is not in ``ready`` status).
1872
+ """
1873
+ now = int(time.time())
1874
+ lock = claimer or _claimer_id()
1875
+ expires = now + int(ttl_seconds)
1876
+ with write_txn(conn):
1877
+ # Structural invariant: never transition ready -> running while any
1878
+ # parent is not yet 'done'. This is the single enforcement point
1879
+ # regardless of which writer (create_task, link_tasks, unblock_task,
1880
+ # release_stale_claims, manual SQL) set status='ready'. If a racy
1881
+ # writer promoted a task with undone parents, demote it back to
1882
+ # 'todo' here — recompute_ready will re-promote when the parents
1883
+ # actually finish. See RCA at
1884
+ # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
1885
+ undone = conn.execute(
1886
+ "SELECT 1 FROM task_links l "
1887
+ "JOIN tasks p ON p.id = l.parent_id "
1888
+ "WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1",
1889
+ (task_id,),
1890
+ ).fetchone()
1891
+ if undone:
1892
+ conn.execute(
1893
+ "UPDATE tasks SET status = 'todo' "
1894
+ "WHERE id = ? AND status = 'ready'",
1895
+ (task_id,),
1896
+ )
1897
+ _append_event(
1898
+ conn, task_id, "claim_rejected",
1899
+ {"reason": "parents_not_done"},
1900
+ )
1901
+ return None
1902
+ # Defensive: if a prior run somehow leaked (invariant violation from
1903
+ # an unknown code path), close it as 'reclaimed' so we don't strand
1904
+ # it when the CAS resets the pointer below. No-op when the invariant
1905
+ # holds (the common case).
1906
+ stale = conn.execute(
1907
+ "SELECT current_run_id FROM tasks WHERE id = ? AND status = 'ready'",
1908
+ (task_id,),
1909
+ ).fetchone()
1910
+ if stale and stale["current_run_id"]:
1911
+ conn.execute(
1912
+ """
1913
+ UPDATE task_runs
1914
+ SET status = 'reclaimed', outcome = 'reclaimed',
1915
+ summary = COALESCE(summary, 'invariant recovery on re-claim'),
1916
+ ended_at = ?,
1917
+ claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
1918
+ WHERE id = ? AND ended_at IS NULL
1919
+ """,
1920
+ (now, int(stale["current_run_id"])),
1921
+ )
1922
+ cur = conn.execute(
1923
+ """
1924
+ UPDATE tasks
1925
+ SET status = 'running',
1926
+ claim_lock = ?,
1927
+ claim_expires = ?,
1928
+ started_at = COALESCE(started_at, ?)
1929
+ WHERE id = ?
1930
+ AND status = 'ready'
1931
+ AND claim_lock IS NULL
1932
+ """,
1933
+ (lock, expires, now, task_id),
1934
+ )
1935
+ if cur.rowcount != 1:
1936
+ return None
1937
+ # Look up the current task row so we can populate the run with
1938
+ # its assignee / step / runtime cap.
1939
+ trow = conn.execute(
1940
+ "SELECT assignee, max_runtime_seconds, current_step_key "
1941
+ "FROM tasks WHERE id = ?",
1942
+ (task_id,),
1943
+ ).fetchone()
1944
+ run_cur = conn.execute(
1945
+ """
1946
+ INSERT INTO task_runs (
1947
+ task_id, profile, step_key, status,
1948
+ claim_lock, claim_expires, max_runtime_seconds,
1949
+ started_at
1950
+ ) VALUES (?, ?, ?, 'running', ?, ?, ?, ?)
1951
+ """,
1952
+ (
1953
+ task_id,
1954
+ trow["assignee"] if trow else None,
1955
+ trow["current_step_key"] if trow else None,
1956
+ lock,
1957
+ expires,
1958
+ trow["max_runtime_seconds"] if trow else None,
1959
+ now,
1960
+ ),
1961
+ )
1962
+ run_id = run_cur.lastrowid
1963
+ conn.execute(
1964
+ "UPDATE tasks SET current_run_id = ? WHERE id = ?",
1965
+ (run_id, task_id),
1966
+ )
1967
+ _append_event(
1968
+ conn, task_id, "claimed",
1969
+ {"lock": lock, "expires": expires, "run_id": run_id},
1970
+ run_id=run_id,
1971
+ )
1972
+ return get_task(conn, task_id)
1973
+
1974
+
1975
+ def heartbeat_claim(
1976
+ conn: sqlite3.Connection,
1977
+ task_id: str,
1978
+ *,
1979
+ ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS,
1980
+ claimer: Optional[str] = None,
1981
+ ) -> bool:
1982
+ """Extend a running claim. Returns True if we still own it.
1983
+
1984
+ Workers that know they'll exceed 15 minutes should call this every
1985
+ few minutes to keep ownership.
1986
+ """
1987
+ expires = int(time.time()) + int(ttl_seconds)
1988
+ lock = claimer or _claimer_id()
1989
+ with write_txn(conn):
1990
+ cur = conn.execute(
1991
+ "UPDATE tasks SET claim_expires = ? "
1992
+ "WHERE id = ? AND status = 'running' AND claim_lock = ?",
1993
+ (expires, task_id, lock),
1994
+ )
1995
+ if cur.rowcount == 1:
1996
+ run_id = _current_run_id(conn, task_id)
1997
+ if run_id is not None:
1998
+ conn.execute(
1999
+ "UPDATE task_runs SET claim_expires = ? WHERE id = ?",
2000
+ (expires, run_id),
2001
+ )
2002
+ return True
2003
+ return False
2004
+
2005
+
2006
+ def release_stale_claims(
2007
+ conn: sqlite3.Connection,
2008
+ *,
2009
+ signal_fn=None,
2010
+ ) -> int:
2011
+ """Reset any ``running`` task whose claim has expired.
2012
+
2013
+ A stale-by-TTL claim whose host-local worker PID is still alive is
2014
+ *extended* (with a ``claim_extended`` event) instead of being
2015
+ reclaimed. Reclaiming a live worker mid-flight produces the spawn-
2016
+ then-immediately-reclaim loop seen on slow models that spend longer
2017
+ than ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM
2018
+ call (#23025): no tool calls means no ``kanban_heartbeat``, even
2019
+ though the subprocess is healthy. ``enforce_max_runtime`` and
2020
+ ``detect_crashed_workers`` remain the upper bounds for genuinely
2021
+ wedged or dead workers.
2022
+
2023
+ Returns the number of stale claims actually reclaimed (live-pid
2024
+ extensions don't count). Safe to call often.
2025
+ """
2026
+ now = int(time.time())
2027
+ reclaimed = 0
2028
+ host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
2029
+ stale = conn.execute(
2030
+ "SELECT id, claim_lock, worker_pid, claim_expires, last_heartbeat_at "
2031
+ "FROM tasks "
2032
+ "WHERE status = 'running' AND claim_expires IS NOT NULL "
2033
+ " AND claim_expires < ?",
2034
+ (now,),
2035
+ ).fetchall()
2036
+ for row in stale:
2037
+ lock = row["claim_lock"] or ""
2038
+ host_local = lock.startswith(host_prefix)
2039
+ if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]):
2040
+ new_expires = now + int(DEFAULT_CLAIM_TTL_SECONDS)
2041
+ with write_txn(conn):
2042
+ cur = conn.execute(
2043
+ "UPDATE tasks SET claim_expires = ? "
2044
+ "WHERE id = ? AND status = 'running' "
2045
+ " AND claim_lock IS ? "
2046
+ " AND claim_expires IS NOT NULL "
2047
+ " AND claim_expires < ?",
2048
+ (new_expires, row["id"], row["claim_lock"], now),
2049
+ )
2050
+ if cur.rowcount != 1:
2051
+ continue
2052
+ run_id = _current_run_id(conn, row["id"])
2053
+ if run_id is not None:
2054
+ conn.execute(
2055
+ "UPDATE task_runs SET claim_expires = ? WHERE id = ?",
2056
+ (new_expires, run_id),
2057
+ )
2058
+ _append_event(
2059
+ conn, row["id"], "claim_extended",
2060
+ {
2061
+ "reason": "pid_alive",
2062
+ "worker_pid": int(row["worker_pid"]),
2063
+ "claim_lock": row["claim_lock"],
2064
+ "claim_expires_was": int(row["claim_expires"]),
2065
+ "claim_expires_now": new_expires,
2066
+ "last_heartbeat_at": (
2067
+ int(row["last_heartbeat_at"])
2068
+ if row["last_heartbeat_at"] is not None
2069
+ else None
2070
+ ),
2071
+ },
2072
+ run_id=run_id,
2073
+ )
2074
+ continue
2075
+
2076
+ termination = _terminate_reclaimed_worker(
2077
+ row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
2078
+ )
2079
+ with write_txn(conn):
2080
+ cur = conn.execute(
2081
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
2082
+ "claim_expires = NULL, worker_pid = NULL "
2083
+ "WHERE id = ? AND status = 'running' AND claim_lock IS ? "
2084
+ "AND claim_expires IS NOT NULL AND claim_expires < ?",
2085
+ (row["id"], row["claim_lock"], now),
2086
+ )
2087
+ if cur.rowcount != 1:
2088
+ continue
2089
+ run_id = _end_run(
2090
+ conn, row["id"],
2091
+ outcome="reclaimed", status="reclaimed",
2092
+ error=f"stale_lock={row['claim_lock']}",
2093
+ metadata=termination,
2094
+ )
2095
+ payload = {
2096
+ "stale_lock": row["claim_lock"],
2097
+ "worker_pid": (
2098
+ int(row["worker_pid"])
2099
+ if row["worker_pid"] is not None else None
2100
+ ),
2101
+ "claim_expires": int(row["claim_expires"]),
2102
+ "last_heartbeat_at": (
2103
+ int(row["last_heartbeat_at"])
2104
+ if row["last_heartbeat_at"] is not None else None
2105
+ ),
2106
+ "now": now,
2107
+ "host_local": host_local,
2108
+ }
2109
+ payload.update(termination)
2110
+ _append_event(
2111
+ conn, row["id"], "reclaimed",
2112
+ payload,
2113
+ run_id=run_id,
2114
+ )
2115
+ reclaimed += 1
2116
+ return reclaimed
2117
+
2118
+
2119
+ def reclaim_task(
2120
+ conn: sqlite3.Connection,
2121
+ task_id: str,
2122
+ *,
2123
+ reason: Optional[str] = None,
2124
+ signal_fn=None,
2125
+ ) -> bool:
2126
+ """Operator-driven reclaim: release the claim and reset to ``ready``.
2127
+
2128
+ Unlike :func:`release_stale_claims` which only acts on tasks whose
2129
+ ``claim_expires`` has passed, this function reclaims immediately
2130
+ regardless of TTL. Intended for the dashboard/CLI recovery flow
2131
+ when an operator wants to abort a running worker without waiting
2132
+ for the TTL to expire (e.g. after seeing a hallucination warning).
2133
+
2134
+ Returns True if a reclaim happened, False if the task isn't in a
2135
+ reclaimable state (not running, or doesn't exist).
2136
+ """
2137
+ row = conn.execute(
2138
+ "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
2139
+ (task_id,),
2140
+ ).fetchone()
2141
+ if not row:
2142
+ return False
2143
+ if row["status"] != "running" and row["claim_lock"] is None:
2144
+ # Nothing to reclaim — already ready / blocked / done.
2145
+ return False
2146
+ prev_lock = row["claim_lock"]
2147
+ termination = _terminate_reclaimed_worker(
2148
+ row["worker_pid"], prev_lock, signal_fn=signal_fn,
2149
+ )
2150
+ with write_txn(conn):
2151
+ cur = conn.execute(
2152
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
2153
+ "claim_expires = NULL, worker_pid = NULL "
2154
+ "WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
2155
+ "AND claim_lock IS ?",
2156
+ (task_id, prev_lock),
2157
+ )
2158
+ if cur.rowcount != 1:
2159
+ return False
2160
+ run_id = _end_run(
2161
+ conn, task_id,
2162
+ outcome="reclaimed", status="reclaimed",
2163
+ error=(
2164
+ f"manual_reclaim: {reason}" if reason
2165
+ else f"manual_reclaim lock={prev_lock}"
2166
+ ),
2167
+ metadata=termination,
2168
+ )
2169
+ payload = {
2170
+ "manual": True,
2171
+ "reason": reason,
2172
+ "prev_lock": prev_lock,
2173
+ }
2174
+ payload.update(termination)
2175
+ _append_event(
2176
+ conn, task_id, "reclaimed",
2177
+ payload,
2178
+ run_id=run_id,
2179
+ )
2180
+ # Operator intervention — they've looked at the task, so the
2181
+ # consecutive-failures counter is now stale. Give the next retry
2182
+ # a fresh budget. (_clear_failure_counter opens its own write_txn,
2183
+ # so it runs after the enclosing one commits.)
2184
+ _clear_failure_counter(conn, task_id)
2185
+ return True
2186
+
2187
+
2188
+ def reassign_task(
2189
+ conn: sqlite3.Connection,
2190
+ task_id: str,
2191
+ profile: Optional[str],
2192
+ *,
2193
+ reclaim_first: bool = False,
2194
+ reason: Optional[str] = None,
2195
+ ) -> bool:
2196
+ """Reassign a task, optionally reclaiming a stuck running worker first.
2197
+
2198
+ This is the recovery path for "this profile's model is broken, try
2199
+ a different one". If ``reclaim_first`` is True, any active claim is
2200
+ released (via :func:`reclaim_task`) before the reassign happens;
2201
+ otherwise the function refuses to reassign a currently-running task
2202
+ and returns False (caller can retry with ``reclaim_first=True``).
2203
+
2204
+ Returns True if the reassign landed. ``profile`` may be ``None`` to
2205
+ unassign entirely.
2206
+ """
2207
+ if reclaim_first:
2208
+ # Safe to call even if nothing to reclaim.
2209
+ reclaim_task(conn, task_id, reason=reason or "reassign")
2210
+ # assign_task handles its own txn + the still-running guard.
2211
+ try:
2212
+ return assign_task(conn, task_id, profile)
2213
+ except RuntimeError:
2214
+ # Task is still running and reclaim_first was False; caller
2215
+ # needs to decide whether to retry with reclaim.
2216
+ return False
2217
+
2218
+
2219
+ def _verify_created_cards(
2220
+ conn: sqlite3.Connection,
2221
+ completing_task_id: str,
2222
+ claimed_ids: Iterable[str],
2223
+ ) -> tuple[list[str], list[str]]:
2224
+ """Partition ``claimed_ids`` into (verified, phantom).
2225
+
2226
+ A card is "verified" iff a row exists in ``tasks`` AND at least one
2227
+ of the following holds:
2228
+
2229
+ * ``created_by`` matches the completing task's ``assignee`` profile
2230
+ (the common case: worker A spawns a card via ``kanban_create``,
2231
+ which stamps ``created_by=A``).
2232
+ * ``created_by`` matches the completing task's id (edge case where
2233
+ a worker passed its own task id as the ``created_by`` value).
2234
+ * The card is linked as a ``task_links.child`` of the completing
2235
+ task — i.e. the worker explicitly called ``kanban_create`` with
2236
+ ``parents=[<current_task>]``. This accepts cards created through
2237
+ the dashboard/CLI by a different principal but then attached to
2238
+ the completing task by the worker.
2239
+
2240
+ ``phantom`` returns ids that either don't exist at all, or exist
2241
+ but don't satisfy any of the three trust conditions. The caller
2242
+ decides what to do with each bucket; this helper never mutates.
2243
+ """
2244
+ claimed = [str(x).strip() for x in (claimed_ids or []) if str(x).strip()]
2245
+ if not claimed:
2246
+ return [], []
2247
+ # Dedupe while preserving order.
2248
+ seen: set[str] = set()
2249
+ ordered: list[str] = []
2250
+ for cid in claimed:
2251
+ if cid not in seen:
2252
+ seen.add(cid)
2253
+ ordered.append(cid)
2254
+
2255
+ row = conn.execute(
2256
+ "SELECT assignee FROM tasks WHERE id = ?", (completing_task_id,),
2257
+ ).fetchone()
2258
+ if row is None:
2259
+ # Completing task not found — nothing resolves.
2260
+ return [], ordered
2261
+ completing_assignee = row["assignee"]
2262
+
2263
+ # Batch-fetch existence + created_by in one query.
2264
+ placeholders = ",".join(["?"] * len(ordered))
2265
+ rows = conn.execute(
2266
+ f"SELECT id, created_by FROM tasks WHERE id IN ({placeholders})",
2267
+ tuple(ordered),
2268
+ ).fetchall()
2269
+ found = {r["id"]: r["created_by"] for r in rows}
2270
+
2271
+ # Pull the set of cards linked as children of the completing task.
2272
+ # Cheap: one query, indexed on parent_id.
2273
+ linked_children: set[str] = set(child_ids(conn, completing_task_id))
2274
+
2275
+ verified: list[str] = []
2276
+ phantom: list[str] = []
2277
+ for cid in ordered:
2278
+ created_by = found.get(cid)
2279
+ if created_by is None:
2280
+ phantom.append(cid)
2281
+ continue
2282
+ # Accept if any of the three trust conditions holds.
2283
+ if completing_assignee and created_by == completing_assignee:
2284
+ verified.append(cid)
2285
+ elif created_by == completing_task_id:
2286
+ verified.append(cid)
2287
+ elif cid in linked_children:
2288
+ verified.append(cid)
2289
+ else:
2290
+ phantom.append(cid)
2291
+ return verified, phantom
2292
+
2293
+
2294
+ # Task-id pattern used both by ``kanban_create`` (``t_<12 hex>``) and
2295
+ # ``_new_task_id`` below. Kept permissive on length for forward compat:
2296
+ # accept 8+ hex chars after the ``t_`` prefix.
2297
+ _TASK_ID_PROSE_RE = re.compile(r"\bt_[a-f0-9]{8,}\b")
2298
+
2299
+
2300
+ def _scan_prose_for_phantom_ids(
2301
+ conn: sqlite3.Connection,
2302
+ text: str,
2303
+ ) -> list[str]:
2304
+ """Regex-scan free-form text for ``t_<hex>`` references; return the
2305
+ ones that don't exist in ``tasks``.
2306
+
2307
+ Used as a non-blocking advisory check on completion summaries. An
2308
+ empty return means "no suspicious references found" — either the
2309
+ text had no IDs at all, or every ID it mentioned resolves to a real
2310
+ task. Duplicates are deduped.
2311
+ """
2312
+ if not text:
2313
+ return []
2314
+ matches = _TASK_ID_PROSE_RE.findall(text)
2315
+ if not matches:
2316
+ return []
2317
+ # Dedupe preserving order.
2318
+ seen: set[str] = set()
2319
+ unique: list[str] = []
2320
+ for m in matches:
2321
+ if m not in seen:
2322
+ seen.add(m)
2323
+ unique.append(m)
2324
+ placeholders = ",".join(["?"] * len(unique))
2325
+ rows = conn.execute(
2326
+ f"SELECT id FROM tasks WHERE id IN ({placeholders})",
2327
+ tuple(unique),
2328
+ ).fetchall()
2329
+ existing = {r["id"] for r in rows}
2330
+ return [m for m in unique if m not in existing]
2331
+
2332
+
2333
+ class HallucinatedCardsError(ValueError):
2334
+ """Raised by ``complete_task`` when ``created_cards`` contains ids
2335
+ that don't exist or weren't created by the completing worker.
2336
+
2337
+ The phantom list is attached as ``.phantom`` for callers that want
2338
+ structured access. Kept as ``ValueError`` subclass so existing
2339
+ tool-error handlers treat it as a recoverable user error.
2340
+ """
2341
+
2342
+ def __init__(self, phantom: list[str], completing_task_id: str):
2343
+ self.phantom = list(phantom)
2344
+ self.completing_task_id = completing_task_id
2345
+ super().__init__(
2346
+ f"completion blocked: claimed created_cards that do not exist "
2347
+ f"or were not created by this worker: {', '.join(phantom)}"
2348
+ )
2349
+
2350
+
2351
+ def complete_task(
2352
+ conn: sqlite3.Connection,
2353
+ task_id: str,
2354
+ *,
2355
+ result: Optional[str] = None,
2356
+ summary: Optional[str] = None,
2357
+ metadata: Optional[dict] = None,
2358
+ created_cards: Optional[Iterable[str]] = None,
2359
+ expected_run_id: Optional[int] = None,
2360
+ ) -> bool:
2361
+ """Transition ``running|ready -> done`` and record ``result``.
2362
+
2363
+ Accepts a task that is merely ``ready`` too, so a manual CLI
2364
+ completion (``hermes kanban complete <id>``) works without requiring
2365
+ a claim/start/complete sequence.
2366
+
2367
+ ``summary`` and ``metadata`` are stored on the closing run (if any)
2368
+ and surfaced to downstream children via :func:`build_worker_context`.
2369
+ When ``summary`` is omitted we fall back to ``result`` so single-run
2370
+ callers do not have to pass both. ``metadata`` is a free-form dict
2371
+ (e.g. ``{"changed_files": [...], "tests_run": [...]}``) — workers
2372
+ are encouraged to use it for structured handoff facts.
2373
+
2374
+ ``created_cards`` is an optional list of task ids the completing
2375
+ worker claims to have created. Each id is verified against
2376
+ ``tasks.created_by``. If any id is phantom (does not exist or was
2377
+ not created by this worker's assignee profile), completion is blocked
2378
+ with a ``HallucinatedCardsError`` and a
2379
+ ``completion_blocked_hallucination`` event is emitted so the rejected
2380
+ attempt is auditable. When all ids verify, they are recorded on the
2381
+ ``completed`` event payload.
2382
+
2383
+ After a successful completion, ``summary`` and ``result`` are scanned
2384
+ for prose references like ``t_deadbeefcafe`` that do not resolve.
2385
+ Any suspected phantom references are recorded as a
2386
+ ``suspected_hallucinated_references`` event. This pass is advisory
2387
+ and never blocks.
2388
+ """
2389
+ now = int(time.time())
2390
+
2391
+ # Gate: verify created_cards BEFORE the main write txn. A rejected
2392
+ # completion still needs an auditable event, so we emit it in a
2393
+ # tiny dedicated txn, then raise. The caller is responsible for
2394
+ # surfacing HallucinatedCardsError to the worker; this function
2395
+ # never mutates task state on a phantom-card rejection.
2396
+ if created_cards:
2397
+ verified_cards, phantom_cards = _verify_created_cards(
2398
+ conn, task_id, created_cards
2399
+ )
2400
+ if phantom_cards:
2401
+ with write_txn(conn):
2402
+ _append_event(
2403
+ conn, task_id, "completion_blocked_hallucination",
2404
+ {
2405
+ "phantom_cards": phantom_cards,
2406
+ "verified_cards": verified_cards,
2407
+ "summary_preview": (
2408
+ (summary or result or "").strip().splitlines()[0][:200]
2409
+ if (summary or result)
2410
+ else None
2411
+ ),
2412
+ },
2413
+ )
2414
+ raise HallucinatedCardsError(phantom_cards, task_id)
2415
+ else:
2416
+ verified_cards = []
2417
+
2418
+ with write_txn(conn):
2419
+ if expected_run_id is None:
2420
+ cur = conn.execute(
2421
+ """
2422
+ UPDATE tasks
2423
+ SET status = 'done',
2424
+ result = ?,
2425
+ completed_at = ?,
2426
+ claim_lock = NULL,
2427
+ claim_expires= NULL,
2428
+ worker_pid = NULL
2429
+ WHERE id = ?
2430
+ AND status IN ('running', 'ready', 'blocked')
2431
+ """,
2432
+ (result, now, task_id),
2433
+ )
2434
+ else:
2435
+ cur = conn.execute(
2436
+ """
2437
+ UPDATE tasks
2438
+ SET status = 'done',
2439
+ result = ?,
2440
+ completed_at = ?,
2441
+ claim_lock = NULL,
2442
+ claim_expires= NULL,
2443
+ worker_pid = NULL
2444
+ WHERE id = ?
2445
+ AND status IN ('running', 'ready', 'blocked')
2446
+ AND current_run_id = ?
2447
+ """,
2448
+ (result, now, task_id, int(expected_run_id)),
2449
+ )
2450
+ if cur.rowcount != 1:
2451
+ return False
2452
+ run_id = _end_run(
2453
+ conn, task_id,
2454
+ outcome="completed", status="done",
2455
+ summary=summary if summary is not None else result,
2456
+ metadata=metadata,
2457
+ )
2458
+ # If complete_task was called on a never-claimed task (ready or
2459
+ # blocked → done with no run in flight), synthesize a
2460
+ # zero-duration run so the handoff fields are persisted in
2461
+ # attempt history instead of silently lost.
2462
+ if run_id is None and (summary or metadata or result):
2463
+ run_id = _synthesize_ended_run(
2464
+ conn, task_id,
2465
+ outcome="completed",
2466
+ summary=summary if summary is not None else result,
2467
+ metadata=metadata,
2468
+ )
2469
+ # Carry the handoff summary in the event payload so gateway
2470
+ # notifiers and dashboard WS consumers can render it without a
2471
+ # second SQL round-trip. First line only, 400 char cap — the
2472
+ # full summary stays on the run row.
2473
+ ev_summary = (summary if summary is not None else result) or ""
2474
+ ev_summary = ev_summary.strip().splitlines()[0][:400] if ev_summary else ""
2475
+ completed_payload: dict = {
2476
+ "result_len": len(result) if result else 0,
2477
+ "summary": ev_summary or None,
2478
+ }
2479
+ if verified_cards:
2480
+ completed_payload["verified_cards"] = verified_cards
2481
+ _append_event(
2482
+ conn, task_id, "completed",
2483
+ completed_payload,
2484
+ run_id=run_id,
2485
+ )
2486
+ # Prose-scan the summary + result for t_<hex> references that do
2487
+ # not resolve. Advisory — does not block the completion. Runs in
2488
+ # its own txn so the completion itself is already durable by the
2489
+ # time we emit the warning.
2490
+ scan_text = " ".join(filter(None, [summary, result]))
2491
+ if scan_text:
2492
+ phantom_refs = _scan_prose_for_phantom_ids(conn, scan_text)
2493
+ # Drop any phantom refs that were already flagged as verified
2494
+ # above (shouldn't happen — verified means they exist — but
2495
+ # belt-and-suspenders).
2496
+ phantom_refs = [p for p in phantom_refs if p not in set(verified_cards)]
2497
+ if phantom_refs:
2498
+ with write_txn(conn):
2499
+ _append_event(
2500
+ conn, task_id, "suspected_hallucinated_references",
2501
+ {
2502
+ "phantom_refs": phantom_refs,
2503
+ "source": "completion_summary",
2504
+ },
2505
+ run_id=run_id,
2506
+ )
2507
+ # Successful completion — wipe the consecutive-failures counter.
2508
+ # Failure history stays on the event log for audit; the counter
2509
+ # just tracks "is there a current pathology the breaker should
2510
+ # care about", and a success resets that question.
2511
+ _clear_failure_counter(conn, task_id)
2512
+ # Recompute ready status for dependents (separate txn so children see done).
2513
+ recompute_ready(conn)
2514
+ return True
2515
+
2516
+
2517
+ def edit_completed_task_result(
2518
+ conn: sqlite3.Connection,
2519
+ task_id: str,
2520
+ *,
2521
+ result: str,
2522
+ summary: Optional[str] = None,
2523
+ metadata: Optional[dict] = None,
2524
+ ) -> bool:
2525
+ """Backfill the user-visible result for an already completed task."""
2526
+ handoff_summary = summary if summary is not None else result
2527
+ with write_txn(conn):
2528
+ row = conn.execute(
2529
+ "SELECT status FROM tasks WHERE id = ?", (task_id,),
2530
+ ).fetchone()
2531
+ if not row or row["status"] != "done":
2532
+ return False
2533
+ conn.execute(
2534
+ "UPDATE tasks SET result = ? WHERE id = ?",
2535
+ (result, task_id),
2536
+ )
2537
+ run = conn.execute(
2538
+ """
2539
+ SELECT id FROM task_runs
2540
+ WHERE task_id = ?
2541
+ AND outcome = 'completed'
2542
+ ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
2543
+ LIMIT 1
2544
+ """,
2545
+ (task_id,),
2546
+ ).fetchone()
2547
+ run_id = int(run["id"]) if run else None
2548
+ if run_id is None:
2549
+ run_id = _synthesize_ended_run(
2550
+ conn, task_id,
2551
+ outcome="completed",
2552
+ summary=handoff_summary,
2553
+ metadata=metadata,
2554
+ )
2555
+ else:
2556
+ conn.execute(
2557
+ "UPDATE task_runs SET summary = ? WHERE id = ?",
2558
+ (handoff_summary, run_id),
2559
+ )
2560
+ if metadata is not None:
2561
+ conn.execute(
2562
+ "UPDATE task_runs SET metadata = ? WHERE id = ?",
2563
+ (json.dumps(metadata, ensure_ascii=False), run_id),
2564
+ )
2565
+ ev_summary = (
2566
+ handoff_summary.strip().splitlines()[0][:400]
2567
+ if handoff_summary else ""
2568
+ )
2569
+ _append_event(
2570
+ conn, task_id, "edited",
2571
+ {
2572
+ "fields": (
2573
+ ["result", "summary"]
2574
+ + (["metadata"] if metadata is not None else [])
2575
+ ),
2576
+ "result_len": len(result) if result else 0,
2577
+ "summary": ev_summary or None,
2578
+ },
2579
+ run_id=run_id,
2580
+ )
2581
+ return True
2582
+
2583
+
2584
+ def block_task(
2585
+ conn: sqlite3.Connection,
2586
+ task_id: str,
2587
+ *,
2588
+ reason: Optional[str] = None,
2589
+ expected_run_id: Optional[int] = None,
2590
+ ) -> bool:
2591
+ """Transition ``running -> blocked``."""
2592
+ with write_txn(conn):
2593
+ if expected_run_id is None:
2594
+ cur = conn.execute(
2595
+ """
2596
+ UPDATE tasks
2597
+ SET status = 'blocked',
2598
+ claim_lock = NULL,
2599
+ claim_expires= NULL,
2600
+ worker_pid = NULL
2601
+ WHERE id = ?
2602
+ AND status IN ('running', 'ready')
2603
+ """,
2604
+ (task_id,),
2605
+ )
2606
+ else:
2607
+ cur = conn.execute(
2608
+ """
2609
+ UPDATE tasks
2610
+ SET status = 'blocked',
2611
+ claim_lock = NULL,
2612
+ claim_expires= NULL,
2613
+ worker_pid = NULL
2614
+ WHERE id = ?
2615
+ AND status IN ('running', 'ready')
2616
+ AND current_run_id = ?
2617
+ """,
2618
+ (task_id, int(expected_run_id)),
2619
+ )
2620
+ if cur.rowcount != 1:
2621
+ return False
2622
+ run_id = _end_run(
2623
+ conn, task_id,
2624
+ outcome="blocked", status="blocked",
2625
+ summary=reason,
2626
+ )
2627
+ # Synthesize a run when blocking a never-claimed task so the
2628
+ # reason is preserved in attempt history.
2629
+ if run_id is None and reason:
2630
+ run_id = _synthesize_ended_run(
2631
+ conn, task_id,
2632
+ outcome="blocked",
2633
+ summary=reason,
2634
+ )
2635
+ _append_event(conn, task_id, "blocked", {"reason": reason}, run_id=run_id)
2636
+ return True
2637
+
2638
+
2639
+ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
2640
+ """Transition ``blocked -> ready``.
2641
+
2642
+ Defensively closes any stale ``current_run_id`` pointer before flipping
2643
+ status. In the common path (``block_task`` closed the run already) this
2644
+ is a no-op. If a future or external write left the pointer dangling,
2645
+ the leaked run is closed as ``reclaimed`` inside the same txn so the
2646
+ runs invariant (``current_run_id IS NULL`` ⇔ run row in terminal
2647
+ state) holds for the rest of this function's lifetime.
2648
+ """
2649
+ now = int(time.time())
2650
+ with write_txn(conn):
2651
+ stale = conn.execute(
2652
+ "SELECT current_run_id FROM tasks WHERE id = ? AND status = 'blocked'",
2653
+ (task_id,),
2654
+ ).fetchone()
2655
+ if stale and stale["current_run_id"]:
2656
+ conn.execute(
2657
+ """
2658
+ UPDATE task_runs
2659
+ SET status = 'reclaimed', outcome = 'reclaimed',
2660
+ summary = COALESCE(summary, 'invariant recovery on unblock'),
2661
+ ended_at = ?,
2662
+ claim_lock = NULL, claim_expires = NULL, worker_pid = NULL
2663
+ WHERE id = ? AND ended_at IS NULL
2664
+ """,
2665
+ (now, int(stale["current_run_id"])),
2666
+ )
2667
+ # Re-gate on parent completion before flipping 'blocked' back to
2668
+ # 'ready'. Unconditionally setting status='ready' here bypasses the
2669
+ # parent-completion invariant (the dispatcher trusts that column);
2670
+ # if parents are still in progress the task must wait in 'todo'
2671
+ # until recompute_ready picks it up. RCA: Bug 2 at
2672
+ # kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
2673
+ undone_parents = conn.execute(
2674
+ "SELECT 1 FROM task_links l "
2675
+ "JOIN tasks p ON p.id = l.parent_id "
2676
+ "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
2677
+ (task_id,),
2678
+ ).fetchone()
2679
+ new_status = "todo" if undone_parents else "ready"
2680
+ cur = conn.execute(
2681
+ "UPDATE tasks SET status = ?, current_run_id = NULL "
2682
+ "WHERE id = ? AND status = 'blocked'",
2683
+ (new_status, task_id),
2684
+ )
2685
+ if cur.rowcount != 1:
2686
+ return False
2687
+ _append_event(
2688
+ conn, task_id, "unblocked",
2689
+ {"status": new_status} if new_status != "ready" else None,
2690
+ )
2691
+ return True
2692
+
2693
+
2694
+ def specify_triage_task(
2695
+ conn: sqlite3.Connection,
2696
+ task_id: str,
2697
+ *,
2698
+ title: Optional[str] = None,
2699
+ body: Optional[str] = None,
2700
+ author: Optional[str] = None,
2701
+ ) -> bool:
2702
+ """Flesh out a triage task and promote it to ``todo``.
2703
+
2704
+ Atomically updates ``title`` / ``body`` (when provided) and transitions
2705
+ ``status: triage -> todo`` in a single write txn. Returns False when
2706
+ the task is missing or not in the ``triage`` column — callers should
2707
+ surface that as "nothing to specify" rather than an error.
2708
+
2709
+ ``todo`` (not ``ready``) is the correct landing column: ``recompute_ready``
2710
+ promotes parent-free / parent-done todos to ``ready`` on the next
2711
+ dispatcher tick, which keeps the normal parent-gating behaviour intact
2712
+ for specified tasks that happen to have open parents.
2713
+
2714
+ ``author`` is recorded on an audit comment only when at least one of
2715
+ ``title`` / ``body`` actually changed — avoids noisy comment spam for
2716
+ status-only promotions.
2717
+ """
2718
+ if title is not None and not title.strip():
2719
+ raise ValueError("title cannot be blank")
2720
+ with write_txn(conn):
2721
+ existing = conn.execute(
2722
+ "SELECT title, body FROM tasks WHERE id = ? AND status = 'triage'",
2723
+ (task_id,),
2724
+ ).fetchone()
2725
+ if existing is None:
2726
+ return False
2727
+ sets: list[str] = ["status = 'todo'"]
2728
+ params: list[Any] = []
2729
+ changed_fields: list[str] = []
2730
+ if title is not None and title.strip() != (existing["title"] or ""):
2731
+ sets.append("title = ?")
2732
+ params.append(title.strip())
2733
+ changed_fields.append("title")
2734
+ if body is not None and (body or "") != (existing["body"] or ""):
2735
+ sets.append("body = ?")
2736
+ params.append(body)
2737
+ changed_fields.append("body")
2738
+ params.append(task_id)
2739
+ cur = conn.execute(
2740
+ f"UPDATE tasks SET {', '.join(sets)} "
2741
+ f"WHERE id = ? AND status = 'triage'",
2742
+ tuple(params),
2743
+ )
2744
+ if cur.rowcount != 1:
2745
+ return False
2746
+ if changed_fields and author and author.strip():
2747
+ # Inline INSERT (rather than ``add_comment``) because we're
2748
+ # already inside this function's write_txn — nested BEGIN
2749
+ # IMMEDIATE would raise OperationalError. We also skip the
2750
+ # 'commented' event that ``add_comment`` emits, since the
2751
+ # 'specified' event below already records the change.
2752
+ conn.execute(
2753
+ "INSERT INTO task_comments (task_id, author, body, created_at) "
2754
+ "VALUES (?, ?, ?, ?)",
2755
+ (
2756
+ task_id,
2757
+ author.strip(),
2758
+ "Specified — updated "
2759
+ + ", ".join(changed_fields)
2760
+ + " and promoted to todo.",
2761
+ int(time.time()),
2762
+ ),
2763
+ )
2764
+ _append_event(
2765
+ conn,
2766
+ task_id,
2767
+ "specified",
2768
+ {"changed_fields": changed_fields} if changed_fields else None,
2769
+ )
2770
+ # Outside the write_txn above, so we don't nest BEGIN IMMEDIATE — the
2771
+ # ready-promotion pass opens its own IMMEDIATE txn. This runs the same
2772
+ # logic the dispatcher would on its next tick, so a specified task
2773
+ # with no open parents flips straight to 'ready' here instead of
2774
+ # idling in 'todo' until the next sweep.
2775
+ recompute_ready(conn)
2776
+ return True
2777
+
2778
+
2779
+ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
2780
+ with write_txn(conn):
2781
+ cur = conn.execute(
2782
+ "UPDATE tasks SET status = 'archived', "
2783
+ " claim_lock = NULL, claim_expires = NULL, worker_pid = NULL "
2784
+ "WHERE id = ? AND status != 'archived'",
2785
+ (task_id,),
2786
+ )
2787
+ if cur.rowcount != 1:
2788
+ return False
2789
+ # If archive happened while a run was still in flight (e.g. user
2790
+ # archived a running task from the dashboard), close that run with
2791
+ # outcome='reclaimed' so attempt history isn't orphaned.
2792
+ run_id = _end_run(
2793
+ conn, task_id,
2794
+ outcome="reclaimed", status="reclaimed",
2795
+ summary="task archived with run still active",
2796
+ )
2797
+ _append_event(conn, task_id, "archived", None, run_id=run_id)
2798
+ return True
2799
+
2800
+
2801
+ # ---------------------------------------------------------------------------
2802
+ # Workspace resolution
2803
+ # ---------------------------------------------------------------------------
2804
+
2805
+ def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
2806
+ """Resolve (and create if needed) the workspace for a task.
2807
+
2808
+ - ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
2809
+ where ``<board-root>`` is the active board's root. The path is the
2810
+ same for the dispatcher and every profile worker, so handoff is
2811
+ path-stable.
2812
+ - ``dir:<path>``: the path stored in ``workspace_path``. Created
2813
+ if missing. MUST be absolute — relative paths are rejected to
2814
+ prevent confused-deputy traversal where ``../../../tmp/attacker``
2815
+ resolves against the dispatcher's CWD instead of a meaningful
2816
+ root. Users who want a kanban-root-relative workspace should
2817
+ compute the absolute path themselves.
2818
+ - ``worktree``: a git worktree at ``workspace_path``. Not created
2819
+ automatically in v1 -- the kanban-worker skill documents
2820
+ ``git worktree add`` as a worker-side step. Returns the intended path.
2821
+
2822
+ Persist the resolved path back to the task row via ``set_workspace_path``
2823
+ so subsequent runs reuse the same directory.
2824
+ """
2825
+ kind = task.workspace_kind or "scratch"
2826
+ if kind == "scratch":
2827
+ if task.workspace_path:
2828
+ # Legacy scratch tasks that were set to an explicit path get the
2829
+ # same absolute-path guard as dir: — consistent with the
2830
+ # threat model.
2831
+ p = Path(task.workspace_path).expanduser()
2832
+ if not p.is_absolute():
2833
+ raise ValueError(
2834
+ f"task {task.id} has non-absolute workspace_path "
2835
+ f"{task.workspace_path!r}; workspace paths must be absolute"
2836
+ )
2837
+ else:
2838
+ p = workspaces_root(board=board) / task.id
2839
+ p.mkdir(parents=True, exist_ok=True)
2840
+ return p
2841
+ if kind == "dir":
2842
+ if not task.workspace_path:
2843
+ raise ValueError(
2844
+ f"task {task.id} has workspace_kind=dir but no workspace_path"
2845
+ )
2846
+ p = Path(task.workspace_path).expanduser()
2847
+ if not p.is_absolute():
2848
+ raise ValueError(
2849
+ f"task {task.id} has non-absolute workspace_path "
2850
+ f"{task.workspace_path!r}; use an absolute path "
2851
+ f"(relative paths are ambiguous against the dispatcher's CWD)"
2852
+ )
2853
+ p.mkdir(parents=True, exist_ok=True)
2854
+ return p
2855
+ if kind == "worktree":
2856
+ if not task.workspace_path:
2857
+ # Default: .worktrees/<id>/ under CWD. Worker skill creates it.
2858
+ return Path.cwd() / ".worktrees" / task.id
2859
+ p = Path(task.workspace_path).expanduser()
2860
+ if not p.is_absolute():
2861
+ raise ValueError(
2862
+ f"task {task.id} has non-absolute worktree path "
2863
+ f"{task.workspace_path!r}; use an absolute path"
2864
+ )
2865
+ return p
2866
+ raise ValueError(f"unknown workspace_kind: {kind}")
2867
+
2868
+
2869
+ def set_workspace_path(
2870
+ conn: sqlite3.Connection, task_id: str, path: Path | str
2871
+ ) -> None:
2872
+ with write_txn(conn):
2873
+ conn.execute(
2874
+ "UPDATE tasks SET workspace_path = ? WHERE id = ?",
2875
+ (str(path), task_id),
2876
+ )
2877
+
2878
+
2879
+ # ---------------------------------------------------------------------------
2880
+ # Dispatcher (one-shot pass)
2881
+ # ---------------------------------------------------------------------------
2882
+
2883
+ # After this many consecutive non-success attempts on a task/profile, the
2884
+ # dispatcher stops retrying and parks the task in ``blocked`` with a reason so
2885
+ # a human can investigate. Prevents retry storms when a worker repeatedly times
2886
+ # out, crashes, or cannot spawn.
2887
+ DEFAULT_FAILURE_LIMIT = 2
2888
+ # Legacy alias — callers / tests still reference the old name.
2889
+ DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT
2890
+
2891
+ # Max bytes to keep in a single worker log file. The dispatcher truncates
2892
+ # and rotates on spawn if the file is larger than this at spawn time.
2893
+ DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
2894
+
2895
+
2896
+ @dataclass
2897
+ class DispatchResult:
2898
+ """Outcome of a single ``dispatch`` pass."""
2899
+
2900
+ reclaimed: int = 0
2901
+ promoted: int = 0
2902
+ spawned: list[tuple[str, str, str]] = field(default_factory=list)
2903
+ """List of ``(task_id, assignee, workspace_path)`` triples."""
2904
+ skipped_unassigned: list[str] = field(default_factory=list)
2905
+ """Ready task ids skipped because they have no assignee at all.
2906
+ Operator-actionable — usually a misfiled task waiting for routing."""
2907
+ skipped_nonspawnable: list[str] = field(default_factory=list)
2908
+ """Ready task ids skipped because their assignee names a control-plane
2909
+ lane (a Claude Code terminal like ``orion-cc``) rather than a Hermes
2910
+ profile. Expected steady-state on multi-lane setups; NOT an
2911
+ operator-actionable failure. Tracked separately so health telemetry
2912
+ can distinguish "real stuck" (nothing spawned but spawnable work
2913
+ available) from "correctly idle" (nothing spawnable in the queue)."""
2914
+ crashed: list[str] = field(default_factory=list)
2915
+ """Task ids reclaimed because their worker PID disappeared."""
2916
+ auto_blocked: list[str] = field(default_factory=list)
2917
+ """Task ids auto-blocked by the spawn-failure circuit breaker."""
2918
+ timed_out: list[str] = field(default_factory=list)
2919
+ """Task ids whose workers exceeded ``max_runtime_seconds``."""
2920
+
2921
+
2922
+ # Bounded registry of recently-reaped worker child exits, populated by the
2923
+ # reap loop at the top of ``dispatch_once`` and consulted by
2924
+ # ``detect_crashed_workers`` to classify a dead-pid task.
2925
+ #
2926
+ # Entry: ``pid -> (raw_wait_status, reaped_at_epoch)``. We keep raw status
2927
+ # so both ``os.WIFEXITED`` / ``os.WEXITSTATUS`` and ``os.WIFSIGNALED`` can
2928
+ # be consulted. Entries are trimmed by age (and total size cap as a
2929
+ # belt-and-braces against unbounded growth on exotic platforms).
2930
+ _RECENT_WORKER_EXIT_TTL_SECONDS = 600
2931
+ _RECENT_WORKER_EXITS_MAX = 4096
2932
+ _recent_worker_exits: "dict[int, tuple[int, float]]" = {}
2933
+
2934
+
2935
+ def _record_worker_exit(pid: int, raw_status: int) -> None:
2936
+ """Record a reaped child's exit status for later classification.
2937
+
2938
+ Called from the reap loop in ``dispatch_once``. Safe to call many
2939
+ times; duplicate pids overwrite (pids can cycle, latest wins).
2940
+ """
2941
+ if not pid or pid <= 0:
2942
+ return
2943
+ now = time.time()
2944
+ _recent_worker_exits[int(pid)] = (int(raw_status), now)
2945
+ # Age-based trim: drop entries older than the TTL.
2946
+ if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX // 2:
2947
+ cutoff = now - _RECENT_WORKER_EXIT_TTL_SECONDS
2948
+ for _pid in [p for p, (_s, t) in _recent_worker_exits.items() if t < cutoff]:
2949
+ _recent_worker_exits.pop(_pid, None)
2950
+ # Size cap as a final guard.
2951
+ if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX:
2952
+ # Drop oldest half.
2953
+ ordered = sorted(_recent_worker_exits.items(), key=lambda kv: kv[1][1])
2954
+ for _pid, _ in ordered[: len(ordered) // 2]:
2955
+ _recent_worker_exits.pop(_pid, None)
2956
+
2957
+
2958
+ def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
2959
+ """Classify a recently-reaped worker by pid.
2960
+
2961
+ Returns ``(kind, code)`` where ``kind`` is one of:
2962
+
2963
+ * ``"clean_exit"`` — ``WIFEXITED`` with ``WEXITSTATUS == 0``. When the
2964
+ task is still ``running`` in the DB, this is a protocol violation
2965
+ (worker exited without calling ``kanban_complete`` / ``kanban_block``)
2966
+ and should be auto-blocked immediately — retrying will just loop.
2967
+ * ``"nonzero_exit"`` — ``WIFEXITED`` with non-zero status. Real error.
2968
+ * ``"signaled"`` — ``WIFSIGNALED`` (OOM killer, SIGKILL, etc). Real crash.
2969
+ * ``"unknown"`` — pid was not in the reap registry (either reaped by
2970
+ something else, or died between reap tick and liveness check). Fall
2971
+ back to existing crashed-counter behavior.
2972
+
2973
+ ``code`` is the exit status (for ``clean_exit`` / ``nonzero_exit``) or
2974
+ the signal number (for ``signaled``), or ``None`` for ``unknown``.
2975
+ """
2976
+ entry = _recent_worker_exits.get(int(pid))
2977
+ if entry is None:
2978
+ return ("unknown", None)
2979
+ raw, _ = entry
2980
+ try:
2981
+ if os.WIFEXITED(raw):
2982
+ code = os.WEXITSTATUS(raw)
2983
+ if code == 0:
2984
+ return ("clean_exit", 0)
2985
+ return ("nonzero_exit", code)
2986
+ if os.WIFSIGNALED(raw):
2987
+ return ("signaled", os.WTERMSIG(raw))
2988
+ except Exception:
2989
+ pass
2990
+ return ("unknown", None)
2991
+
2992
+
2993
+ def _pid_alive(pid: Optional[int]) -> bool:
2994
+ """Return True if ``pid`` is still running on this host.
2995
+
2996
+ Cross-platform: uses ``OpenProcess`` + ``WaitForSingleObject`` on
2997
+ Windows (via ``gateway.status._pid_exists``) and ``os.kill(pid, 0)``
2998
+ on POSIX. Returns False for falsy PIDs or on any OS error.
2999
+
3000
+ **DO NOT** use ``os.kill(pid, 0)`` directly on Windows — Python's
3001
+ Windows ``os.kill`` treats ``sig=0`` as ``CTRL_C_EVENT`` (bpo-14484)
3002
+ and will broadcast it to the target's console group, potentially
3003
+ killing unrelated processes.
3004
+
3005
+ **Zombie handling:** the existence check succeeds against zombie
3006
+ processes (post-exit, pre-reap) because the process table entry
3007
+ still exists. A worker that exits without being reaped by its
3008
+ parent would stay "alive" to the dispatcher forever. Dispatcher
3009
+ workers are started via ``start_new_session=True`` + intentional
3010
+ Popen handle abandonment, so init reaps them quickly — but during
3011
+ the window between exit and reap, we'd otherwise see stale "alive"
3012
+ signals. On Linux we peek at ``/proc/<pid>/status`` and treat
3013
+ ``State: Z`` as dead. On macOS we ask ``ps`` for the BSD ``stat``
3014
+ field and treat values containing ``Z`` as dead.
3015
+ """
3016
+ if not pid or pid <= 0:
3017
+ return False
3018
+ from gateway.status import _pid_exists
3019
+ if not _pid_exists(int(pid)):
3020
+ return False
3021
+ # Still here → process exists. Check for zombie on platforms
3022
+ # where we have a cheap, deterministic process-state probe.
3023
+ if sys.platform == "linux":
3024
+ try:
3025
+ with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as f:
3026
+ for line in f:
3027
+ if line.startswith("State:"):
3028
+ # "State:\tZ (zombie)" → dead
3029
+ if "Z" in line.split(":", 1)[1]:
3030
+ return False
3031
+ break
3032
+ except (FileNotFoundError, PermissionError, OSError):
3033
+ # proc entry gone → already reaped; treat as dead.
3034
+ # PermissionError shouldn't happen for our own children but
3035
+ # be defensive.
3036
+ pass
3037
+ elif sys.platform == "darwin":
3038
+ try:
3039
+ proc = subprocess.run(
3040
+ ["ps", "-o", "stat=", "-p", str(int(pid))],
3041
+ stdout=subprocess.PIPE,
3042
+ stderr=subprocess.DEVNULL,
3043
+ text=True,
3044
+ timeout=1,
3045
+ check=False,
3046
+ )
3047
+ if proc.returncode != 0:
3048
+ return False
3049
+ if "Z" in (proc.stdout or "").strip():
3050
+ return False
3051
+ except (OSError, subprocess.SubprocessError, TimeoutError):
3052
+ # If the secondary probe fails, keep the kill(0) answer.
3053
+ pass
3054
+ return True
3055
+
3056
+
3057
+ def _terminate_reclaimed_worker(
3058
+ pid: Optional[int],
3059
+ claim_lock: Optional[str],
3060
+ *,
3061
+ signal_fn=None,
3062
+ ) -> dict[str, Any]:
3063
+ """Best-effort host-local worker termination for reclaim paths."""
3064
+ import signal
3065
+
3066
+ info: dict[str, Any] = {
3067
+ "prev_pid": int(pid) if pid else None,
3068
+ "host_local": False,
3069
+ "termination_attempted": False,
3070
+ "terminated": False,
3071
+ "sigkill": False,
3072
+ }
3073
+ if not pid or pid <= 0 or not claim_lock:
3074
+ return info
3075
+
3076
+ host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
3077
+ if not str(claim_lock).startswith(host_prefix):
3078
+ return info
3079
+ info["host_local"] = True
3080
+
3081
+ kill = signal_fn if signal_fn is not None else (
3082
+ os.kill if hasattr(os, "kill") else None
3083
+ )
3084
+ if kill is None:
3085
+ return info
3086
+
3087
+ info["termination_attempted"] = True
3088
+ try:
3089
+ kill(int(pid), signal.SIGTERM)
3090
+ except (ProcessLookupError, OSError):
3091
+ return info
3092
+
3093
+ for _ in range(10):
3094
+ if not _pid_alive(pid):
3095
+ info["terminated"] = True
3096
+ return info
3097
+ time.sleep(0.5)
3098
+
3099
+ if _pid_alive(pid):
3100
+ try:
3101
+ # signal.SIGKILL doesn't exist on Windows; fall back to SIGTERM
3102
+ # (which maps to TerminateProcess via the stdlib shim).
3103
+ _sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
3104
+ kill(int(pid), _sigkill)
3105
+ info["sigkill"] = True
3106
+ except (ProcessLookupError, OSError):
3107
+ return info
3108
+
3109
+ info["terminated"] = not _pid_alive(pid)
3110
+ return info
3111
+
3112
+
3113
+ def heartbeat_worker(
3114
+ conn: sqlite3.Connection,
3115
+ task_id: str,
3116
+ *,
3117
+ note: Optional[str] = None,
3118
+ expected_run_id: Optional[int] = None,
3119
+ ) -> bool:
3120
+ """Record a ``heartbeat`` event + touch ``last_heartbeat_at``.
3121
+
3122
+ Called by long-running workers as a liveness signal orthogonal to
3123
+ the PID check. A worker that forks a long-lived child (train loop,
3124
+ video encode, web crawl) can have its Python still alive while the
3125
+ actual work process is stuck; periodic heartbeats catch that.
3126
+
3127
+ Returns True on success, False if the task is not in a state that
3128
+ should be heartbeating (not running, or claim expired).
3129
+ """
3130
+ now = int(time.time())
3131
+ with write_txn(conn):
3132
+ if expected_run_id is None:
3133
+ cur = conn.execute(
3134
+ "UPDATE tasks SET last_heartbeat_at = ? "
3135
+ "WHERE id = ? AND status = 'running'",
3136
+ (now, task_id),
3137
+ )
3138
+ else:
3139
+ cur = conn.execute(
3140
+ "UPDATE tasks SET last_heartbeat_at = ? "
3141
+ "WHERE id = ? AND status = 'running' AND current_run_id = ?",
3142
+ (now, task_id, int(expected_run_id)),
3143
+ )
3144
+ if cur.rowcount != 1:
3145
+ return False
3146
+ run_id = (
3147
+ int(expected_run_id)
3148
+ if expected_run_id is not None
3149
+ else _current_run_id(conn, task_id)
3150
+ )
3151
+ if run_id is not None:
3152
+ conn.execute(
3153
+ "UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?",
3154
+ (now, run_id),
3155
+ )
3156
+ _append_event(
3157
+ conn, task_id, "heartbeat",
3158
+ {"note": note} if note else None,
3159
+ run_id=run_id,
3160
+ )
3161
+ return True
3162
+
3163
+
3164
+ def enforce_max_runtime(
3165
+ conn: sqlite3.Connection,
3166
+ *,
3167
+ signal_fn=None,
3168
+ ) -> list[str]:
3169
+ """Terminate workers whose per-task ``max_runtime_seconds`` has elapsed.
3170
+
3171
+ Sends SIGTERM, waits a short grace window, then SIGKILL. Emits a
3172
+ ``timed_out`` event and drops the task back to ``ready`` so the next
3173
+ dispatcher tick re-spawns it — unless the spawn-failure circuit
3174
+ breaker has already given up, in which case the task stays blocked
3175
+ where ``_record_spawn_failure`` parked it.
3176
+
3177
+ Runs host-local: only tasks claimed by this host are candidates
3178
+ (same reasoning as ``detect_crashed_workers``). ``signal_fn`` is a
3179
+ test hook; defaults to ``os.kill`` on POSIX.
3180
+ """
3181
+ import signal
3182
+ timed_out: list[str] = []
3183
+ now = int(time.time())
3184
+ host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
3185
+
3186
+ rows = conn.execute(
3187
+ "SELECT t.id, t.worker_pid, "
3188
+ " COALESCE(r.started_at, t.started_at) AS active_started_at, "
3189
+ " t.max_runtime_seconds, t.claim_lock "
3190
+ "FROM tasks t "
3191
+ "LEFT JOIN task_runs r ON r.id = t.current_run_id "
3192
+ "WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL "
3193
+ " AND COALESCE(r.started_at, t.started_at) IS NOT NULL "
3194
+ " AND t.worker_pid IS NOT NULL"
3195
+ ).fetchall()
3196
+ for row in rows:
3197
+ lock = row["claim_lock"] or ""
3198
+ if not lock.startswith(host_prefix):
3199
+ continue
3200
+ # Runtime is per attempt, not lifetime-of-task. ``tasks.started_at``
3201
+ # intentionally records the first time a task ever started, so retries
3202
+ # must be measured from the active task_runs row when present.
3203
+ elapsed = now - int(row["active_started_at"])
3204
+ if elapsed < int(row["max_runtime_seconds"]):
3205
+ continue
3206
+
3207
+ pid = int(row["worker_pid"])
3208
+ tid = row["id"]
3209
+ # SIGTERM then SIGKILL. Keep it simple: 5 s grace. Workers that
3210
+ # want a cleaner shutdown can install their own SIGTERM handler
3211
+ # before the grace expires.
3212
+ killed = False
3213
+ kill = signal_fn if signal_fn is not None else (
3214
+ os.kill if hasattr(os, "kill") else None
3215
+ )
3216
+ if kill is not None:
3217
+ try:
3218
+ kill(pid, signal.SIGTERM)
3219
+ except (ProcessLookupError, OSError):
3220
+ pass
3221
+ # Short polling wait — no time.sleep on the write txn.
3222
+ for _ in range(10):
3223
+ if not _pid_alive(pid):
3224
+ break
3225
+ time.sleep(0.5)
3226
+ if _pid_alive(pid):
3227
+ try:
3228
+ # signal.SIGKILL doesn't exist on Windows.
3229
+ _sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
3230
+ kill(pid, _sigkill)
3231
+ killed = True
3232
+ except (ProcessLookupError, OSError):
3233
+ pass
3234
+
3235
+ with write_txn(conn):
3236
+ cur = conn.execute(
3237
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
3238
+ "claim_expires = NULL, worker_pid = NULL, "
3239
+ "last_heartbeat_at = NULL "
3240
+ "WHERE id = ? AND status = 'running'",
3241
+ (tid,),
3242
+ )
3243
+ if cur.rowcount == 1:
3244
+ payload = {
3245
+ "pid": pid,
3246
+ "elapsed_seconds": int(elapsed),
3247
+ "limit_seconds": int(row["max_runtime_seconds"]),
3248
+ "sigkill": killed,
3249
+ }
3250
+ run_id = _end_run(
3251
+ conn, tid,
3252
+ outcome="timed_out", status="timed_out",
3253
+ error=f"elapsed {int(elapsed)}s > limit {int(row['max_runtime_seconds'])}s",
3254
+ metadata=payload,
3255
+ )
3256
+ _append_event(
3257
+ conn, tid, "timed_out", payload, run_id=run_id,
3258
+ )
3259
+ timed_out.append(tid)
3260
+ # Increment the unified failure counter. Outside the write_txn
3261
+ # above because ``_record_task_failure`` opens its own. If the
3262
+ # breaker trips, this flips the task ``ready → blocked`` and
3263
+ # emits a ``gave_up`` event on top of the ``timed_out`` we
3264
+ # already emitted.
3265
+ if cur.rowcount == 1:
3266
+ _record_task_failure(
3267
+ conn, tid,
3268
+ error=f"elapsed {int(elapsed)}s > limit {int(row['max_runtime_seconds'])}s",
3269
+ outcome="timed_out",
3270
+ release_claim=False,
3271
+ end_run=False,
3272
+ event_payload_extra={"pid": pid, "sigkill": killed},
3273
+ )
3274
+ return timed_out
3275
+
3276
+
3277
+ def set_max_runtime(
3278
+ conn: sqlite3.Connection,
3279
+ task_id: str,
3280
+ seconds: Optional[int],
3281
+ ) -> bool:
3282
+ """Set or clear the per-task max_runtime_seconds. Returns True on
3283
+ success."""
3284
+ with write_txn(conn):
3285
+ cur = conn.execute(
3286
+ "UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?",
3287
+ (int(seconds) if seconds is not None else None, task_id),
3288
+ )
3289
+ return cur.rowcount == 1
3290
+
3291
+
3292
+ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
3293
+ """Reclaim ``running`` tasks whose worker PID is no longer alive.
3294
+
3295
+ Appends a ``crashed`` event and drops the task back to ``ready``.
3296
+ Different from ``release_stale_claims``: this checks liveness
3297
+ immediately rather than waiting for the claim TTL.
3298
+
3299
+ Only considers tasks claimed by *this host* — PIDs from other hosts
3300
+ are meaningless here. The host-local check is enough because
3301
+ ``_default_spawn`` always runs the worker on the same host as the
3302
+ dispatcher (the whole design is single-host).
3303
+
3304
+ When the reap registry shows the worker exited cleanly (rc=0) but
3305
+ the task was still ``running`` in the DB, treat it as a protocol
3306
+ violation (worker answered conversationally without calling
3307
+ ``kanban_complete`` / ``kanban_block``) and trip the circuit breaker
3308
+ on the first occurrence — retrying a worker whose CLI keeps
3309
+ returning 0 without a terminal transition just loops forever.
3310
+ """
3311
+ crashed: list[str] = []
3312
+ # Per-crash details collected inside the main txn, used after it
3313
+ # closes to run ``_record_task_failure`` (which needs its own
3314
+ # write_txn so can't nest). ``protocol_violation`` flags the
3315
+ # clean-exit-but-still-running case so we can trip the breaker
3316
+ # immediately instead of incrementing by 1.
3317
+ crash_details: list[tuple[str, int, str, bool, str]] = []
3318
+ # (task_id, pid, claimer, protocol_violation, error_text)
3319
+ with write_txn(conn):
3320
+ rows = conn.execute(
3321
+ "SELECT id, worker_pid, claim_lock FROM tasks "
3322
+ "WHERE status = 'running' AND worker_pid IS NOT NULL"
3323
+ ).fetchall()
3324
+ host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
3325
+ for row in rows:
3326
+ # Only check liveness for claims owned by this host.
3327
+ lock = row["claim_lock"] or ""
3328
+ if not lock.startswith(host_prefix):
3329
+ continue
3330
+ if _pid_alive(row["worker_pid"]):
3331
+ continue
3332
+
3333
+ pid = int(row["worker_pid"])
3334
+ kind, code = _classify_worker_exit(pid)
3335
+ if kind == "clean_exit":
3336
+ # Worker subprocess returned 0 but its task is still
3337
+ # ``running`` in the DB — it exited without calling
3338
+ # ``kanban_complete`` / ``kanban_block``. Retrying won't
3339
+ # help.
3340
+ protocol_violation = True
3341
+ error_text = (
3342
+ "worker exited cleanly (rc=0) without calling "
3343
+ "kanban_complete or kanban_block — protocol violation"
3344
+ )
3345
+ event_kind = "protocol_violation"
3346
+ event_payload = {
3347
+ "pid": pid,
3348
+ "claimer": row["claim_lock"],
3349
+ "exit_code": code,
3350
+ }
3351
+ else:
3352
+ protocol_violation = False
3353
+ if kind == "nonzero_exit":
3354
+ error_text = f"pid {pid} exited with code {code}"
3355
+ elif kind == "signaled":
3356
+ error_text = f"pid {pid} killed by signal {code}"
3357
+ else:
3358
+ error_text = f"pid {pid} not alive"
3359
+ event_kind = "crashed"
3360
+ event_payload = {"pid": pid, "claimer": row["claim_lock"]}
3361
+ if code is not None and kind != "unknown":
3362
+ event_payload["exit_kind"] = kind
3363
+ event_payload["exit_code"] = code
3364
+
3365
+ cur = conn.execute(
3366
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
3367
+ "claim_expires = NULL, worker_pid = NULL "
3368
+ "WHERE id = ? AND status = 'running'",
3369
+ (row["id"],),
3370
+ )
3371
+ if cur.rowcount == 1:
3372
+ run_id = _end_run(
3373
+ conn, row["id"],
3374
+ outcome="crashed", status="crashed",
3375
+ error=error_text,
3376
+ metadata=dict(event_payload),
3377
+ )
3378
+ _append_event(
3379
+ conn, row["id"], event_kind,
3380
+ event_payload,
3381
+ run_id=run_id,
3382
+ )
3383
+ crashed.append(row["id"])
3384
+ crash_details.append(
3385
+ (row["id"], pid, row["claim_lock"],
3386
+ protocol_violation, error_text)
3387
+ )
3388
+ # Outside the main txn: increment the unified failure counter for
3389
+ # each crashed task. If the breaker trips, the task transitions
3390
+ # ready → blocked with a ``gave_up`` event on top of the ``crashed``
3391
+ # event we already emitted.
3392
+ #
3393
+ # Protocol-violation crashes force an immediate trip (failure_limit=1)
3394
+ # because clean-exit-without-transition is deterministic: the next
3395
+ # respawn will do exactly the same thing. Better to surface to a
3396
+ # human with a clear reason than to loop ``DEFAULT_FAILURE_LIMIT``
3397
+ # times first.
3398
+ auto_blocked: list[str] = []
3399
+ for tid, pid, claimer, protocol_violation, error_text in crash_details:
3400
+ tripped = _record_task_failure(
3401
+ conn, tid,
3402
+ error=error_text,
3403
+ outcome="crashed",
3404
+ failure_limit=(1 if protocol_violation else None),
3405
+ release_claim=False,
3406
+ end_run=False,
3407
+ event_payload_extra={"pid": pid, "claimer": claimer},
3408
+ )
3409
+ if tripped:
3410
+ auto_blocked.append(tid)
3411
+ # Stash auto-blocked ids on the function for the dispatch loop to pick up.
3412
+ # Keeps the public return type (``list[str]``) stable for direct callers
3413
+ # and tests that destructure the result; ``dispatch_once`` reads this
3414
+ # side-channel attribute to populate ``DispatchResult.auto_blocked``.
3415
+ detect_crashed_workers._last_auto_blocked = auto_blocked # type: ignore[attr-defined]
3416
+ return crashed
3417
+
3418
+
3419
+ def _record_task_failure(
3420
+ conn: sqlite3.Connection,
3421
+ task_id: str,
3422
+ error: str,
3423
+ *,
3424
+ outcome: str,
3425
+ failure_limit: int = None,
3426
+ release_claim: bool = False,
3427
+ end_run: bool = False,
3428
+ event_payload_extra: Optional[dict] = None,
3429
+ ) -> bool:
3430
+ """Record a non-success outcome (spawn_failed / crashed / timed_out)
3431
+ and maybe trip the circuit breaker.
3432
+
3433
+ Unified replacement for the old spawn-only ``_record_spawn_failure``.
3434
+ Every path that ends a task with a non-success outcome funnels
3435
+ through here so the ``consecutive_failures`` counter and the
3436
+ auto-block threshold stay consistent.
3437
+
3438
+ Returns True when the task was auto-blocked (counter reached
3439
+ ``failure_limit``), False when it was just updated in place.
3440
+
3441
+ Modes:
3442
+
3443
+ * ``release_claim=True, end_run=True`` — spawn-failure path.
3444
+ Caller has a running task with an open run; this transitions
3445
+ it back to ``ready`` (or ``blocked`` when the breaker trips),
3446
+ releases the claim, and closes the run with ``outcome=<outcome>``.
3447
+
3448
+ * ``release_claim=False, end_run=False`` — timeout/crash path.
3449
+ Caller has ALREADY flipped the task to ``ready`` and closed the
3450
+ run with the appropriate outcome. This just increments the
3451
+ counter; if the breaker trips, the task is re-transitioned
3452
+ ``ready → blocked`` and a ``gave_up`` event is emitted.
3453
+
3454
+ ``event_payload_extra`` merges into the ``gave_up`` event payload
3455
+ when the breaker trips, so callers can include outcome-specific
3456
+ context (e.g. pid on crash, elapsed on timeout).
3457
+
3458
+ Resolution order for the effective threshold:
3459
+ 1. per-task ``max_retries`` if set (nothing else overrides)
3460
+ 2. caller-supplied ``failure_limit`` (gateway passes the config
3461
+ value from ``kanban.failure_limit``; tests pass fixed values)
3462
+ 3. ``DEFAULT_FAILURE_LIMIT``
3463
+ """
3464
+ if failure_limit is None:
3465
+ failure_limit = DEFAULT_FAILURE_LIMIT
3466
+ blocked = False
3467
+ with write_txn(conn):
3468
+ row = conn.execute(
3469
+ "SELECT consecutive_failures, status, max_retries "
3470
+ "FROM tasks WHERE id = ?", (task_id,),
3471
+ ).fetchone()
3472
+ if row is None:
3473
+ return False
3474
+ failures = int(row["consecutive_failures"]) + 1
3475
+ cur_status = row["status"]
3476
+
3477
+ # Per-task override wins over both caller-supplied and default
3478
+ # thresholds. None (the common case) falls through.
3479
+ task_override = (
3480
+ row["max_retries"] if "max_retries" in row.keys() else None
3481
+ )
3482
+ if task_override is not None:
3483
+ effective_limit = int(task_override)
3484
+ limit_source = "task"
3485
+ else:
3486
+ effective_limit = int(failure_limit)
3487
+ limit_source = "dispatcher"
3488
+
3489
+ if failures >= effective_limit:
3490
+ # Trip the breaker.
3491
+ if release_claim:
3492
+ # Spawn path: still running, also clear claim state.
3493
+ conn.execute(
3494
+ "UPDATE tasks SET status = 'blocked', claim_lock = NULL, "
3495
+ "claim_expires = NULL, worker_pid = NULL, "
3496
+ "consecutive_failures = ?, last_failure_error = ? "
3497
+ "WHERE id = ? AND status IN ('running', 'ready')",
3498
+ (failures, error[:500], task_id),
3499
+ )
3500
+ else:
3501
+ # Timeout/crash path: task is already at ``ready``
3502
+ # with claim cleared; just flip to blocked + update
3503
+ # counter fields.
3504
+ conn.execute(
3505
+ "UPDATE tasks SET status = 'blocked', "
3506
+ "consecutive_failures = ?, last_failure_error = ? "
3507
+ "WHERE id = ? AND status IN ('ready', 'running')",
3508
+ (failures, error[:500], task_id),
3509
+ )
3510
+ run_id = None
3511
+ if end_run:
3512
+ # Only the spawn path has an open run to close.
3513
+ run_id = _end_run(
3514
+ conn, task_id,
3515
+ outcome="gave_up", status="gave_up",
3516
+ error=error[:500],
3517
+ metadata={
3518
+ "failures": failures,
3519
+ "trigger_outcome": outcome,
3520
+ "effective_limit": effective_limit,
3521
+ "limit_source": limit_source,
3522
+ },
3523
+ )
3524
+ payload = {
3525
+ "failures": failures,
3526
+ "effective_limit": effective_limit,
3527
+ "limit_source": limit_source,
3528
+ "error": error[:500],
3529
+ "trigger_outcome": outcome,
3530
+ }
3531
+ if event_payload_extra:
3532
+ payload.update(event_payload_extra)
3533
+ _append_event(
3534
+ conn, task_id, "gave_up", payload, run_id=run_id,
3535
+ )
3536
+ blocked = True
3537
+ else:
3538
+ # Below threshold.
3539
+ if release_claim:
3540
+ # Spawn path: transition running → ready + clear claim.
3541
+ conn.execute(
3542
+ "UPDATE tasks SET status = 'ready', claim_lock = NULL, "
3543
+ "claim_expires = NULL, worker_pid = NULL, "
3544
+ "consecutive_failures = ?, last_failure_error = ? "
3545
+ "WHERE id = ? AND status = 'running'",
3546
+ (failures, error[:500], task_id),
3547
+ )
3548
+ else:
3549
+ # Timeout/crash path: task is already at ``ready`` via
3550
+ # its own UPDATE. Just bookkeep the counter + last error.
3551
+ conn.execute(
3552
+ "UPDATE tasks SET consecutive_failures = ?, "
3553
+ "last_failure_error = ? WHERE id = ?",
3554
+ (failures, error[:500], task_id),
3555
+ )
3556
+ if end_run:
3557
+ # Spawn path: close the open run with outcome.
3558
+ run_id = _end_run(
3559
+ conn, task_id,
3560
+ outcome=outcome, status=outcome,
3561
+ error=error[:500],
3562
+ metadata={"failures": failures},
3563
+ )
3564
+ _append_event(
3565
+ conn, task_id, outcome,
3566
+ {"error": error[:500], "failures": failures},
3567
+ run_id=run_id,
3568
+ )
3569
+ # Timeout/crash path's caller already emitted its own event.
3570
+ return blocked
3571
+
3572
+
3573
+ # Backward-compat alias. Old name is referenced from tests and possibly
3574
+ # third-party callers. New code should call ``_record_task_failure``.
3575
+ def _record_spawn_failure(
3576
+ conn: sqlite3.Connection,
3577
+ task_id: str,
3578
+ error: str,
3579
+ *,
3580
+ failure_limit: int = None,
3581
+ ) -> bool:
3582
+ return _record_task_failure(
3583
+ conn, task_id, error,
3584
+ outcome="spawn_failed",
3585
+ failure_limit=failure_limit,
3586
+ release_claim=True,
3587
+ end_run=True,
3588
+ )
3589
+
3590
+
3591
+ def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
3592
+ """Record the spawned child's pid + emit a ``spawned`` event.
3593
+
3594
+ The event's payload carries the pid so a human reading ``hermes kanban
3595
+ tail`` can correlate log lines with OS-level traces without opening
3596
+ the drawer.
3597
+ """
3598
+ with write_txn(conn):
3599
+ conn.execute(
3600
+ "UPDATE tasks SET worker_pid = ? WHERE id = ?",
3601
+ (int(pid), task_id),
3602
+ )
3603
+ run_id = _current_run_id(conn, task_id)
3604
+ if run_id is not None:
3605
+ conn.execute(
3606
+ "UPDATE task_runs SET worker_pid = ? WHERE id = ?",
3607
+ (int(pid), run_id),
3608
+ )
3609
+ _append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id)
3610
+
3611
+
3612
+ def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
3613
+ """Reset the unified consecutive-failures counter.
3614
+
3615
+ Called from ``complete_task`` on successful completion — a fresh
3616
+ success means the task + profile combination is working and any
3617
+ past failures are history. NOT called on spawn success anymore:
3618
+ a successful spawn proves the worker could start but says nothing
3619
+ about whether the run will succeed, so we need to let timeouts and
3620
+ crashes accumulate across spawn boundaries.
3621
+ """
3622
+ with write_txn(conn):
3623
+ conn.execute(
3624
+ "UPDATE tasks SET consecutive_failures = 0, "
3625
+ "last_failure_error = NULL WHERE id = ?",
3626
+ (task_id,),
3627
+ )
3628
+
3629
+
3630
+ # Legacy alias for test-code and anything else that still imports it.
3631
+ _clear_spawn_failures = _clear_failure_counter
3632
+
3633
+
3634
+ def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
3635
+ """Return True iff there is at least one ready+assigned+unclaimed task
3636
+ whose assignee maps to a real Hermes profile.
3637
+
3638
+ Used by the gateway- and CLI-embedded dispatchers' health telemetry to
3639
+ decide whether ``0 spawned`` is a "stuck" condition (real spawnable
3640
+ work waiting) or a "correctly idle" condition (only control-plane
3641
+ lanes like ``orion-cc`` / ``orion-research`` waiting on terminals
3642
+ that pull tasks via ``claim_task`` directly).
3643
+
3644
+ Falls back to "any ready+assigned" if ``profile_exists`` is not
3645
+ importable (e.g. partial install) — preserves the old behavior so
3646
+ the warning still fires in degraded environments.
3647
+ """
3648
+ rows = conn.execute(
3649
+ "SELECT DISTINCT assignee FROM tasks "
3650
+ "WHERE status = 'ready' AND assignee IS NOT NULL "
3651
+ " AND claim_lock IS NULL"
3652
+ ).fetchall()
3653
+ if not rows:
3654
+ return False
3655
+ try:
3656
+ from hermes_cli.profiles import profile_exists # local import: avoids cycle
3657
+ except Exception:
3658
+ # Can't introspect — assume spawnable, preserve legacy behavior.
3659
+ return True
3660
+ for row in rows:
3661
+ if profile_exists(row["assignee"]):
3662
+ return True
3663
+ return False
3664
+
3665
+
3666
+ def dispatch_once(
3667
+ conn: sqlite3.Connection,
3668
+ *,
3669
+ spawn_fn=None,
3670
+ ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS,
3671
+ dry_run: bool = False,
3672
+ max_spawn: Optional[int] = None,
3673
+ failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
3674
+ board: Optional[str] = None,
3675
+ ) -> DispatchResult:
3676
+ """Run one dispatcher tick.
3677
+
3678
+ Steps:
3679
+ 1. Reclaim stale running tasks (TTL expired).
3680
+ 2. Reclaim crashed running tasks (host-local PID no longer alive).
3681
+ 3. Promote todo -> ready where all parents are done.
3682
+ 4. For each ready task with an assignee, atomically claim and call
3683
+ ``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
3684
+ return value (if any) is recorded as ``worker_pid`` so subsequent
3685
+ ticks can detect crashes before the TTL expires.
3686
+
3687
+ Spawn failures are counted per-task. After ``failure_limit`` consecutive
3688
+ failures the task is auto-blocked with the last error as its reason —
3689
+ prevents the dispatcher from thrashing forever on an unfixable task.
3690
+
3691
+ ``max_spawn`` is a **live concurrency cap**, not a per-tick spawn budget:
3692
+ it counts tasks already in ``status='running'`` plus this tick's spawns
3693
+ against the limit. So ``max_spawn=4`` means "at most 4 workers running
3694
+ at any time across the whole board" — matching the gateway's stated
3695
+ intent ("limit concurrent kanban tasks"). With a per-tick interpretation
3696
+ a 60-second tick interval could grow concurrency by N every minute on a
3697
+ busy board and accumulate without bound.
3698
+
3699
+ ``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
3700
+ ``board`` pins workspace/log/db resolution for this tick to a specific
3701
+ board. When omitted, the current-board resolution chain is used.
3702
+ """
3703
+ # Reap zombie children from previously spawned workers.
3704
+ # The gateway-embedded dispatcher is the parent of every worker spawned
3705
+ # via _default_spawn (start_new_session=True only detaches the
3706
+ # controlling tty, not the parent). Without an explicit waitpid, each
3707
+ # completed worker becomes a <defunct> entry that lingers until gateway
3708
+ # exit. WNOHANG keeps this non-blocking; ChildProcessError means no
3709
+ # children to reap. Bounded: at most one tick's worth of completions
3710
+ # can be in <defunct> at once.
3711
+ #
3712
+ # We also record the exit status keyed by pid, so
3713
+ # ``detect_crashed_workers`` can distinguish a worker that exited
3714
+ # cleanly without calling ``kanban_complete`` / ``kanban_block``
3715
+ # (protocol violation — auto-block) from a real crash (OOM killer,
3716
+ # SIGKILL, non-zero exit — existing counter behavior).
3717
+ #
3718
+ # Windows has no zombies / no os.WNOHANG — subprocess.Popen handles
3719
+ # are freed when the Python object is garbage-collected or .wait() is
3720
+ # called explicitly. The kanban dispatcher discards the Popen handle
3721
+ # after spawn (``_default_spawn`` → abandon), so on Windows there's
3722
+ # nothing to reap here — skip the whole block.
3723
+ if os.name != "nt":
3724
+ try:
3725
+ while True:
3726
+ try:
3727
+ _pid, _status = os.waitpid(-1, os.WNOHANG)
3728
+ except ChildProcessError:
3729
+ break
3730
+ if _pid == 0:
3731
+ break
3732
+ _record_worker_exit(_pid, _status)
3733
+ except Exception:
3734
+ pass
3735
+
3736
+ result = DispatchResult()
3737
+ result.reclaimed = release_stale_claims(conn)
3738
+ result.crashed = detect_crashed_workers(conn)
3739
+ # detect_crashed_workers stashes protocol-violation auto-blocks on
3740
+ # itself so the public list-return stays stable. Pull them into the
3741
+ # DispatchResult here so telemetry / tests see the trip.
3742
+ _crash_auto_blocked = getattr(
3743
+ detect_crashed_workers, "_last_auto_blocked", []
3744
+ )
3745
+ if _crash_auto_blocked:
3746
+ result.auto_blocked.extend(_crash_auto_blocked)
3747
+ result.timed_out = enforce_max_runtime(conn)
3748
+ result.promoted = recompute_ready(conn)
3749
+
3750
+ # Count tasks already running so max_spawn enforces concurrency rather
3751
+ # than a per-tick spawn budget. See the docstring above for the full
3752
+ # rationale; the short version is that a 60-second tick interval with a
3753
+ # per-tick budget of N would grow concurrency by N every tick on a busy
3754
+ # board, since "running" tasks aren't reclaimed by completion alone —
3755
+ # they sit in status='running' until the worker calls
3756
+ # kanban_complete/kanban_block (or the dispatcher TTL-reclaims them).
3757
+ running_count = 0
3758
+ if max_spawn is not None:
3759
+ running_count = int(
3760
+ conn.execute(
3761
+ "SELECT COUNT(*) FROM tasks WHERE status = 'running'"
3762
+ ).fetchone()[0]
3763
+ )
3764
+
3765
+ ready_rows = conn.execute(
3766
+ "SELECT id, assignee FROM tasks "
3767
+ "WHERE status = 'ready' AND claim_lock IS NULL "
3768
+ "ORDER BY priority DESC, created_at ASC"
3769
+ ).fetchall()
3770
+ spawned = 0
3771
+ for row in ready_rows:
3772
+ if max_spawn is not None and running_count + spawned >= max_spawn:
3773
+ break
3774
+ if not row["assignee"]:
3775
+ result.skipped_unassigned.append(row["id"])
3776
+ continue
3777
+ # Skip ready tasks whose assignee is not a real Hermes profile.
3778
+ # `_default_spawn` invokes ``hermes -p <assignee>`` which fails
3779
+ # with "Profile 'X' does not exist" when the assignee names a
3780
+ # control-plane lane (e.g. an interactive Claude Code terminal
3781
+ # like ``orion-cc`` / ``orion-research``) rather than a Hermes
3782
+ # profile. Those task lanes are pulled by terminals via
3783
+ # ``claim_task`` directly and should NEVER auto-spawn — the
3784
+ # subprocess would crash on startup, get reaped as a zombie,
3785
+ # the task would loop back to ``ready`` on next tick, and we'd
3786
+ # burn CPU forever (#kanban-dispatcher-crash-loop 2026-05-05).
3787
+ try:
3788
+ from hermes_cli.profiles import profile_exists # local import: avoids cycle
3789
+ except Exception:
3790
+ profile_exists = None # type: ignore[assignment]
3791
+ if profile_exists is not None and not profile_exists(row["assignee"]):
3792
+ # Bucket separately from skipped_unassigned: the operator
3793
+ # cannot fix this by assigning a profile (the assignee IS the
3794
+ # intended owner — a terminal lane). Health telemetry uses
3795
+ # this distinction to suppress spurious "stuck" warnings on
3796
+ # multi-lane setups where the ready queue is steadily full
3797
+ # of human-pulled work.
3798
+ result.skipped_nonspawnable.append(row["id"])
3799
+ continue
3800
+ if dry_run:
3801
+ result.spawned.append((row["id"], row["assignee"], ""))
3802
+ continue
3803
+ claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds)
3804
+ if claimed is None:
3805
+ continue
3806
+ try:
3807
+ workspace = resolve_workspace(claimed, board=board)
3808
+ except Exception as exc:
3809
+ auto = _record_spawn_failure(
3810
+ conn, claimed.id, f"workspace: {exc}",
3811
+ failure_limit=failure_limit,
3812
+ )
3813
+ if auto:
3814
+ result.auto_blocked.append(claimed.id)
3815
+ continue
3816
+ # Persist the resolved workspace path so the worker can cd there.
3817
+ set_workspace_path(conn, claimed.id, str(workspace))
3818
+ _spawn = spawn_fn if spawn_fn is not None else _default_spawn
3819
+ try:
3820
+ # Back-compat: older spawn_fn signatures accept only
3821
+ # (task, workspace). Test stubs in the suite rely on that.
3822
+ # Introspect the callable and pass `board` only when supported.
3823
+ import inspect
3824
+ try:
3825
+ sig = inspect.signature(_spawn)
3826
+ if "board" in sig.parameters:
3827
+ pid = _spawn(claimed, str(workspace), board=board)
3828
+ else:
3829
+ pid = _spawn(claimed, str(workspace))
3830
+ except (TypeError, ValueError):
3831
+ pid = _spawn(claimed, str(workspace))
3832
+ if pid:
3833
+ _set_worker_pid(conn, claimed.id, int(pid))
3834
+ # NOTE: we intentionally do NOT reset consecutive_failures
3835
+ # here. A successful spawn proves the worker can start but
3836
+ # doesn't prove the run will succeed. Under unified
3837
+ # failure counting, resetting on spawn would let a task
3838
+ # that keeps timing out after spawn loop forever. The
3839
+ # counter is cleared only on successful completion (see
3840
+ # complete_task).
3841
+ result.spawned.append((claimed.id, claimed.assignee or "", str(workspace)))
3842
+ spawned += 1
3843
+ except Exception as exc:
3844
+ auto = _record_spawn_failure(
3845
+ conn, claimed.id, str(exc),
3846
+ failure_limit=failure_limit,
3847
+ )
3848
+ if auto:
3849
+ result.auto_blocked.append(claimed.id)
3850
+ return result
3851
+
3852
+
3853
+ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
3854
+ """Rotate ``<log>`` to ``<log>.1`` if it exceeds ``max_bytes``.
3855
+
3856
+ Single-generation rotation — one old file kept, newer one replaces it.
3857
+ Keeps disk usage bounded while still giving the user a chance to grab
3858
+ the prior run's output.
3859
+ """
3860
+ try:
3861
+ if not log_path.exists():
3862
+ return
3863
+ if log_path.stat().st_size <= max_bytes:
3864
+ return
3865
+ rotated = log_path.with_suffix(log_path.suffix + ".1")
3866
+ try:
3867
+ if rotated.exists():
3868
+ rotated.unlink()
3869
+ except OSError:
3870
+ pass
3871
+ log_path.rename(rotated)
3872
+ except OSError:
3873
+ pass
3874
+
3875
+
3876
+ def _resolve_hermes_argv() -> list[str]:
3877
+ """Resolve the ``hermes`` invocation as argv parts for ``Popen``.
3878
+
3879
+ Tries in order:
3880
+
3881
+ 1. ``shutil.which("hermes")`` — the console-script shim, the same form
3882
+ that shows up in ``ps`` output and existing logs. Preferred so live
3883
+ systems' diagnostics stay familiar.
3884
+ 2. ``sys.executable -m hermes_cli.main`` — fallback for setups where
3885
+ Hermes is launched from a venv and the ``hermes`` shim is not on
3886
+ the dispatcher's ``$PATH`` (cron, systemd ``User=`` services,
3887
+ launchd jobs, detached processes, etc.). Goes through the running
3888
+ interpreter so the result is independent of ``$PATH``.
3889
+
3890
+ Mirrors ``gateway.run._resolve_hermes_bin`` for the same reason. Kept
3891
+ local (not imported from gateway) because ``hermes_cli`` sits below
3892
+ ``gateway`` in the dependency order.
3893
+ """
3894
+ import shutil
3895
+
3896
+ hermes_bin = shutil.which("hermes")
3897
+ if hermes_bin:
3898
+ return [hermes_bin]
3899
+ # Fallback to the module form. ``hermes_cli.main`` is the actual
3900
+ # console-script target declared in pyproject.toml, NOT a top-level
3901
+ # ``hermes`` package — there is no ``hermes`` package to import.
3902
+ return [sys.executable, "-m", "hermes_cli.main"]
3903
+
3904
+
3905
+ def _default_spawn(
3906
+ task: Task,
3907
+ workspace: str,
3908
+ *,
3909
+ board: Optional[str] = None,
3910
+ ) -> Optional[int]:
3911
+ """Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
3912
+
3913
+ Returns the spawned child's PID so the dispatcher can detect crashes
3914
+ before the claim TTL expires. The child's completion is still observed
3915
+ via the ``complete`` / ``block`` transitions the worker writes itself;
3916
+ the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
3917
+
3918
+ ``board`` pins the child's kanban context to that board: the child's
3919
+ ``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
3920
+ vars all resolve to the same board the dispatcher claimed the task
3921
+ from. Workers cannot accidentally see other boards.
3922
+ """
3923
+ import subprocess
3924
+ if not task.assignee:
3925
+ raise ValueError(f"task {task.id} has no assignee")
3926
+
3927
+ from hermes_cli.profiles import normalize_profile_name
3928
+
3929
+ profile_arg = normalize_profile_name(task.assignee)
3930
+
3931
+ prompt = f"work kanban task {task.id}"
3932
+ env = dict(os.environ)
3933
+
3934
+ # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml
3935
+ # (fallback_providers, toolsets, agent settings, etc.) instead of the root
3936
+ # config. Without this, `env = dict(os.environ)` copies only the parent's
3937
+ # env, and when the child process starts `hermes -p <name>` the
3938
+ # _apply_profile_override() runs *before* calvyn_constants is imported.
3939
+ # If HERMES_HOME is absent from the child's env, get_hermes_home() falls
3940
+ # back to Path.home() / ".hermes" (the DEFAULT profile root), ignoring the
3941
+ # profile-specific config entirely. Fixes profile-scoped fallback_providers
3942
+ # being invisible to kanban workers.
3943
+ from hermes_cli.profiles import resolve_profile_env
3944
+ try:
3945
+ env["HERMES_HOME"] = resolve_profile_env(profile_arg)
3946
+ except FileNotFoundError:
3947
+ # Profile dir doesn't exist — defer resolution to the CLI's
3948
+ # _apply_profile_override() via HERMES_PROFILE (set below).
3949
+ # This only happens in test fixtures where the isolated
3950
+ # HERMES_HOME never had profiles created.
3951
+ pass
3952
+ if task.tenant:
3953
+ env["HERMES_TENANT"] = task.tenant
3954
+ env["HERMES_KANBAN_TASK"] = task.id
3955
+ env["HERMES_KANBAN_WORKSPACE"] = workspace
3956
+ if task.current_run_id is not None:
3957
+ env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
3958
+ if task.claim_lock:
3959
+ env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
3960
+ # Pin the shared board + workspaces root the dispatcher resolved, so
3961
+ # that even when the worker activates a profile (`hermes -p <name>`
3962
+ # rewrites HERMES_HOME), its kanban paths still match the
3963
+ # dispatcher's. Belt-and-braces with the `get_default_hermes_root()`
3964
+ # resolution in `kanban_home()` — symmetric resolution is the norm,
3965
+ # but unusual symlink / Docker layouts are caught here too.
3966
+ env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
3967
+ env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
3968
+ # Board slug — the final defense-in-depth pin. If the worker ever
3969
+ # resolves kanban paths without the DB / workspaces env vars, the
3970
+ # board slug still forces it to the right directory.
3971
+ resolved_board = _normalize_board_slug(board) or get_current_board()
3972
+ env["HERMES_KANBAN_BOARD"] = resolved_board
3973
+ # HERMES_PROFILE is the author the kanban_comment tool defaults to.
3974
+ # `hermes -p <assignee>` activates the profile, but the env var is
3975
+ # what the tool reads — set it explicitly here so comments are
3976
+ # attributed correctly regardless of how the child loads config.
3977
+ env["HERMES_PROFILE"] = profile_arg
3978
+
3979
+ cmd = [
3980
+ *_resolve_hermes_argv(),
3981
+ "-p", profile_arg,
3982
+ # Auto-load the kanban-worker skill so every dispatched worker
3983
+ # has the pattern library (good summary/metadata shapes, retry
3984
+ # diagnostics, block-reason examples) in its context, even if
3985
+ # the profile hasn't wired it into skills config. The MANDATORY
3986
+ # lifecycle is already in the system prompt via KANBAN_GUIDANCE;
3987
+ # this skill is the deeper reference. Users can point a profile
3988
+ # at a different/additional skill via config if they want —
3989
+ # --skills is additive to the profile's default skill set.
3990
+ "--skills", "kanban-worker",
3991
+ ]
3992
+ # Per-task force-loaded skills. Each name goes in its own
3993
+ # `--skills X` pair rather than a single comma-joined arg: the CLI
3994
+ # accepts both forms (action='append' + comma-split), but
3995
+ # per-name pairs are easier to read in `ps` output and avoid any
3996
+ # quoting ambiguity if a skill name ever contains unusual chars.
3997
+ # Dedupe against the built-in so we don't double-load kanban-worker
3998
+ # if a task author asks for it explicitly.
3999
+ if task.skills:
4000
+ for sk in task.skills:
4001
+ if sk and sk != "kanban-worker":
4002
+ cmd.extend(["--skills", sk])
4003
+ cmd.extend([
4004
+ "chat",
4005
+ "-q", prompt,
4006
+ ])
4007
+ # Redirect output to a per-task log under <board-root>/logs/.
4008
+ # Anchored at the board root (not the shared kanban root), so
4009
+ # `hermes kanban log` on a specific board reads its own file and
4010
+ # logs don't collide across boards that happen to share task ids.
4011
+ log_dir = worker_logs_dir(board=board)
4012
+ log_dir.mkdir(parents=True, exist_ok=True)
4013
+ log_path = log_dir / f"{task.id}.log"
4014
+ _rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
4015
+
4016
+ # Use 'a' so a re-run on unblock appends rather than overwrites.
4017
+ log_f = open(log_path, "ab")
4018
+ try:
4019
+ proc = subprocess.Popen( # noqa: S603 -- argv is a fixed list built above
4020
+ cmd,
4021
+ cwd=workspace if os.path.isdir(workspace) else None,
4022
+ stdin=subprocess.DEVNULL,
4023
+ stdout=log_f,
4024
+ stderr=subprocess.STDOUT,
4025
+ env=env,
4026
+ start_new_session=True,
4027
+ )
4028
+ except FileNotFoundError:
4029
+ log_f.close()
4030
+ raise RuntimeError(
4031
+ "`hermes` executable not found on PATH. "
4032
+ "Install Hermes Agent or activate its venv before running the kanban dispatcher."
4033
+ )
4034
+ # NOTE: we intentionally do NOT close log_f here — we want Popen's
4035
+ # child process to keep writing after this function returns. The
4036
+ # handle is kept alive by the child's inheritance. The parent's
4037
+ # reference goes out of scope and is GC'd, but the OS-level FD stays
4038
+ # open in the child until the child exits.
4039
+ return proc.pid
4040
+
4041
+
4042
+ # ---------------------------------------------------------------------------
4043
+ # Long-lived dispatcher daemon
4044
+ # ---------------------------------------------------------------------------
4045
+
4046
+ def run_daemon(
4047
+ *,
4048
+ interval: float = 60.0,
4049
+ max_spawn: Optional[int] = None,
4050
+ failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
4051
+ stop_event=None,
4052
+ on_tick=None,
4053
+ ) -> None:
4054
+ """Run the dispatcher in a loop until interrupted.
4055
+
4056
+ Calls :func:`dispatch_once` every ``interval`` seconds. Exits cleanly
4057
+ on SIGINT / SIGTERM so ``hermes kanban daemon`` is systemd-friendly.
4058
+ ``stop_event`` (a :class:`threading.Event`) and ``on_tick`` (a
4059
+ callable receiving the :class:`DispatchResult`) are test hooks.
4060
+ """
4061
+ import signal
4062
+ import threading
4063
+
4064
+ if stop_event is None:
4065
+ stop_event = threading.Event()
4066
+
4067
+ def _handle(_signum, _frame):
4068
+ stop_event.set()
4069
+
4070
+ # Install handlers only when running on the main thread — tests call
4071
+ # this inline from worker threads and signal() would raise there.
4072
+ if threading.current_thread() is threading.main_thread():
4073
+ for sig_name in ("SIGINT", "SIGTERM"):
4074
+ sig = getattr(signal, sig_name, None)
4075
+ if sig is not None:
4076
+ try:
4077
+ signal.signal(sig, _handle)
4078
+ except (ValueError, OSError):
4079
+ pass
4080
+
4081
+ while not stop_event.is_set():
4082
+ try:
4083
+ with contextlib.closing(connect()) as conn:
4084
+ res = dispatch_once(
4085
+ conn,
4086
+ max_spawn=max_spawn,
4087
+ failure_limit=failure_limit,
4088
+ )
4089
+ if on_tick is not None:
4090
+ try:
4091
+ on_tick(res)
4092
+ except Exception:
4093
+ pass
4094
+ except Exception:
4095
+ # Don't let any single tick kill the daemon.
4096
+ import traceback
4097
+ traceback.print_exc()
4098
+ stop_event.wait(timeout=interval)
4099
+
4100
+
4101
+ # ---------------------------------------------------------------------------
4102
+ # Worker context builder (what a spawned worker sees)
4103
+ # ---------------------------------------------------------------------------
4104
+
4105
+ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
4106
+ """Return the full text a worker should read to understand its task.
4107
+
4108
+ Order:
4109
+ 1. Task title (mandatory).
4110
+ 2. Task body (optional opening post, capped at 8 KB).
4111
+ 3. Prior attempts on THIS task (most recent ``_CTX_MAX_PRIOR_ATTEMPTS``
4112
+ shown; older attempts collapsed into a one-line summary).
4113
+ Each attempt's ``summary`` / ``error`` / ``metadata`` capped at
4114
+ ``_CTX_MAX_FIELD_BYTES`` each.
4115
+ 4. Structured handoff results of every done parent task. Prefers
4116
+ ``run.summary`` / ``run.metadata`` when the parent was executed
4117
+ via a run; falls back to ``task.result`` for older data. Same
4118
+ per-field cap.
4119
+ 5. Cross-task role history for the assignee (most recent 5
4120
+ completed runs on other tasks).
4121
+ 6. Comment thread (most recent ``_CTX_MAX_COMMENTS`` shown, older
4122
+ collapsed).
4123
+
4124
+ All caps exist so worker prompts stay bounded even on pathological
4125
+ boards (retry-heavy tasks, comment storms). The per-field char cap
4126
+ prevents a single 1 MB summary from dominating context.
4127
+ """
4128
+ task = get_task(conn, task_id)
4129
+ if not task:
4130
+ raise ValueError(f"unknown task {task_id}")
4131
+
4132
+ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str:
4133
+ """Truncate a string to `limit` chars with a visible ellipsis."""
4134
+ if not s:
4135
+ return ""
4136
+ s = s.strip()
4137
+ if len(s) <= limit:
4138
+ return s
4139
+ return s[:limit] + f"… [truncated, {len(s) - limit} chars omitted]"
4140
+
4141
+ lines: list[str] = []
4142
+ lines.append(f"# Kanban task {task.id}: {task.title}")
4143
+ lines.append("")
4144
+ lines.append(f"Assignee: {task.assignee or '(unassigned)'}")
4145
+ lines.append(f"Status: {task.status}")
4146
+ if task.tenant:
4147
+ lines.append(f"Tenant: {task.tenant}")
4148
+ lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}")
4149
+ lines.append("")
4150
+
4151
+ if task.body and task.body.strip():
4152
+ lines.append("## Body")
4153
+ lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
4154
+ lines.append("")
4155
+
4156
+ # Prior attempts — show closed runs so a retrying worker sees the
4157
+ # history. Skip the currently-active run (that's this worker).
4158
+ # Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
4159
+ # attempts get collapsed into a one-line marker so the worker knows
4160
+ # more exist without bloating the prompt.
4161
+ all_prior = [r for r in list_runs(conn, task_id) if r.ended_at is not None]
4162
+ # list_runs returns ascending by started_at; "most recent" = last N
4163
+ if len(all_prior) > _CTX_MAX_PRIOR_ATTEMPTS:
4164
+ omitted = len(all_prior) - _CTX_MAX_PRIOR_ATTEMPTS
4165
+ shown = all_prior[-_CTX_MAX_PRIOR_ATTEMPTS:]
4166
+ first_shown_idx = omitted + 1
4167
+ else:
4168
+ omitted = 0
4169
+ shown = all_prior
4170
+ first_shown_idx = 1
4171
+ if shown:
4172
+ lines.append("## Prior attempts on this task")
4173
+ if omitted:
4174
+ lines.append(
4175
+ f"_({omitted} earlier attempt{'s' if omitted != 1 else ''} "
4176
+ f"omitted; showing most recent {len(shown)})_"
4177
+ )
4178
+ for offset, run in enumerate(shown):
4179
+ idx = first_shown_idx + offset
4180
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(run.started_at))
4181
+ profile = run.profile or "(unknown)"
4182
+ outcome = run.outcome or run.status
4183
+ lines.append(f"### Attempt {idx} — {outcome} ({profile}, {ts})")
4184
+ if run.summary and run.summary.strip():
4185
+ lines.append(_cap(run.summary))
4186
+ if run.error and run.error.strip():
4187
+ lines.append(f"_error_: {_cap(run.error)}")
4188
+ if run.metadata:
4189
+ try:
4190
+ meta_str = json.dumps(run.metadata, ensure_ascii=False, sort_keys=True)
4191
+ lines.append(f"_metadata_: `{_cap(meta_str)}`")
4192
+ except Exception:
4193
+ pass
4194
+ lines.append("")
4195
+
4196
+ # Parents: prefer the most-recent 'completed' run's summary + metadata,
4197
+ # fall back to ``task.result`` when no run rows exist (legacy DBs,
4198
+ # or tasks completed before the runs table landed).
4199
+ parent_rows = conn.execute(
4200
+ "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id",
4201
+ (task_id,),
4202
+ ).fetchall()
4203
+ parent_ids = [r["parent_id"] for r in parent_rows]
4204
+
4205
+ if parent_ids:
4206
+ wrote_header = False
4207
+ for pid in parent_ids:
4208
+ pt = get_task(conn, pid)
4209
+ if not pt or pt.status != "done":
4210
+ continue
4211
+ runs = [r for r in list_runs(conn, pid) if r.outcome == "completed"]
4212
+ runs.sort(key=lambda r: r.started_at, reverse=True)
4213
+ run = runs[0] if runs else None
4214
+
4215
+ if not wrote_header:
4216
+ lines.append("## Parent task results")
4217
+ wrote_header = True
4218
+ lines.append(f"### {pid}")
4219
+
4220
+ body_lines: list[str] = []
4221
+ if run is not None and run.summary and run.summary.strip():
4222
+ body_lines.append(_cap(run.summary))
4223
+ elif pt.result:
4224
+ body_lines.append(_cap(pt.result))
4225
+ else:
4226
+ body_lines.append("(no result recorded)")
4227
+
4228
+ if run is not None and run.metadata:
4229
+ try:
4230
+ meta_str = json.dumps(run.metadata, ensure_ascii=False, sort_keys=True)
4231
+ body_lines.append(f"_metadata_: `{_cap(meta_str)}`")
4232
+ except Exception:
4233
+ pass
4234
+ lines.extend(body_lines)
4235
+ lines.append("")
4236
+
4237
+ # Cross-task role history: what else has THIS assignee completed
4238
+ # recently? Gives the worker implicit continuity — "I'm the reviewer
4239
+ # and my last three reviews focused on security" — without forcing
4240
+ # the user to wire anything into SOUL.md / MEMORY.md. Bounded to the
4241
+ # most recent 5 completed runs, excluding this task so the retry
4242
+ # section above isn't duplicated. Safe on assignee=None (skipped).
4243
+ if task.assignee:
4244
+ role_rows = conn.execute(
4245
+ "SELECT t.id, t.title, r.summary, r.ended_at "
4246
+ "FROM task_runs r JOIN tasks t ON r.task_id = t.id "
4247
+ "WHERE r.profile = ? AND r.task_id != ? "
4248
+ " AND r.outcome = 'completed' "
4249
+ "ORDER BY r.ended_at DESC LIMIT 5",
4250
+ (task.assignee, task_id),
4251
+ ).fetchall()
4252
+ if role_rows:
4253
+ lines.append(f"## Recent work by @{task.assignee}")
4254
+ for row in role_rows:
4255
+ ts = time.strftime(
4256
+ "%Y-%m-%d %H:%M", time.localtime(int(row["ended_at"]))
4257
+ )
4258
+ s = (row["summary"] or "").strip().splitlines()
4259
+ first = s[0][:200] if s else "(no summary)"
4260
+ lines.append(f"- {row['id']} — {row['title']} ({ts}): {first}")
4261
+ lines.append("")
4262
+
4263
+ # Comments: cap at the most-recent _CTX_MAX_COMMENTS so
4264
+ # comment-storm tasks don't blow out the worker's prompt. Older
4265
+ # comments summarised in a one-line marker like prior attempts.
4266
+ all_comments = list_comments(conn, task_id)
4267
+ if len(all_comments) > _CTX_MAX_COMMENTS:
4268
+ omitted_c = len(all_comments) - _CTX_MAX_COMMENTS
4269
+ shown_c = all_comments[-_CTX_MAX_COMMENTS:]
4270
+ else:
4271
+ omitted_c = 0
4272
+ shown_c = all_comments
4273
+ if shown_c:
4274
+ lines.append("## Comment thread")
4275
+ if omitted_c:
4276
+ lines.append(
4277
+ f"_({omitted_c} earlier comment{'s' if omitted_c != 1 else ''} "
4278
+ f"omitted; showing most recent {len(shown_c)})_"
4279
+ )
4280
+ for c in shown_c:
4281
+ ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c.created_at))
4282
+ # Render author with explicit "comment from worker" framing so
4283
+ # operator-controlled HERMES_PROFILE values like "hermes-system"
4284
+ # or "operator" can't be misread by the next worker as a system
4285
+ # directive above the (attacker-influenceable) comment body.
4286
+ # Defense-in-depth — the LLM-controlled author-forgery surface
4287
+ # was already closed in #22435. See #22452.
4288
+ safe_author = (c.author or "").replace("`", "")
4289
+ lines.append(f"comment from worker `{safe_author}` at {ts}:")
4290
+ lines.append(_cap(c.body, _CTX_MAX_COMMENT_BYTES))
4291
+ lines.append("")
4292
+
4293
+ return "\n".join(lines).rstrip() + "\n"
4294
+
4295
+
4296
+ # ---------------------------------------------------------------------------
4297
+ # Stats + SLA helpers
4298
+ # ---------------------------------------------------------------------------
4299
+
4300
+ def board_stats(conn: sqlite3.Connection) -> dict:
4301
+ """Per-status + per-assignee counts, plus the oldest ``ready`` age in
4302
+ seconds (the clearest staleness signal for a router or HUD).
4303
+ """
4304
+ by_status: dict[str, int] = {}
4305
+ for row in conn.execute(
4306
+ "SELECT status, COUNT(*) AS n FROM tasks "
4307
+ "WHERE status != 'archived' GROUP BY status"
4308
+ ):
4309
+ by_status[row["status"]] = int(row["n"])
4310
+
4311
+ by_assignee: dict[str, dict[str, int]] = {}
4312
+ for row in conn.execute(
4313
+ "SELECT assignee, status, COUNT(*) AS n FROM tasks "
4314
+ "WHERE status != 'archived' AND assignee IS NOT NULL "
4315
+ "GROUP BY assignee, status"
4316
+ ):
4317
+ by_assignee.setdefault(row["assignee"], {})[row["status"]] = int(row["n"])
4318
+
4319
+ oldest_row = conn.execute(
4320
+ "SELECT MIN(created_at) AS ts FROM tasks WHERE status = 'ready'"
4321
+ ).fetchone()
4322
+ now = int(time.time())
4323
+ oldest_ready_age = (
4324
+ (now - int(oldest_row["ts"]))
4325
+ if oldest_row and oldest_row["ts"] is not None else None
4326
+ )
4327
+
4328
+ return {
4329
+ "by_status": by_status,
4330
+ "by_assignee": by_assignee,
4331
+ "oldest_ready_age_seconds": oldest_ready_age,
4332
+ "now": now,
4333
+ }
4334
+
4335
+
4336
+ def _safe_int(val: Optional[str]) -> Optional[int]:
4337
+ """Parse a timestamp field to int, returning None on garbage like '%s'."""
4338
+ if val is None:
4339
+ return None
4340
+ try:
4341
+ return int(val)
4342
+ except (ValueError, TypeError):
4343
+ return None
4344
+
4345
+
4346
+ def task_age(task: Task) -> dict:
4347
+ """Return age metrics for a single task. All values are seconds or None."""
4348
+ now = int(time.time())
4349
+ created = _safe_int(task.created_at)
4350
+ started = _safe_int(task.started_at)
4351
+ completed = _safe_int(task.completed_at)
4352
+ age_since_created = now - created if created else None
4353
+ age_since_started = now - started if started else None
4354
+ time_to_complete = (
4355
+ completed - (started or created) if completed else None
4356
+ )
4357
+ return {
4358
+ "created_age_seconds": age_since_created,
4359
+ "started_age_seconds": age_since_started,
4360
+ "time_to_complete_seconds": time_to_complete,
4361
+ }
4362
+
4363
+
4364
+ # ---------------------------------------------------------------------------
4365
+ # Notification subscriptions (used by the gateway kanban-notifier)
4366
+ # ---------------------------------------------------------------------------
4367
+
4368
+ def add_notify_sub(
4369
+ conn: sqlite3.Connection,
4370
+ *,
4371
+ task_id: str,
4372
+ platform: str,
4373
+ chat_id: str,
4374
+ thread_id: Optional[str] = None,
4375
+ user_id: Optional[str] = None,
4376
+ notifier_profile: Optional[str] = None,
4377
+ ) -> None:
4378
+ """Register a gateway source that wants terminal-state notifications
4379
+ for ``task_id``. Idempotent on (task, platform, chat, thread)."""
4380
+ now = int(time.time())
4381
+ with write_txn(conn):
4382
+ conn.execute(
4383
+ """
4384
+ INSERT OR IGNORE INTO kanban_notify_subs
4385
+ (task_id, platform, chat_id, thread_id, user_id, notifier_profile, created_at)
4386
+ VALUES (?, ?, ?, ?, ?, ?, ?)
4387
+ """,
4388
+ (task_id, platform, chat_id, thread_id or "", user_id, notifier_profile, now),
4389
+ )
4390
+
4391
+
4392
+ def list_notify_subs(
4393
+ conn: sqlite3.Connection, task_id: Optional[str] = None,
4394
+ ) -> list[dict]:
4395
+ if task_id is not None:
4396
+ rows = conn.execute(
4397
+ "SELECT * FROM kanban_notify_subs WHERE task_id = ?", (task_id,),
4398
+ ).fetchall()
4399
+ else:
4400
+ rows = conn.execute("SELECT * FROM kanban_notify_subs").fetchall()
4401
+ return [dict(r) for r in rows]
4402
+
4403
+
4404
+ def remove_notify_sub(
4405
+ conn: sqlite3.Connection,
4406
+ *,
4407
+ task_id: str,
4408
+ platform: str,
4409
+ chat_id: str,
4410
+ thread_id: Optional[str] = None,
4411
+ ) -> bool:
4412
+ with write_txn(conn):
4413
+ cur = conn.execute(
4414
+ "DELETE FROM kanban_notify_subs WHERE task_id = ? "
4415
+ "AND platform = ? AND chat_id = ? AND thread_id = ?",
4416
+ (task_id, platform, chat_id, thread_id or ""),
4417
+ )
4418
+ return cur.rowcount > 0
4419
+
4420
+
4421
+ def unseen_events_for_sub(
4422
+ conn: sqlite3.Connection,
4423
+ *,
4424
+ task_id: str,
4425
+ platform: str,
4426
+ chat_id: str,
4427
+ thread_id: Optional[str] = None,
4428
+ kinds: Optional[Iterable[str]] = None,
4429
+ ) -> tuple[int, list[Event]]:
4430
+ """Return ``(new_cursor, events)`` for a given subscription.
4431
+
4432
+ Only events with ``id > last_event_id`` are returned. The subscription's
4433
+ cursor is NOT advanced here; call :func:`advance_notify_cursor` after
4434
+ the gateway has successfully delivered the notifications.
4435
+ """
4436
+ row = conn.execute(
4437
+ "SELECT last_event_id FROM kanban_notify_subs "
4438
+ "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
4439
+ (task_id, platform, chat_id, thread_id or ""),
4440
+ ).fetchone()
4441
+ if row is None:
4442
+ return 0, []
4443
+ cursor = int(row["last_event_id"])
4444
+ kind_list = list(kinds) if kinds else None
4445
+ q = (
4446
+ "SELECT * FROM task_events WHERE task_id = ? AND id > ? "
4447
+ + ("AND kind IN (" + ",".join("?" * len(kind_list)) + ") " if kind_list else "")
4448
+ + "ORDER BY id ASC"
4449
+ )
4450
+ params: list[Any] = [task_id, cursor]
4451
+ if kind_list:
4452
+ params.extend(kind_list)
4453
+ rows = conn.execute(q, params).fetchall()
4454
+ out: list[Event] = []
4455
+ max_id = cursor
4456
+ for r in rows:
4457
+ try:
4458
+ payload = json.loads(r["payload"]) if r["payload"] else None
4459
+ except Exception:
4460
+ payload = None
4461
+ out.append(Event(
4462
+ id=r["id"], task_id=r["task_id"], kind=r["kind"],
4463
+ payload=payload, created_at=r["created_at"],
4464
+ run_id=(int(r["run_id"]) if "run_id" in r.keys() and r["run_id"] is not None else None),
4465
+ ))
4466
+ max_id = max(max_id, int(r["id"]))
4467
+ return max_id, out
4468
+
4469
+
4470
+ def claim_unseen_events_for_sub(
4471
+ conn: sqlite3.Connection,
4472
+ *,
4473
+ task_id: str,
4474
+ platform: str,
4475
+ chat_id: str,
4476
+ thread_id: Optional[str] = None,
4477
+ kinds: Optional[Iterable[str]] = None,
4478
+ ) -> tuple[int, int, list[Event]]:
4479
+ """Atomically claim unseen notification events for one subscription.
4480
+
4481
+ Returns ``(old_cursor, new_cursor, events)``. When events are returned,
4482
+ ``kanban_notify_subs.last_event_id`` has already been advanced to
4483
+ ``new_cursor`` inside a ``BEGIN IMMEDIATE`` transaction. That makes the
4484
+ notifier's read/claim step single-owner across multiple gateway watcher
4485
+ processes pointed at the same board DB: concurrent watchers serialize on
4486
+ SQLite's writer lock, and only the first process sees and claims a given
4487
+ event range.
4488
+
4489
+ Callers should send the claimed events, then either leave the cursor at
4490
+ ``new_cursor`` on success or call :func:`rewind_notify_cursor` if delivery
4491
+ failed before any terminal unsubscribe removed the row.
4492
+ """
4493
+ with write_txn(conn):
4494
+ row = conn.execute(
4495
+ "SELECT last_event_id FROM kanban_notify_subs "
4496
+ "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
4497
+ (task_id, platform, chat_id, thread_id or ""),
4498
+ ).fetchone()
4499
+ if row is None:
4500
+ return 0, 0, []
4501
+ old_cursor = int(row["last_event_id"])
4502
+ new_cursor, events = unseen_events_for_sub(
4503
+ conn,
4504
+ task_id=task_id,
4505
+ platform=platform,
4506
+ chat_id=chat_id,
4507
+ thread_id=thread_id,
4508
+ kinds=kinds,
4509
+ )
4510
+ if not events:
4511
+ return old_cursor, old_cursor, []
4512
+ conn.execute(
4513
+ "UPDATE kanban_notify_subs SET last_event_id = ? "
4514
+ "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? "
4515
+ "AND last_event_id = ?",
4516
+ (int(new_cursor), task_id, platform, chat_id, thread_id or "", int(old_cursor)),
4517
+ )
4518
+ return old_cursor, new_cursor, events
4519
+
4520
+
4521
+ def advance_notify_cursor(
4522
+ conn: sqlite3.Connection,
4523
+ *,
4524
+ task_id: str,
4525
+ platform: str,
4526
+ chat_id: str,
4527
+ thread_id: Optional[str] = None,
4528
+ new_cursor: int,
4529
+ ) -> None:
4530
+ with write_txn(conn):
4531
+ conn.execute(
4532
+ "UPDATE kanban_notify_subs SET last_event_id = ? "
4533
+ "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?",
4534
+ (int(new_cursor), task_id, platform, chat_id, thread_id or ""),
4535
+ )
4536
+
4537
+
4538
+ def rewind_notify_cursor(
4539
+ conn: sqlite3.Connection,
4540
+ *,
4541
+ task_id: str,
4542
+ platform: str,
4543
+ chat_id: str,
4544
+ thread_id: Optional[str] = None,
4545
+ claimed_cursor: int,
4546
+ old_cursor: int,
4547
+ ) -> bool:
4548
+ """Undo a notification claim when delivery fails.
4549
+
4550
+ The CAS guard only rewinds if no later notifier advanced the row after our
4551
+ claim. This keeps retry behavior for transient send failures without
4552
+ clobbering newer progress.
4553
+ """
4554
+ with write_txn(conn):
4555
+ cur = conn.execute(
4556
+ "UPDATE kanban_notify_subs SET last_event_id = ? "
4557
+ "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? "
4558
+ "AND last_event_id = ?",
4559
+ (
4560
+ int(old_cursor), task_id, platform, chat_id, thread_id or "",
4561
+ int(claimed_cursor),
4562
+ ),
4563
+ )
4564
+ return cur.rowcount > 0
4565
+
4566
+
4567
+ # ---------------------------------------------------------------------------
4568
+ # Retention + garbage collection
4569
+ # ---------------------------------------------------------------------------
4570
+
4571
+ def gc_events(
4572
+ conn: sqlite3.Connection, *, older_than_seconds: int = 30 * 24 * 3600,
4573
+ ) -> int:
4574
+ """Delete task_events rows older than ``older_than_seconds`` for tasks
4575
+ in a terminal state (``done`` or ``archived``). Returns the number of
4576
+ rows deleted. Running / ready / blocked tasks keep their full event
4577
+ history."""
4578
+ cutoff = int(time.time()) - int(older_than_seconds)
4579
+ with write_txn(conn):
4580
+ cur = conn.execute(
4581
+ "DELETE FROM task_events WHERE created_at < ? AND task_id IN "
4582
+ "(SELECT id FROM tasks WHERE status IN ('done', 'archived'))",
4583
+ (cutoff,),
4584
+ )
4585
+ return int(cur.rowcount or 0)
4586
+
4587
+
4588
+ def gc_worker_logs(
4589
+ *, older_than_seconds: int = 30 * 24 * 3600,
4590
+ board: Optional[str] = None,
4591
+ ) -> int:
4592
+ """Delete worker log files older than ``older_than_seconds``. Returns
4593
+ the number of files removed. Kept separate from ``gc_events`` because
4594
+ log files live on disk, not in SQLite. Scoped to ``board`` (defaults
4595
+ to the active board) — per-board isolation means deleting logs from
4596
+ board A cannot touch board B's logs."""
4597
+ log_dir = worker_logs_dir(board=board)
4598
+ if not log_dir.exists():
4599
+ return 0
4600
+ cutoff = time.time() - older_than_seconds
4601
+ removed = 0
4602
+ for p in log_dir.iterdir():
4603
+ try:
4604
+ if p.is_file() and p.stat().st_mtime < cutoff:
4605
+ p.unlink()
4606
+ removed += 1
4607
+ except OSError:
4608
+ continue
4609
+ return removed
4610
+
4611
+
4612
+ # ---------------------------------------------------------------------------
4613
+ # Worker log accessor
4614
+ # ---------------------------------------------------------------------------
4615
+
4616
+ def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path:
4617
+ """Return the path to a worker's log file. The file may not exist
4618
+ (task never spawned, or log already GC'd).
4619
+
4620
+ When ``board`` is None, resolves via the active board (env var →
4621
+ current-board file → default). The dispatcher always passes the
4622
+ board explicitly to avoid any resolution ambiguity when multiple
4623
+ boards exist."""
4624
+ return worker_logs_dir(board=board) / f"{task_id}.log"
4625
+
4626
+
4627
+ def read_worker_log(
4628
+ task_id: str, *, tail_bytes: Optional[int] = None,
4629
+ board: Optional[str] = None,
4630
+ ) -> Optional[str]:
4631
+ """Read the worker log for ``task_id``. Returns None if the file
4632
+ doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
4633
+ returned (useful for the dashboard drawer which shouldn't page megabytes)."""
4634
+ path = worker_log_path(task_id, board=board)
4635
+ if not path.exists():
4636
+ return None
4637
+ try:
4638
+ if tail_bytes is None:
4639
+ return path.read_text(encoding="utf-8", errors="replace")
4640
+ size = path.stat().st_size
4641
+ with open(path, "rb") as f:
4642
+ if size > tail_bytes:
4643
+ f.seek(size - tail_bytes)
4644
+ # Skip a partial line if we tailed mid-line. But if the
4645
+ # window has no newline at all (one giant log line),
4646
+ # readline() would eat everything — in that case don't
4647
+ # skip and return the raw tail.
4648
+ probe = f.tell()
4649
+ partial = f.readline()
4650
+ if not partial.endswith(b"\n") and f.tell() >= size:
4651
+ f.seek(probe)
4652
+ data = f.read()
4653
+ return data.decode("utf-8", errors="replace")
4654
+ except OSError:
4655
+ return None
4656
+
4657
+
4658
+ # ---------------------------------------------------------------------------
4659
+ # Assignee enumeration (known profiles + per-profile board stats)
4660
+ # ---------------------------------------------------------------------------
4661
+
4662
+ def list_profiles_on_disk() -> list[str]:
4663
+ """Return the set of assignee/profile names discovered on disk.
4664
+
4665
+ Includes:
4666
+ - named profiles under ``<default-root>/profiles/<name>/config.yaml``
4667
+ - the implicit ``default`` profile when the default Hermes root exists
4668
+
4669
+ Reads profile paths directly so this module has no import dependency on
4670
+ ``hermes_cli.profiles`` (which pulls in a large chunk of the CLI startup
4671
+ path).
4672
+ """
4673
+ try:
4674
+ from calvyn_constants import get_default_hermes_root
4675
+ default_root = get_default_hermes_root()
4676
+ profiles_dir = default_root / "profiles"
4677
+ except Exception:
4678
+ return []
4679
+
4680
+ names: set[str] = set()
4681
+ if default_root.exists():
4682
+ names.add("default")
4683
+
4684
+ if profiles_dir.is_dir():
4685
+ try:
4686
+ for entry in sorted(profiles_dir.iterdir()):
4687
+ if not entry.is_dir():
4688
+ continue
4689
+ if (entry / "config.yaml").is_file():
4690
+ names.add(entry.name)
4691
+ except OSError:
4692
+ pass
4693
+
4694
+ return sorted(names)
4695
+
4696
+
4697
+ def known_assignees(conn: sqlite3.Connection) -> list[dict]:
4698
+ """Return every assignee name known to the board or on disk.
4699
+
4700
+ Each entry is ``{"name": str, "on_disk": bool, "counts": {status: n}}``.
4701
+ A name is included when it's a configured profile on disk OR when
4702
+ any non-archived task has it as the assignee. Used by:
4703
+
4704
+ - ``hermes kanban assignees`` for the terminal.
4705
+ - The dashboard assignee dropdown (so a fresh profile appears in
4706
+ the picker even before it's been given any task).
4707
+ - Router-profile heuristics ("who's overloaded?") without scanning
4708
+ the whole board.
4709
+ """
4710
+ on_disk = set(list_profiles_on_disk())
4711
+
4712
+ # Count tasks per (assignee, status), excluding archived.
4713
+ counts: dict[str, dict[str, int]] = {}
4714
+ for row in conn.execute(
4715
+ "SELECT assignee, status, COUNT(*) AS n FROM tasks "
4716
+ "WHERE status != 'archived' AND assignee IS NOT NULL "
4717
+ "GROUP BY assignee, status"
4718
+ ):
4719
+ counts.setdefault(row["assignee"], {})[row["status"]] = int(row["n"])
4720
+
4721
+ names = sorted(on_disk | set(counts.keys()))
4722
+ return [
4723
+ {
4724
+ "name": name,
4725
+ "on_disk": name in on_disk,
4726
+ "counts": counts.get(name, {}),
4727
+ }
4728
+ for name in names
4729
+ ]
4730
+
4731
+
4732
+ # ---------------------------------------------------------------------------
4733
+ # Runs (attempt history on a task)
4734
+ # ---------------------------------------------------------------------------
4735
+
4736
+ def list_runs(
4737
+ conn: sqlite3.Connection,
4738
+ task_id: str,
4739
+ *,
4740
+ include_active: bool = True,
4741
+ ) -> list[Run]:
4742
+ """Return all runs for ``task_id`` in start order.
4743
+
4744
+ ``include_active=True`` (default) includes the currently-running
4745
+ attempt if any. Set False to return only closed runs (useful for
4746
+ "how many prior attempts have there been?" checks).
4747
+ """
4748
+ q = "SELECT * FROM task_runs WHERE task_id = ?"
4749
+ params: list[Any] = [task_id]
4750
+ if not include_active:
4751
+ q += " AND ended_at IS NOT NULL"
4752
+ q += " ORDER BY started_at ASC, id ASC"
4753
+ rows = conn.execute(q, params).fetchall()
4754
+ return [Run.from_row(r) for r in rows]
4755
+
4756
+
4757
+ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]:
4758
+ row = conn.execute(
4759
+ "SELECT * FROM task_runs WHERE id = ?", (int(run_id),),
4760
+ ).fetchone()
4761
+ return Run.from_row(row) if row else None
4762
+
4763
+
4764
+ def active_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
4765
+ """Return the currently-open run for ``task_id`` (``ended_at IS NULL``)."""
4766
+ row = conn.execute(
4767
+ "SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL "
4768
+ "ORDER BY started_at DESC LIMIT 1",
4769
+ (task_id,),
4770
+ ).fetchone()
4771
+ return Run.from_row(row) if row else None
4772
+
4773
+
4774
+ def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
4775
+ """Return the most recent run regardless of outcome (active or closed)."""
4776
+ row = conn.execute(
4777
+ "SELECT * FROM task_runs WHERE task_id = ? "
4778
+ "ORDER BY started_at DESC, id DESC LIMIT 1",
4779
+ (task_id,),
4780
+ ).fetchone()
4781
+ return Run.from_row(row) if row else None
4782
+
4783
+
4784
+ def latest_summary(conn: sqlite3.Connection, task_id: str) -> Optional[str]:
4785
+ """Return the latest non-null ``task_runs.summary`` for ``task_id``.
4786
+
4787
+ The kanban-worker skill writes its handoff to ``task_runs.summary``
4788
+ via ``complete_task(summary=...)``; ``tasks.result`` is left empty
4789
+ unless the caller passes ``result=`` explicitly. Dashboards and CLI
4790
+ "show" views need this value to surface what a worker actually did
4791
+ — without it, ``tasks.result`` is NULL and the task looks like a
4792
+ no-op even when the run completed.
4793
+
4794
+ Picks the most recent run by ``ended_at`` (falling back to ``id``
4795
+ for ties or unfinished rows). Returns None if no run has a summary.
4796
+ """
4797
+ row = conn.execute(
4798
+ "SELECT summary FROM task_runs "
4799
+ "WHERE task_id = ? AND summary IS NOT NULL AND summary != '' "
4800
+ "ORDER BY COALESCE(ended_at, started_at) DESC, id DESC LIMIT 1",
4801
+ (task_id,),
4802
+ ).fetchone()
4803
+ return row["summary"] if row else None
4804
+
4805
+
4806
+ def latest_summaries(
4807
+ conn: sqlite3.Connection, task_ids: Iterable[str]
4808
+ ) -> dict[str, str]:
4809
+ """Batch-fetch latest non-null summaries for a list of task ids.
4810
+
4811
+ Used by the dashboard board endpoint to attach ``latest_summary`` to
4812
+ every card in a single SQL query, avoiding the N+1 pattern of
4813
+ calling :func:`latest_summary` per task. Returns a dict mapping
4814
+ ``task_id`` → summary string, omitting tasks with no summary.
4815
+
4816
+ Approach: a window function picks the newest non-null-summary row
4817
+ per ``task_id``; works against SQLite ≥ 3.25 (default on every
4818
+ supported platform).
4819
+ """
4820
+ ids = list(task_ids)
4821
+ if not ids:
4822
+ return {}
4823
+ placeholders = ",".join("?" for _ in ids)
4824
+ rows = conn.execute(
4825
+ f"""
4826
+ SELECT task_id, summary FROM (
4827
+ SELECT task_id, summary,
4828
+ ROW_NUMBER() OVER (
4829
+ PARTITION BY task_id
4830
+ ORDER BY COALESCE(ended_at, started_at) DESC, id DESC
4831
+ ) AS rn
4832
+ FROM task_runs
4833
+ WHERE task_id IN ({placeholders})
4834
+ AND summary IS NOT NULL AND summary != ''
4835
+ ) WHERE rn = 1
4836
+ """,
4837
+ ids,
4838
+ ).fetchall()
4839
+ return {r["task_id"]: r["summary"] for r in rows}
4840
+