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,92 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
3
+ import { startTaskSyncWatcher } from './sync-watcher.js';
4
+ function makeCoordinator() {
5
+ return {
6
+ sync: vi.fn(async () => ({
7
+ threadsCreated: 0,
8
+ emojisUpdated: 0,
9
+ starterMessagesUpdated: 0,
10
+ threadsArchived: 0,
11
+ statusesUpdated: 0,
12
+ tagsUpdated: 0,
13
+ warnings: 0,
14
+ })),
15
+ };
16
+ }
17
+ function makeStore() {
18
+ return new EventEmitter();
19
+ }
20
+ describe('startTaskSyncWatcher', () => {
21
+ it('triggers sync on store created event', async () => {
22
+ const coordinator = makeCoordinator();
23
+ const store = makeStore();
24
+ const handle = startTaskSyncWatcher({ coordinator, store });
25
+ store.emit('created', {});
26
+ await Promise.resolve();
27
+ expect(coordinator.sync).toHaveBeenCalledOnce();
28
+ expect(coordinator.sync).toHaveBeenCalledWith();
29
+ handle.stop();
30
+ });
31
+ it('triggers sync on store updated event', async () => {
32
+ const coordinator = makeCoordinator();
33
+ const store = makeStore();
34
+ const handle = startTaskSyncWatcher({ coordinator, store });
35
+ store.emit('updated', {}, {});
36
+ await Promise.resolve();
37
+ expect(coordinator.sync).toHaveBeenCalledOnce();
38
+ handle.stop();
39
+ });
40
+ it('triggers sync on store closed event', async () => {
41
+ const coordinator = makeCoordinator();
42
+ const store = makeStore();
43
+ const handle = startTaskSyncWatcher({ coordinator, store });
44
+ store.emit('closed', {});
45
+ await Promise.resolve();
46
+ expect(coordinator.sync).toHaveBeenCalledOnce();
47
+ handle.stop();
48
+ });
49
+ it('triggers sync on store labeled event', async () => {
50
+ const coordinator = makeCoordinator();
51
+ const store = makeStore();
52
+ const handle = startTaskSyncWatcher({ coordinator, store });
53
+ store.emit('labeled', {}, 'some-label');
54
+ await Promise.resolve();
55
+ expect(coordinator.sync).toHaveBeenCalledOnce();
56
+ handle.stop();
57
+ });
58
+ it('no sync fires after stop()', async () => {
59
+ const coordinator = makeCoordinator();
60
+ const store = makeStore();
61
+ const handle = startTaskSyncWatcher({ coordinator, store });
62
+ handle.stop();
63
+ store.emit('created', {});
64
+ await Promise.resolve();
65
+ expect(coordinator.sync).not.toHaveBeenCalled();
66
+ });
67
+ it('multiple events each trigger a sync call (coordinator coalesces)', async () => {
68
+ const coordinator = makeCoordinator();
69
+ const store = makeStore();
70
+ const handle = startTaskSyncWatcher({ coordinator, store });
71
+ store.emit('created', {});
72
+ store.emit('updated', {}, {});
73
+ store.emit('closed', {});
74
+ await Promise.resolve();
75
+ // Each event fires a sync; the coordinator's concurrency guard coalesces them.
76
+ expect(coordinator.sync).toHaveBeenCalledTimes(3);
77
+ handle.stop();
78
+ });
79
+ it('sync failure is caught and logged without throwing', async () => {
80
+ const coordinator = {
81
+ sync: vi.fn().mockRejectedValue(new Error('network error')),
82
+ };
83
+ const store = makeStore();
84
+ const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() };
85
+ const handle = startTaskSyncWatcher({ coordinator, store, log });
86
+ store.emit('created', {});
87
+ // Flush the microtask queue so the catch handler runs.
88
+ await new Promise((r) => setTimeout(r, 0));
89
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:watcher sync failed');
90
+ handle.stop();
91
+ });
92
+ });
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs/promises';
2
+ /** Load a tag-map.json file: `{ "tag-name": "discord-tag-id", ... }`. */
3
+ export async function loadTagMap(filePath) {
4
+ try {
5
+ const raw = await fs.readFile(filePath, 'utf8');
6
+ return JSON.parse(raw);
7
+ }
8
+ catch {
9
+ return {};
10
+ }
11
+ }
12
+ /**
13
+ * Reload a tag-map.json file and mutate the existing TagMap object in-place.
14
+ * Unlike loadTagMap(), this throws on read/parse/validation failure so callers
15
+ * can catch and preserve the existing map. Only mutates after full validation.
16
+ * Returns the new tag count.
17
+ */
18
+ export async function reloadTagMapInPlace(tagMapPath, tagMap) {
19
+ const raw = await fs.readFile(tagMapPath, 'utf8');
20
+ const parsed = JSON.parse(raw);
21
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
22
+ throw new Error(`tag-map.json must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
23
+ }
24
+ const newMap = {};
25
+ for (const [key, val] of Object.entries(parsed)) {
26
+ if (typeof val !== 'string') {
27
+ throw new Error(`tag-map.json value for "${key}" must be a string, got ${typeof val}`);
28
+ }
29
+ newMap[key] = val;
30
+ }
31
+ // Only mutate after full validation.
32
+ for (const key of Object.keys(tagMap))
33
+ delete tagMap[key];
34
+ Object.assign(tagMap, newMap);
35
+ return Object.keys(tagMap).length;
36
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs/promises';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { loadTagMap, reloadTagMapInPlace } from './tag-map.js';
4
+ describe('loadTagMap', () => {
5
+ it('returns parsed tag map when file is valid', async () => {
6
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce(JSON.stringify({ bug: '111', feature: '222' }));
7
+ const result = await loadTagMap('/tmp/tag-map.json');
8
+ expect(result).toEqual({ bug: '111', feature: '222' });
9
+ });
10
+ it('returns empty map when file is missing', async () => {
11
+ vi.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('ENOENT'));
12
+ const result = await loadTagMap('/tmp/missing.json');
13
+ expect(result).toEqual({});
14
+ });
15
+ it('returns empty map when JSON is invalid', async () => {
16
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce('{ bad json');
17
+ const result = await loadTagMap('/tmp/bad.json');
18
+ expect(result).toEqual({});
19
+ });
20
+ });
21
+ describe('reloadTagMapInPlace', () => {
22
+ it('reads file, mutates object in-place, and returns count', async () => {
23
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce(JSON.stringify({ bug: '111', feature: '222' }));
24
+ const tagMap = { old: '000' };
25
+ const count = await reloadTagMapInPlace('/tmp/tag-map.json', tagMap);
26
+ expect(count).toBe(2);
27
+ expect(tagMap).toEqual({ bug: '111', feature: '222' });
28
+ expect(tagMap).not.toHaveProperty('old');
29
+ });
30
+ it('throws on read failure, existing map untouched', async () => {
31
+ vi.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('ENOENT'));
32
+ const tagMap = { existing: '999' };
33
+ await expect(reloadTagMapInPlace('/tmp/missing.json', tagMap)).rejects.toThrow('ENOENT');
34
+ expect(tagMap).toEqual({ existing: '999' });
35
+ });
36
+ it('throws on truncated JSON, existing map untouched', async () => {
37
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce('{ "bug": "111"');
38
+ const tagMap = { existing: '999' };
39
+ await expect(reloadTagMapInPlace('/tmp/bad.json', tagMap)).rejects.toThrow();
40
+ expect(tagMap).toEqual({ existing: '999' });
41
+ });
42
+ it('rejects array with descriptive error, existing map untouched', async () => {
43
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce('["a", "b"]');
44
+ const tagMap = { existing: '999' };
45
+ await expect(reloadTagMapInPlace('/tmp/array.json', tagMap)).rejects.toThrow('must be a JSON object, got array');
46
+ expect(tagMap).toEqual({ existing: '999' });
47
+ });
48
+ it('rejects non-string values with descriptive error, existing map untouched', async () => {
49
+ vi.spyOn(fs, 'readFile').mockResolvedValueOnce(JSON.stringify({ bug: 123 }));
50
+ const tagMap = { existing: '999' };
51
+ await expect(reloadTagMapInPlace('/tmp/bad-val.json', tagMap)).rejects.toThrow('must be a string, got number');
52
+ expect(tagMap).toEqual({ existing: '999' });
53
+ });
54
+ });
@@ -0,0 +1,16 @@
1
+ const TASK_TYPE_MAP = {
2
+ taskCreate: true,
3
+ taskUpdate: true,
4
+ taskClose: true,
5
+ taskShow: true,
6
+ taskList: true,
7
+ taskSync: true,
8
+ tagMapReload: true,
9
+ };
10
+ export const TASK_ACTION_TYPES = new Set(Object.keys(TASK_TYPE_MAP));
11
+ export function isTaskActionType(type) {
12
+ return TASK_ACTION_TYPES.has(type);
13
+ }
14
+ export function isTaskActionRequest(action) {
15
+ return typeof action.type === 'string' && isTaskActionType(action.type);
16
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isTaskActionRequest, isTaskActionType, TASK_ACTION_TYPES } from './task-action-contract.js';
3
+ describe('task-action-contract guards', () => {
4
+ it('accepts canonical task action types', () => {
5
+ for (const type of TASK_ACTION_TYPES) {
6
+ expect(isTaskActionType(type)).toBe(true);
7
+ }
8
+ });
9
+ it('recognizes task action requests by type', () => {
10
+ expect(isTaskActionRequest({ type: 'taskCreate' })).toBe(true);
11
+ expect(isTaskActionRequest({ type: 'taskSync' })).toBe(true);
12
+ expect(isTaskActionRequest({ type: 'channelCreate' })).toBe(false);
13
+ expect(isTaskActionRequest({ type: 42 })).toBe(false);
14
+ expect(isTaskActionRequest({})).toBe(false);
15
+ });
16
+ });
@@ -0,0 +1,18 @@
1
+ import { handleTaskClose, handleTaskCreate, handleTaskUpdate, } from './task-action-mutations.js';
2
+ import { handleTagMapReload, handleTaskList, handleTaskShow, handleTaskSync, } from './task-action-read-ops.js';
3
+ const TASK_ACTION_HANDLERS = {
4
+ taskCreate: handleTaskCreate,
5
+ taskUpdate: handleTaskUpdate,
6
+ taskClose: handleTaskClose,
7
+ taskShow: handleTaskShow,
8
+ taskList: handleTaskList,
9
+ taskSync: handleTaskSync,
10
+ tagMapReload: handleTagMapReload,
11
+ };
12
+ // ---------------------------------------------------------------------------
13
+ // Executor
14
+ // ---------------------------------------------------------------------------
15
+ export async function executeTaskAction(action, ctx, taskCtx) {
16
+ const handler = TASK_ACTION_HANDLERS[action.type];
17
+ return handler(action, ctx, taskCtx);
18
+ }
@@ -0,0 +1,420 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TASK_ACTION_TYPES } from './task-action-contract.js';
3
+ import { executeTaskAction } from './task-action-executor.js';
4
+ import { taskActionsPromptSection } from './task-action-prompt.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Mocks — override thread ops and related modules
7
+ // ---------------------------------------------------------------------------
8
+ vi.mock('./thread-ops.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
+ findExistingThreadForTask: vi.fn(async () => null),
21
+ }));
22
+ vi.mock('./thread-helpers.js', () => ({
23
+ getThreadIdFromTask: vi.fn((task) => {
24
+ const ref = task.externalRef ?? task.external_ref ?? '';
25
+ if (ref.startsWith('discord:'))
26
+ return ref.slice('discord:'.length);
27
+ return null;
28
+ }),
29
+ }));
30
+ vi.mock('./tag-map.js', () => ({
31
+ reloadTagMapInPlace: vi.fn(async () => 2),
32
+ }));
33
+ vi.mock('./auto-tag.js', () => ({
34
+ autoTagTask: vi.fn(async () => ['feature']),
35
+ }));
36
+ vi.mock('./task-sync-engine.js', () => {
37
+ const runTaskSync = vi.fn(async () => ({
38
+ threadsCreated: 1,
39
+ emojisUpdated: 2,
40
+ starterMessagesUpdated: 5,
41
+ threadsArchived: 3,
42
+ statusesUpdated: 4,
43
+ tagsUpdated: 0,
44
+ warnings: 0,
45
+ }));
46
+ return { runTaskSync };
47
+ });
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+ function makeCtx() {
52
+ return {
53
+ guild: {},
54
+ client: {
55
+ channels: {
56
+ cache: {
57
+ get: () => undefined,
58
+ },
59
+ },
60
+ },
61
+ };
62
+ }
63
+ function makeStore() {
64
+ const defaultTask = (id) => ({
65
+ id,
66
+ title: 'Test task',
67
+ description: 'A test',
68
+ status: 'open',
69
+ priority: 2,
70
+ issue_type: 'task',
71
+ owner: '',
72
+ external_ref: 'discord:111222333',
73
+ labels: ['feature'],
74
+ comments: [],
75
+ created_at: '2026-01-01T00:00:00Z',
76
+ updated_at: '2026-01-01T00:00:00Z',
77
+ });
78
+ return {
79
+ get: vi.fn((id) => {
80
+ if (id === 'ws-notfound')
81
+ return undefined;
82
+ return defaultTask(id);
83
+ }),
84
+ list: vi.fn(() => [
85
+ { id: 'ws-001', title: 'First', status: 'open', priority: 2 },
86
+ { id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
87
+ ]),
88
+ create: vi.fn((params) => ({
89
+ id: 'ws-new',
90
+ title: params.title,
91
+ description: params.description ?? '',
92
+ status: 'open',
93
+ priority: params.priority ?? 2,
94
+ issue_type: 'task',
95
+ owner: '',
96
+ external_ref: '',
97
+ labels: params.labels ?? [],
98
+ comments: [],
99
+ created_at: '2026-01-01T00:00:00Z',
100
+ updated_at: '2026-01-01T00:00:00Z',
101
+ })),
102
+ update: vi.fn((id) => defaultTask(id)),
103
+ close: vi.fn((id) => ({ ...defaultTask(id), status: 'closed' })),
104
+ addLabel: vi.fn((id) => defaultTask(id)),
105
+ };
106
+ }
107
+ function makeTaskCtx(overrides) {
108
+ return {
109
+ tasksCwd: '/tmp/test-tasks',
110
+ forumId: 'forum-123',
111
+ tagMap: { feature: 'tag-1', bug: 'tag-2' },
112
+ store: makeStore(),
113
+ runtime: { id: 'other', capabilities: new Set(), invoke: async function* () { } },
114
+ autoTag: false,
115
+ autoTagModel: 'haiku',
116
+ ...overrides,
117
+ };
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Tests
121
+ // ---------------------------------------------------------------------------
122
+ describe('TASK_ACTION_TYPES', () => {
123
+ it('contains all task action types', () => {
124
+ expect(TASK_ACTION_TYPES.has('taskCreate')).toBe(true);
125
+ expect(TASK_ACTION_TYPES.has('taskUpdate')).toBe(true);
126
+ expect(TASK_ACTION_TYPES.has('taskClose')).toBe(true);
127
+ expect(TASK_ACTION_TYPES.has('taskShow')).toBe(true);
128
+ expect(TASK_ACTION_TYPES.has('taskList')).toBe(true);
129
+ expect(TASK_ACTION_TYPES.has('taskSync')).toBe(true);
130
+ expect(TASK_ACTION_TYPES.has('tagMapReload')).toBe(true);
131
+ });
132
+ it('does not contain non-task types', () => {
133
+ expect(TASK_ACTION_TYPES.has('channelCreate')).toBe(false);
134
+ });
135
+ });
136
+ describe('executeTaskAction', () => {
137
+ it('taskCreate returns created task summary', async () => {
138
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'New task', priority: 1 }, makeCtx(), makeTaskCtx());
139
+ expect(result.ok).toBe(true);
140
+ expect(result.summary).toContain('ws-new');
141
+ expect(result.summary).toContain('New task');
142
+ });
143
+ it('taskCreate calls forumCountSync.requestUpdate', async () => {
144
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
145
+ await executeTaskAction({ type: 'taskCreate', title: 'Counted task' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
146
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
147
+ });
148
+ it('taskCreate fails without title', async () => {
149
+ const result = await executeTaskAction({ type: 'taskCreate', title: '' }, makeCtx(), makeTaskCtx());
150
+ expect(result.ok).toBe(false);
151
+ });
152
+ it('taskCreate honors no-thread by skipping thread creation', async () => {
153
+ const { createTaskThread } = await import('./thread-ops.js');
154
+ createTaskThread.mockClear?.();
155
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeTaskCtx());
156
+ expect(result.ok).toBe(true);
157
+ expect(createTaskThread).not.toHaveBeenCalled();
158
+ });
159
+ it('taskCreate skips thread creation when task is already linked before direct lifecycle step', async () => {
160
+ const { createTaskThread } = await import('./thread-ops.js');
161
+ createTaskThread.mockClear?.();
162
+ const store = makeStore();
163
+ store.get.mockImplementation((id) => ({
164
+ id,
165
+ title: 'Already linked',
166
+ status: 'open',
167
+ priority: 2,
168
+ issue_type: 'task',
169
+ owner: '',
170
+ external_ref: 'discord:thread-existing',
171
+ labels: ['feature'],
172
+ comments: [],
173
+ created_at: '2026-01-01T00:00:00Z',
174
+ updated_at: '2026-01-01T00:00:00Z',
175
+ }));
176
+ const result = await executeTaskAction({ type: 'taskCreate', title: 'Task already linked' }, makeCtx(), makeTaskCtx({ store: store }));
177
+ expect(result.ok).toBe(true);
178
+ expect(result.summary).toContain('thread linked');
179
+ expect(createTaskThread).not.toHaveBeenCalled();
180
+ });
181
+ it('taskUpdate returns updated summary', async () => {
182
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress', priority: 1 }, makeCtx(), makeTaskCtx());
183
+ expect(result.ok).toBe(true);
184
+ expect(result.summary).toContain('ws-001');
185
+ expect(result.summary).toContain('in_progress');
186
+ });
187
+ it('taskUpdate calls forumCountSync.requestUpdate when status changed', async () => {
188
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
189
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
190
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
191
+ });
192
+ it('taskUpdate does NOT call forumCountSync.requestUpdate without status change', async () => {
193
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
194
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', title: 'New title' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
195
+ expect(mockSync.requestUpdate).not.toHaveBeenCalled();
196
+ });
197
+ it('taskUpdate fails without taskId', async () => {
198
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: '' }, makeCtx(), makeTaskCtx());
199
+ expect(result.ok).toBe(false);
200
+ });
201
+ it('taskUpdate calls updateTaskStarterMessage when task has a linked thread', async () => {
202
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
203
+ updateTaskStarterMessage.mockClear();
204
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeTaskCtx());
205
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), undefined);
206
+ });
207
+ it('taskUpdate passes sidebarMentionUserId to updateTaskStarterMessage', async () => {
208
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
209
+ updateTaskStarterMessage.mockClear();
210
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeTaskCtx({ sidebarMentionUserId: '999' }));
211
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), '999');
212
+ });
213
+ it('taskUpdate succeeds even if updateTaskStarterMessage throws', async () => {
214
+ const { updateTaskStarterMessage } = await import('./thread-ops.js');
215
+ updateTaskStarterMessage.mockRejectedValueOnce(new Error('Discord API error'));
216
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
217
+ expect(result.ok).toBe(true);
218
+ });
219
+ it('taskUpdate calls updateTaskThreadTags when task has a linked thread', async () => {
220
+ const { updateTaskThreadTags } = await import('./thread-ops.js');
221
+ updateTaskThreadTags.mockClear();
222
+ await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
223
+ expect(updateTaskThreadTags).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), expect.objectContaining({ feature: 'tag-1' }));
224
+ });
225
+ it('taskClose passes tagMap to closeTaskThread', async () => {
226
+ const { closeTaskThread } = await import('./thread-ops.js');
227
+ closeTaskThread.mockClear();
228
+ const taskCtx = makeTaskCtx();
229
+ await executeTaskAction({ type: 'taskClose', taskId: 'ws-001', reason: 'Done' }, makeCtx(), taskCtx);
230
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), taskCtx.tagMap, taskCtx.log);
231
+ });
232
+ it('taskUpdate rejects invalid status', async () => {
233
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'nonsense' }, makeCtx(), makeTaskCtx());
234
+ expect(result.ok).toBe(false);
235
+ expect(result.error).toContain('Invalid');
236
+ });
237
+ it('taskClose returns closed summary', async () => {
238
+ const result = await executeTaskAction({ type: 'taskClose', taskId: 'ws-001', reason: 'Done' }, makeCtx(), makeTaskCtx());
239
+ expect(result.ok).toBe(true);
240
+ expect(result.summary).toContain('ws-001');
241
+ expect(result.summary).toContain('Done');
242
+ });
243
+ it('taskClose calls forumCountSync.requestUpdate', async () => {
244
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
245
+ await executeTaskAction({ type: 'taskClose', taskId: 'ws-001' }, makeCtx(), makeTaskCtx({ forumCountSync: mockSync }));
246
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
247
+ });
248
+ it('taskShow returns task details', async () => {
249
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-001' }, makeCtx(), makeTaskCtx());
250
+ expect(result.ok).toBe(true);
251
+ expect(result.summary).toContain('Test task');
252
+ expect(result.summary).toContain('ws-001');
253
+ });
254
+ it('taskShow fails for unknown task', async () => {
255
+ const result = await executeTaskAction({ type: 'taskShow', taskId: 'ws-notfound' }, makeCtx(), makeTaskCtx());
256
+ expect(result.ok).toBe(false);
257
+ expect(result.error).toContain('not found');
258
+ });
259
+ it('taskList returns task list', async () => {
260
+ const result = await executeTaskAction({ type: 'taskList', status: 'open', limit: 10 }, makeCtx(), makeTaskCtx());
261
+ expect(result.ok).toBe(true);
262
+ expect(result.summary).toContain('ws-001');
263
+ expect(result.summary).toContain('ws-002');
264
+ });
265
+ it('taskList defaults to limit 50 when no limit provided', async () => {
266
+ const store = makeStore();
267
+ await executeTaskAction({ type: 'taskList', status: 'all' }, makeCtx(), makeTaskCtx({ store: store }));
268
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 50 }));
269
+ });
270
+ it('taskList respects explicit limit', async () => {
271
+ const store = makeStore();
272
+ await executeTaskAction({ type: 'taskList', status: 'all', limit: 5 }, makeCtx(), makeTaskCtx({ store: store }));
273
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 5 }));
274
+ });
275
+ it('taskSync returns extended sync summary', async () => {
276
+ const result = await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx());
277
+ expect(result.ok).toBe(true);
278
+ expect(result.summary).toContain('status-fixes');
279
+ expect(result.summary).toContain('5 starters');
280
+ });
281
+ it('taskSync passes statusPoster through to runTaskSync', async () => {
282
+ const { runTaskSync } = await import('./task-sync-engine.js');
283
+ runTaskSync.mockClear();
284
+ const mockPoster = { taskSyncComplete: vi.fn() };
285
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ statusPoster: mockPoster }));
286
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster: mockPoster, mentionUserId: undefined }));
287
+ });
288
+ it('taskSync passes sidebarMentionUserId as mentionUserId to runTaskSync', async () => {
289
+ const { runTaskSync } = await import('./task-sync-engine.js');
290
+ runTaskSync.mockClear();
291
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ sidebarMentionUserId: '999' }));
292
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ mentionUserId: '999' }));
293
+ });
294
+ it('taskSync lazily creates and reuses syncCoordinator when missing', async () => {
295
+ const { runTaskSync } = await import('./task-sync-engine.js');
296
+ runTaskSync.mockClear();
297
+ const taskCtx = makeTaskCtx();
298
+ expect(taskCtx.syncCoordinator).toBeUndefined();
299
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), taskCtx);
300
+ const firstCoordinator = taskCtx.syncCoordinator;
301
+ expect(firstCoordinator).toBeDefined();
302
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), taskCtx);
303
+ expect(taskCtx.syncCoordinator).toBe(firstCoordinator);
304
+ expect(runTaskSync).toHaveBeenCalledTimes(2);
305
+ });
306
+ it('taskUpdate schedules repair sync after thread lifecycle failure without prewired coordinator', async () => {
307
+ const { runTaskSync } = await import('./task-sync-engine.js');
308
+ const { updateTaskThreadName } = await import('./thread-ops.js');
309
+ runTaskSync.mockClear();
310
+ updateTaskThreadName.mockRejectedValueOnce(new Error('rename failed'));
311
+ const result = await executeTaskAction({ type: 'taskUpdate', taskId: 'ws-001', status: 'in_progress' }, makeCtx(), makeTaskCtx());
312
+ expect(result.ok).toBe(true);
313
+ await new Promise((resolve) => setTimeout(resolve, 0));
314
+ expect(runTaskSync).toHaveBeenCalled();
315
+ });
316
+ });
317
+ describe('tagMapReload action', () => {
318
+ it('success: returns old/new count with tag names', async () => {
319
+ const { reloadTagMapInPlace } = await import('./tag-map.js');
320
+ reloadTagMapInPlace.mockClear();
321
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
322
+ // Simulate reload: clear and add new tags
323
+ for (const k of Object.keys(tagMap))
324
+ delete tagMap[k];
325
+ Object.assign(tagMap, { bug: '111', feature: '222', docs: '333' });
326
+ return 3;
327
+ });
328
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
329
+ expect(result.ok).toBe(true);
330
+ expect(result.summary).toContain('Tag map reloaded');
331
+ expect(result.summary).toContain('bug');
332
+ expect(result.summary).toContain('feature');
333
+ expect(result.summary).toContain('docs');
334
+ });
335
+ it('success with >10 tags: truncates tag list display', async () => {
336
+ const { reloadTagMapInPlace } = await import('./tag-map.js');
337
+ reloadTagMapInPlace.mockClear();
338
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
339
+ for (const k of Object.keys(tagMap))
340
+ delete tagMap[k];
341
+ for (let i = 0; i < 15; i++)
342
+ tagMap[`tag${i}`] = `id${i}`;
343
+ return 15;
344
+ });
345
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
346
+ expect(result.ok).toBe(true);
347
+ expect(result.summary).toContain('(+5 more)');
348
+ });
349
+ it('failure: returns error with message, map preserved', async () => {
350
+ const { reloadTagMapInPlace } = await import('./tag-map.js');
351
+ reloadTagMapInPlace.mockClear();
352
+ reloadTagMapInPlace.mockRejectedValueOnce(new Error('ENOENT: file not found'));
353
+ const tagMap = { existing: '999' };
354
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json', tagMap }));
355
+ expect(result.ok).toBe(false);
356
+ expect(result.error).toContain('Tag map reload failed');
357
+ expect(result.error).toContain('ENOENT');
358
+ });
359
+ it('without tagMapPath: returns error', async () => {
360
+ const result = await executeTaskAction({ type: 'tagMapReload' }, makeCtx(), makeTaskCtx());
361
+ expect(result.ok).toBe(false);
362
+ expect(result.error).toContain('Tag map path not configured');
363
+ });
364
+ });
365
+ describe('taskSync coordinator tagMap reload behavior', () => {
366
+ it('reloads tag map before runTaskSync when tagMapPath is configured', async () => {
367
+ const { reloadTagMapInPlace } = await import('./tag-map.js');
368
+ const { runTaskSync } = await import('./task-sync-engine.js');
369
+ reloadTagMapInPlace.mockClear();
370
+ runTaskSync.mockClear();
371
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx({ tagMapPath: '/tmp/tag-map.json' }));
372
+ expect(reloadTagMapInPlace).toHaveBeenCalledWith('/tmp/tag-map.json', expect.any(Object));
373
+ expect(runTaskSync).toHaveBeenCalled();
374
+ });
375
+ it('does not attempt reload without tagMapPath', async () => {
376
+ const { reloadTagMapInPlace } = await import('./tag-map.js');
377
+ reloadTagMapInPlace.mockClear();
378
+ await executeTaskAction({ type: 'taskSync' }, makeCtx(), makeTaskCtx());
379
+ expect(reloadTagMapInPlace).not.toHaveBeenCalled();
380
+ });
381
+ });
382
+ describe('taskActionsPromptSection', () => {
383
+ it('returns non-empty prompt section', () => {
384
+ const section = taskActionsPromptSection();
385
+ expect(section).toContain('taskCreate');
386
+ expect(section).toContain('taskClose');
387
+ expect(section).toContain('taskList');
388
+ });
389
+ it('includes tagMapReload in prompt section', () => {
390
+ const section = taskActionsPromptSection();
391
+ expect(section).toContain('tagMapReload');
392
+ });
393
+ it('includes task quality guidelines', () => {
394
+ const section = taskActionsPromptSection();
395
+ expect(section).toContain('imperative mood');
396
+ expect(section).toContain('Description');
397
+ expect(section).toContain('P0');
398
+ expect(section).toContain('P1');
399
+ expect(section).toContain('taskUpdate');
400
+ });
401
+ it('keeps guidelines block under 600 chars', () => {
402
+ const section = taskActionsPromptSection();
403
+ const marker = '#### Task Quality Guidelines';
404
+ const crossRefMarker = '#### Cross-Task References';
405
+ const idx = section.indexOf(marker);
406
+ expect(idx).toBeGreaterThanOrEqual(0);
407
+ const crossRefIdx = section.indexOf(crossRefMarker);
408
+ // Slice up to the cross-task section (or end of string if not found)
409
+ const end = crossRefIdx > idx ? crossRefIdx : section.length;
410
+ const guidelinesBlock = section.slice(idx, end);
411
+ expect(guidelinesBlock.length).toBeLessThanOrEqual(700);
412
+ });
413
+ it('includes cross-task references guideline', () => {
414
+ const section = taskActionsPromptSection();
415
+ expect(section).toContain('#### Cross-Task References');
416
+ expect(section).toContain('taskShow');
417
+ expect(section).toContain('taskUpdate');
418
+ expect(section).toContain('taskSync');
419
+ });
420
+ });