discoclaw 0.1.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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import { executePollAction } from './actions-poll.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeCtx(channels) {
8
+ const cache = new Map();
9
+ for (const ch of channels)
10
+ cache.set(ch.id, ch);
11
+ return {
12
+ guild: {
13
+ channels: {
14
+ cache: {
15
+ get: (id) => cache.get(id),
16
+ find: (fn) => {
17
+ for (const ch of cache.values()) {
18
+ if (fn(ch))
19
+ return ch;
20
+ }
21
+ return undefined;
22
+ },
23
+ values: () => cache.values(),
24
+ },
25
+ },
26
+ },
27
+ client: {},
28
+ channelId: 'ch1',
29
+ messageId: 'msg1',
30
+ };
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+ describe('poll', () => {
36
+ it('creates a poll in a channel', async () => {
37
+ const ch = {
38
+ id: 'ch1',
39
+ name: 'general',
40
+ type: ChannelType.GuildText,
41
+ send: vi.fn(async () => ({})),
42
+ };
43
+ const ctx = makeCtx([ch]);
44
+ const result = await executePollAction({
45
+ type: 'poll',
46
+ channel: '#general',
47
+ question: 'What should we build?',
48
+ answers: ['Feature A', 'Feature B', 'Feature C'],
49
+ }, ctx);
50
+ expect(result).toEqual({
51
+ ok: true,
52
+ summary: 'Created poll "What should we build?" in #general with 3 options',
53
+ });
54
+ expect(ch.send).toHaveBeenCalledWith({
55
+ poll: {
56
+ question: { text: 'What should we build?' },
57
+ answers: [{ text: 'Feature A' }, { text: 'Feature B' }, { text: 'Feature C' }],
58
+ allowMultiselect: false,
59
+ duration: 24,
60
+ },
61
+ });
62
+ });
63
+ it('supports multiselect and custom duration', async () => {
64
+ const ch = {
65
+ id: 'ch1',
66
+ name: 'general',
67
+ type: ChannelType.GuildText,
68
+ send: vi.fn(async () => ({})),
69
+ };
70
+ const ctx = makeCtx([ch]);
71
+ await executePollAction({
72
+ type: 'poll',
73
+ channel: '#general',
74
+ question: 'Pick all that apply',
75
+ answers: ['A', 'B'],
76
+ allowMultiselect: true,
77
+ durationHours: 48,
78
+ }, ctx);
79
+ expect(ch.send).toHaveBeenCalledWith({
80
+ poll: {
81
+ question: { text: 'Pick all that apply' },
82
+ answers: [{ text: 'A' }, { text: 'B' }],
83
+ allowMultiselect: true,
84
+ duration: 48,
85
+ },
86
+ });
87
+ });
88
+ it('fails when channel not found', async () => {
89
+ const ctx = makeCtx([]);
90
+ const result = await executePollAction({ type: 'poll', channel: '#nonexistent', question: 'Q?', answers: ['A', 'B'] }, ctx);
91
+ expect(result).toEqual({ ok: false, error: 'Channel "#nonexistent" not found' });
92
+ });
93
+ });
@@ -0,0 +1,3 @@
1
+ export { executeTaskAction } from '../tasks/task-action-executor.js';
2
+ export { TASK_ACTION_TYPES } from '../tasks/task-action-contract.js';
3
+ export { taskActionsPromptSection } from '../tasks/task-action-prompt.js';
@@ -0,0 +1,418 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TASK_ACTION_TYPES } from '../tasks/task-action-contract.js';
3
+ import { executeTaskAction } from '../tasks/task-action-executor.js';
4
+ import { taskActionsPromptSection } from '../tasks/task-action-prompt.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Mocks — override discord-sync and related modules
7
+ // ---------------------------------------------------------------------------
8
+ vi.mock('../tasks/discord-sync.js', () => ({
9
+ resolveTasksForum: vi.fn(() => ({
10
+ threads: {
11
+ create: vi.fn(async () => ({ id: 'thread-new' })),
12
+ },
13
+ })),
14
+ createTaskThread: vi.fn(async () => 'thread-new'),
15
+ closeTaskThread: vi.fn(async () => { }),
16
+ updateTaskThreadName: vi.fn(async () => { }),
17
+ updateTaskStarterMessage: vi.fn(async () => true),
18
+ updateTaskThreadTags: vi.fn(async () => false),
19
+ ensureUnarchived: vi.fn(async () => { }),
20
+ getThreadIdFromTask: vi.fn((task) => {
21
+ const ref = task.externalRef ?? task.external_ref ?? '';
22
+ if (ref.startsWith('discord:'))
23
+ return ref.slice('discord:'.length);
24
+ return null;
25
+ }),
26
+ findExistingThreadForTask: vi.fn(async () => null),
27
+ reloadTagMapInPlace: vi.fn(async () => 2),
28
+ }));
29
+ vi.mock('../tasks/auto-tag.js', () => ({
30
+ autoTagTask: vi.fn(async () => ['feature']),
31
+ }));
32
+ vi.mock('../tasks/task-sync-engine.js', () => {
33
+ const runTaskSync = vi.fn(async () => ({
34
+ threadsCreated: 1,
35
+ emojisUpdated: 2,
36
+ starterMessagesUpdated: 5,
37
+ threadsArchived: 3,
38
+ statusesUpdated: 4,
39
+ tagsUpdated: 0,
40
+ warnings: 0,
41
+ }));
42
+ return { runTaskSync };
43
+ });
44
+ // ---------------------------------------------------------------------------
45
+ // Helpers
46
+ // ---------------------------------------------------------------------------
47
+ function makeCtx() {
48
+ return {
49
+ guild: {},
50
+ client: {
51
+ channels: {
52
+ cache: {
53
+ get: () => undefined,
54
+ },
55
+ },
56
+ },
57
+ channelId: 'test-channel',
58
+ messageId: 'test-message',
59
+ };
60
+ }
61
+ function makeStore() {
62
+ const defaultBead = (id) => ({
63
+ id,
64
+ title: 'Test bead',
65
+ description: 'A test',
66
+ status: 'open',
67
+ priority: 2,
68
+ issue_type: 'task',
69
+ owner: '',
70
+ external_ref: 'discord:111222333',
71
+ labels: ['feature'],
72
+ comments: [],
73
+ created_at: '2026-01-01T00:00:00Z',
74
+ updated_at: '2026-01-01T00:00:00Z',
75
+ });
76
+ return {
77
+ get: vi.fn((id) => {
78
+ if (id === 'ws-notfound')
79
+ return undefined;
80
+ return defaultBead(id);
81
+ }),
82
+ list: vi.fn(() => [
83
+ { id: 'ws-001', title: 'First', status: 'open', priority: 2 },
84
+ { id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
85
+ ]),
86
+ create: vi.fn((params) => ({
87
+ id: 'ws-new',
88
+ title: params.title,
89
+ description: params.description ?? '',
90
+ status: 'open',
91
+ priority: params.priority ?? 2,
92
+ issue_type: 'task',
93
+ owner: '',
94
+ external_ref: '',
95
+ labels: params.labels ?? [],
96
+ comments: [],
97
+ created_at: '2026-01-01T00:00:00Z',
98
+ updated_at: '2026-01-01T00:00:00Z',
99
+ })),
100
+ update: vi.fn((id) => defaultBead(id)),
101
+ close: vi.fn((id) => ({ ...defaultBead(id), status: 'closed' })),
102
+ addLabel: vi.fn((id) => defaultBead(id)),
103
+ };
104
+ }
105
+ function makeTaskCtx(overrides) {
106
+ return {
107
+ tasksCwd: '/tmp/test-beads',
108
+ forumId: 'forum-123',
109
+ tagMap: { feature: 'tag-1', bug: 'tag-2' },
110
+ store: makeStore(),
111
+ runtime: { id: 'other', capabilities: new Set(), invoke: async function* () { } },
112
+ autoTag: false,
113
+ autoTagModel: 'haiku',
114
+ ...overrides,
115
+ };
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Tests
119
+ // ---------------------------------------------------------------------------
120
+ describe('TASK_ACTION_TYPES', () => {
121
+ it('contains all task action types', () => {
122
+ expect(TASK_ACTION_TYPES.has('taskCreate')).toBe(true);
123
+ expect(TASK_ACTION_TYPES.has('taskUpdate')).toBe(true);
124
+ expect(TASK_ACTION_TYPES.has('taskClose')).toBe(true);
125
+ expect(TASK_ACTION_TYPES.has('taskShow')).toBe(true);
126
+ expect(TASK_ACTION_TYPES.has('taskList')).toBe(true);
127
+ expect(TASK_ACTION_TYPES.has('taskSync')).toBe(true);
128
+ expect(TASK_ACTION_TYPES.has('tagMapReload')).toBe(true);
129
+ });
130
+ it('does not contain non-task types', () => {
131
+ expect(TASK_ACTION_TYPES.has('channelCreate')).toBe(false);
132
+ });
133
+ });
134
+ describe('executeTaskAction', () => {
135
+ it('taskCreate returns created bead summary', async () => {
136
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
137
+ expect(result.ok).toBe(true);
138
+ expect(result.summary).toContain('ws-new');
139
+ expect(result.summary).toContain('New task');
140
+ });
141
+ it('taskCreate calls forumCountSync.requestUpdate', async () => {
142
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
143
+ await executeTaskAction({ type: 'taskCreate', title: 'Counted task' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
144
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
145
+ });
146
+ it('taskCreate fails without title', async () => {
147
+ const result = await executeTaskAction({ type: 'taskCreate', title: '' }, makeCtx(), makeTaskCtx());
148
+ expect(result.ok).toBe(false);
149
+ });
150
+ it('taskCreate honors no-thread by skipping thread creation', async () => {
151
+ const { createTaskThread } = await import('../tasks/discord-sync.js');
152
+ createTaskThread.mockClear?.();
153
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
154
+ expect(result.ok).toBe(true);
155
+ expect(createTaskThread).not.toHaveBeenCalled();
156
+ });
157
+ it('taskCreate skips thread creation when task is already linked before direct lifecycle step', async () => {
158
+ const { createTaskThread } = await import('../tasks/discord-sync.js');
159
+ createTaskThread.mockClear?.();
160
+ const store = makeStore();
161
+ store.get.mockImplementation((id) => ({
162
+ id,
163
+ title: 'Already linked',
164
+ status: 'open',
165
+ priority: 2,
166
+ issue_type: 'task',
167
+ owner: '',
168
+ external_ref: 'discord:thread-existing',
169
+ labels: ['feature'],
170
+ comments: [],
171
+ created_at: '2026-01-01T00:00:00Z',
172
+ updated_at: '2026-01-01T00:00:00Z',
173
+ }));
174
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
175
+ expect(result.ok).toBe(true);
176
+ expect(result.summary).toContain('thread linked');
177
+ expect(createTaskThread).not.toHaveBeenCalled();
178
+ });
179
+ it('taskUpdate returns updated summary', async () => {
180
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress', priority: 1 }, makeCtx(), makeTaskCtx());
181
+ expect(result.ok).toBe(true);
182
+ expect(result.summary).toContain('ws-001');
183
+ expect(result.summary).toContain('in_progress');
184
+ });
185
+ it('taskUpdate calls forumCountSync.requestUpdate when status changed', async () => {
186
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
187
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
188
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
189
+ });
190
+ it('taskUpdate does NOT call forumCountSync.requestUpdate without status change', async () => {
191
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
192
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', title: 'New title' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
193
+ expect(mockSync.requestUpdate).not.toHaveBeenCalled();
194
+ });
195
+ it('taskUpdate fails without taskId', async () => {
196
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: '' }, makeCtx(), makeTaskCtx());
197
+ expect(result.ok).toBe(false);
198
+ });
199
+ it('taskUpdate calls updateBeadStarterMessage when bead has a linked thread', async () => {
200
+ const { updateTaskStarterMessage } = await import('../tasks/discord-sync.js');
201
+ updateTaskStarterMessage.mockClear();
202
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeTaskCtx());
203
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), undefined);
204
+ });
205
+ it('taskUpdate passes sidebarMentionUserId to updateBeadStarterMessage', async () => {
206
+ const { updateTaskStarterMessage } = await import('../tasks/discord-sync.js');
207
+ updateTaskStarterMessage.mockClear();
208
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeTaskCtx({ sidebarMentionUserId: '999' }));
209
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), '999');
210
+ });
211
+ it('taskUpdate succeeds even if updateBeadStarterMessage throws', async () => {
212
+ const { updateTaskStarterMessage } = await import('../tasks/discord-sync.js');
213
+ updateTaskStarterMessage.mockRejectedValueOnce(new Error('Discord API error'));
214
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
215
+ expect(result.ok).toBe(true);
216
+ });
217
+ it('taskUpdate calls updateBeadThreadTags when bead has a linked thread', async () => {
218
+ const { updateTaskThreadTags } = await import('../tasks/discord-sync.js');
219
+ updateTaskThreadTags.mockClear();
220
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
221
+ expect(updateTaskThreadTags).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), expect.objectContaining({ feature: 'tag-1' }));
222
+ });
223
+ it('taskClose passes tagMap to closeBeadThread', async () => {
224
+ const { closeTaskThread } = await import('../tasks/discord-sync.js');
225
+ closeTaskThread.mockClear();
226
+ const taskCtx = makeTaskCtx();
227
+ await executeTaskAction({ type: 'taskClose', taskId: 'ws-001', reason: 'Done' }, makeCtx(), taskCtx);
228
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), taskCtx.tagMap, taskCtx.log);
229
+ });
230
+ it('taskUpdate rejects invalid status', async () => {
231
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'nonsense' }, makeCtx(), makeTaskCtx());
232
+ expect(result.ok).toBe(false);
233
+ expect(result.error).toContain('Invalid');
234
+ });
235
+ it('taskClose returns closed summary', async () => {
236
+ const result = await executeTaskAction({ type: 'taskClose', taskId: 'ws-001', reason: 'Done' }, makeCtx(), makeTaskCtx());
237
+ expect(result.ok).toBe(true);
238
+ expect(result.summary).toContain('ws-001');
239
+ expect(result.summary).toContain('Done');
240
+ });
241
+ it('taskClose calls forumCountSync.requestUpdate', async () => {
242
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
243
+ await executeTaskAction({ type: 'taskClose', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
244
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
245
+ });
246
+ it('taskShow returns bead details', async () => {
247
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
248
+ expect(result.ok).toBe(true);
249
+ expect(result.summary).toContain('Test bead');
250
+ expect(result.summary).toContain('ws-001');
251
+ });
252
+ it('taskShow fails for unknown bead', async () => {
253
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
254
+ expect(result.ok).toBe(false);
255
+ expect(result.error).toContain('not found');
256
+ });
257
+ it('taskList returns bead list', async () => {
258
+ const result = await executeTaskAction({ type: 'taskList', status: 'open', limit: 10 }, makeCtx(), makeTaskCtx());
259
+ expect(result.ok).toBe(true);
260
+ expect(result.summary).toContain('ws-001');
261
+ expect(result.summary).toContain('ws-002');
262
+ });
263
+ it('taskList defaults to limit 50 when no limit provided', async () => {
264
+ const store = makeStore();
265
+ await executeTaskAction({ type: 'taskList', status: 'all' }, makeCtx(), makeTaskCtx({ store: store }));
266
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 50 }));
267
+ });
268
+ it('taskList respects explicit limit', async () => {
269
+ const store = makeStore();
270
+ await executeTaskAction({ type: 'taskList', status: 'all', limit: 5 }, makeCtx(), makeTaskCtx({ store: store }));
271
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 5 }));
272
+ });
273
+ it('taskSync returns extended sync summary', async () => {
274
+ const result = await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx());
275
+ expect(result.ok).toBe(true);
276
+ expect(result.summary).toContain('status-fixes');
277
+ expect(result.summary).toContain('5 starters');
278
+ });
279
+ it('taskSync passes statusPoster through to runTaskSync', async () => {
280
+ const { runTaskSync } = await import('../tasks/task-sync-engine.js');
281
+ runTaskSync.mockClear();
282
+ const mockPoster = { taskSyncComplete: vi.fn() };
283
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ statusPoster: mockPoster }));
284
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster: mockPoster, mentionUserId: undefined }));
285
+ });
286
+ it('taskSync passes sidebarMentionUserId as mentionUserId to runTaskSync', async () => {
287
+ const { runTaskSync } = await import('../tasks/task-sync-engine.js');
288
+ runTaskSync.mockClear();
289
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ sidebarMentionUserId: '999' }));
290
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ mentionUserId: '999' }));
291
+ });
292
+ it('taskSync lazily creates and reuses syncCoordinator when missing', async () => {
293
+ const { runTaskSync } = await import('../tasks/task-sync-engine.js');
294
+ runTaskSync.mockClear();
295
+ const taskCtx = makeTaskCtx();
296
+ expect(taskCtx.syncCoordinator).toBeUndefined();
297
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), taskCtx);
298
+ const firstCoordinator = taskCtx.syncCoordinator;
299
+ expect(firstCoordinator).toBeDefined();
300
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), taskCtx);
301
+ expect(taskCtx.syncCoordinator).toBe(firstCoordinator);
302
+ expect(runTaskSync).toHaveBeenCalledTimes(2);
303
+ });
304
+ it('taskUpdate schedules repair sync after thread lifecycle failure without prewired coordinator', async () => {
305
+ const { runTaskSync } = await import('../tasks/task-sync-engine.js');
306
+ const { updateTaskThreadName } = await import('../tasks/discord-sync.js');
307
+ runTaskSync.mockClear();
308
+ updateTaskThreadName.mockRejectedValueOnce(new Error('rename failed'));
309
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
310
+ expect(result.ok).toBe(true);
311
+ await new Promise((resolve) => setTimeout(resolve, 0));
312
+ expect(runTaskSync).toHaveBeenCalled();
313
+ });
314
+ });
315
+ describe('tagMapReload action', () => {
316
+ it('success: returns old/new count with tag names', async () => {
317
+ const { reloadTagMapInPlace } = await import('../tasks/discord-sync.js');
318
+ reloadTagMapInPlace.mockClear();
319
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
320
+ // Simulate reload: clear and add new tags
321
+ for (const k of Object.keys(tagMap))
322
+ delete tagMap[k];
323
+ Object.assign(tagMap, { bug: '111', feature: '222', docs: '333' });
324
+ return 3;
325
+ });
326
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
327
+ expect(result.ok).toBe(true);
328
+ expect(result.summary).toContain('Tag map reloaded');
329
+ expect(result.summary).toContain('bug');
330
+ expect(result.summary).toContain('feature');
331
+ expect(result.summary).toContain('docs');
332
+ });
333
+ it('success with >10 tags: truncates tag list display', async () => {
334
+ const { reloadTagMapInPlace } = await import('../tasks/discord-sync.js');
335
+ reloadTagMapInPlace.mockClear();
336
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
337
+ for (const k of Object.keys(tagMap))
338
+ delete tagMap[k];
339
+ for (let i = 0; i < 15; i++)
340
+ tagMap[`tag${i}`] = `id${i}`;
341
+ return 15;
342
+ });
343
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
344
+ expect(result.ok).toBe(true);
345
+ expect(result.summary).toContain('(+5 more)');
346
+ });
347
+ it('failure: returns error with message, map preserved', async () => {
348
+ const { reloadTagMapInPlace } = await import('../tasks/discord-sync.js');
349
+ reloadTagMapInPlace.mockClear();
350
+ reloadTagMapInPlace.mockRejectedValueOnce(new Error('ENOENT: file not found'));
351
+ const tagMap = { existing: '999' };
352
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json', tagMap }));
353
+ expect(result.ok).toBe(false);
354
+ expect(result.error).toContain('Tag map reload failed');
355
+ expect(result.error).toContain('ENOENT');
356
+ });
357
+ it('without tagMapPath: returns error', async () => {
358
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx());
359
+ expect(result.ok).toBe(false);
360
+ expect(result.error).toContain('Tag map path not configured');
361
+ });
362
+ });
363
+ describe('taskSync coordinator tagMap reload behavior', () => {
364
+ it('reloads tag map before runTaskSync when tagMapPath is configured', async () => {
365
+ const { reloadTagMapInPlace } = await import('../tasks/discord-sync.js');
366
+ const { runTaskSync } = await import('../tasks/task-sync-engine.js');
367
+ reloadTagMapInPlace.mockClear();
368
+ runTaskSync.mockClear();
369
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
370
+ expect(reloadTagMapInPlace).toHaveBeenCalledWith('/tmp/tag-map.json', expect.any(Object));
371
+ expect(runTaskSync).toHaveBeenCalled();
372
+ });
373
+ it('does not attempt reload without tagMapPath', async () => {
374
+ const { reloadTagMapInPlace } = await import('../tasks/discord-sync.js');
375
+ reloadTagMapInPlace.mockClear();
376
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx());
377
+ expect(reloadTagMapInPlace).not.toHaveBeenCalled();
378
+ });
379
+ });
380
+ describe('taskActionsPromptSection', () => {
381
+ it('returns non-empty prompt section', () => {
382
+ const section = taskActionsPromptSection();
383
+ expect(section).toContain('taskCreate');
384
+ expect(section).toContain('taskClose');
385
+ expect(section).toContain('taskList');
386
+ });
387
+ it('includes tagMapReload in prompt section', () => {
388
+ const section = taskActionsPromptSection();
389
+ expect(section).toContain('tagMapReload');
390
+ });
391
+ it('includes task quality guidelines', () => {
392
+ const section = taskActionsPromptSection();
393
+ expect(section).toContain('imperative mood');
394
+ expect(section).toContain('Description');
395
+ expect(section).toContain('P0');
396
+ expect(section).toContain('P1');
397
+ expect(section).toContain('taskUpdate');
398
+ });
399
+ it('keeps guidelines block under 600 chars', () => {
400
+ const section = taskActionsPromptSection();
401
+ const marker = '#### Task Quality Guidelines';
402
+ const crossRefMarker = '#### Cross-Task References';
403
+ const idx = section.indexOf(marker);
404
+ expect(idx).toBeGreaterThanOrEqual(0);
405
+ const crossRefIdx = section.indexOf(crossRefMarker);
406
+ // Slice up to the cross-task section (or end of string if not found)
407
+ const end = crossRefIdx > idx ? crossRefIdx : section.length;
408
+ const guidelinesBlock = section.slice(idx, end);
409
+ expect(guidelinesBlock.length).toBeLessThanOrEqual(600);
410
+ });
411
+ it('includes cross-task references guideline', () => {
412
+ const section = taskActionsPromptSection();
413
+ expect(section).toContain('#### Cross-Task References');
414
+ expect(section).toContain('taskShow');
415
+ expect(section).toContain('taskUpdate');
416
+ expect(section).toContain('taskSync');
417
+ });
418
+ });