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,685 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { buildThreadName, buildTaskStarterContent, getThreadIdFromTask, updateTaskStarterMessage, closeTaskThread, isTaskThreadAlreadyClosed, isThreadArchived, getStatusTagIds, buildAppliedTagsWithStatus, updateTaskThreadTags, createTaskThread, shortTaskId, taskIdToken, extractShortIdFromThreadName, } from './discord-sync.js';
3
+ // ---------------------------------------------------------------------------
4
+ // buildThreadName
5
+ // ---------------------------------------------------------------------------
6
+ describe('buildThreadName', () => {
7
+ it('builds name with emoji prefix and ID', () => {
8
+ const name = buildThreadName('ws-001', 'Fix login bug', 'open');
9
+ expect(name).toBe('\u{1F7E2} [001] Fix login bug');
10
+ });
11
+ it('uses yellow emoji for in_progress', () => {
12
+ const name = buildThreadName('ws-002', 'Add feature', 'in_progress');
13
+ expect(name).toContain('\u{1F7E1}');
14
+ });
15
+ it('uses checkmark for closed', () => {
16
+ const name = buildThreadName('ws-003', 'Done task', 'closed');
17
+ expect(name).toContain('\u2611\uFE0F');
18
+ });
19
+ it('uses prohibition for blocked', () => {
20
+ const name = buildThreadName('ws-004', 'Blocked task', 'blocked');
21
+ expect(name).toContain('\u26A0\uFE0F');
22
+ });
23
+ it('truncates long titles to 100 chars total', () => {
24
+ const longTitle = 'A'.repeat(200);
25
+ const name = buildThreadName('ws-001', longTitle, 'open');
26
+ expect(name.length).toBeLessThanOrEqual(100);
27
+ expect(name).toContain('\u2026'); // ellipsis
28
+ });
29
+ it('defaults to open emoji for unknown status', () => {
30
+ const name = buildThreadName('ws-001', 'Test', 'unknown_status');
31
+ expect(name).toContain('\u{1F7E2}');
32
+ });
33
+ });
34
+ // ---------------------------------------------------------------------------
35
+ // buildTaskStarterContent
36
+ // ---------------------------------------------------------------------------
37
+ describe('buildTaskStarterContent', () => {
38
+ const makeTask = (overrides) => ({
39
+ id: 'ws-001',
40
+ title: 'Test',
41
+ description: 'A test task',
42
+ status: 'open',
43
+ priority: 2,
44
+ issue_type: 'task',
45
+ owner: '',
46
+ external_ref: '',
47
+ labels: [],
48
+ comments: [],
49
+ created_at: '',
50
+ updated_at: '',
51
+ close_reason: '',
52
+ ...overrides,
53
+ });
54
+ it('produces correct format with description, ID, priority, status', () => {
55
+ const content = buildTaskStarterContent(makeTask());
56
+ expect(content).toContain('A test task');
57
+ expect(content).toContain('**ID:** `ws-001`');
58
+ expect(content).toContain('**Priority:** P2');
59
+ expect(content).toContain('**Status:** open');
60
+ });
61
+ it('includes owner when present', () => {
62
+ const content = buildTaskStarterContent(makeTask({ owner: 'alice' }));
63
+ expect(content).toContain('**Owner:** alice');
64
+ });
65
+ it('omits owner when empty', () => {
66
+ const content = buildTaskStarterContent(makeTask({ owner: '' }));
67
+ expect(content).not.toContain('**Owner:**');
68
+ });
69
+ it('does not include mention lines when mentionUserId omitted', () => {
70
+ const content = buildTaskStarterContent(makeTask());
71
+ expect(content).not.toContain('<@');
72
+ });
73
+ it('appends mention when mentionUserId provided', () => {
74
+ const content = buildTaskStarterContent(makeTask(), '999888777');
75
+ expect(content).toContain('<@999888777>');
76
+ });
77
+ it('defaults priority to P2 when undefined', () => {
78
+ const content = buildTaskStarterContent(makeTask({ priority: undefined }));
79
+ expect(content).toContain('**Priority:** P2');
80
+ });
81
+ });
82
+ // ---------------------------------------------------------------------------
83
+ // getThreadIdFromTask
84
+ // ---------------------------------------------------------------------------
85
+ describe('getThreadIdFromTask', () => {
86
+ const makeTask = (externalRef) => ({
87
+ id: 'ws-001',
88
+ title: 'Test',
89
+ description: '',
90
+ status: 'open',
91
+ priority: 2,
92
+ issue_type: 'task',
93
+ owner: '',
94
+ external_ref: externalRef,
95
+ labels: [],
96
+ comments: [],
97
+ created_at: '',
98
+ updated_at: '',
99
+ close_reason: '',
100
+ });
101
+ it('extracts thread ID from discord: prefix', () => {
102
+ expect(getThreadIdFromTask(makeTask('discord:123456789'))).toBe('123456789');
103
+ });
104
+ it('extracts raw numeric ID', () => {
105
+ expect(getThreadIdFromTask(makeTask('123456789'))).toBe('123456789');
106
+ });
107
+ it('returns null for empty external_ref', () => {
108
+ expect(getThreadIdFromTask(makeTask(''))).toBeNull();
109
+ });
110
+ it('returns null for non-discord external_ref', () => {
111
+ expect(getThreadIdFromTask(makeTask('gh-123'))).toBeNull();
112
+ });
113
+ it('handles whitespace', () => {
114
+ expect(getThreadIdFromTask(makeTask(' discord:123 '))).toBe('123');
115
+ });
116
+ });
117
+ // ---------------------------------------------------------------------------
118
+ // updateTaskStarterMessage
119
+ // ---------------------------------------------------------------------------
120
+ describe('updateTaskStarterMessage', () => {
121
+ const task = {
122
+ id: 'ws-001',
123
+ title: 'Test',
124
+ description: 'A test task',
125
+ status: 'open',
126
+ priority: 2,
127
+ issue_type: 'task',
128
+ owner: '',
129
+ external_ref: '',
130
+ labels: [],
131
+ comments: [],
132
+ created_at: '',
133
+ updated_at: '',
134
+ close_reason: '',
135
+ };
136
+ function makeClient(thread) {
137
+ return {
138
+ channels: { cache: { get: () => thread } },
139
+ user: { id: 'bot-123' },
140
+ };
141
+ }
142
+ function makeThread(starterOverrides) {
143
+ const editFn = vi.fn();
144
+ return {
145
+ isThread: () => true,
146
+ fetchStarterMessage: vi.fn(async () => ({
147
+ author: { id: 'bot-123' },
148
+ content: 'old content',
149
+ edit: editFn,
150
+ ...starterOverrides,
151
+ })),
152
+ _editFn: editFn,
153
+ };
154
+ }
155
+ it('returns false when thread is not found', async () => {
156
+ const client = { channels: { cache: { get: () => undefined } }, user: { id: 'bot-123' } };
157
+ expect(await updateTaskStarterMessage(client, 'missing', task)).toBe(false);
158
+ });
159
+ it('returns false when fetchStarterMessage throws', async () => {
160
+ const thread = {
161
+ isThread: () => true,
162
+ fetchStarterMessage: vi.fn(async () => { throw new Error('not found'); }),
163
+ };
164
+ expect(await updateTaskStarterMessage(makeClient(thread), '123', task)).toBe(false);
165
+ });
166
+ it('returns false when starter is not bot-authored', async () => {
167
+ const thread = makeThread({ author: { id: 'user-456' } });
168
+ expect(await updateTaskStarterMessage(makeClient(thread), '123', task)).toBe(false);
169
+ expect(thread._editFn).not.toHaveBeenCalled();
170
+ });
171
+ it('returns false when content is already identical (idempotent)', async () => {
172
+ const currentContent = buildTaskStarterContent(task);
173
+ const thread = makeThread({ content: currentContent });
174
+ expect(await updateTaskStarterMessage(makeClient(thread), '123', task)).toBe(false);
175
+ expect(thread._editFn).not.toHaveBeenCalled();
176
+ });
177
+ it('edits starter and returns true when content differs', async () => {
178
+ const thread = makeThread({ content: 'stale content' });
179
+ const result = await updateTaskStarterMessage(makeClient(thread), '123', task);
180
+ expect(result).toBe(true);
181
+ expect(thread._editFn).toHaveBeenCalledWith({
182
+ content: buildTaskStarterContent(task),
183
+ allowedMentions: { parse: [], users: [] },
184
+ });
185
+ });
186
+ it('passes mentionUserId to content builder and sets allowedMentions.users', async () => {
187
+ const thread = makeThread({ content: 'stale content' });
188
+ const result = await updateTaskStarterMessage(makeClient(thread), '123', task, '999');
189
+ expect(result).toBe(true);
190
+ expect(thread._editFn).toHaveBeenCalledWith({
191
+ content: buildTaskStarterContent(task, '999'),
192
+ allowedMentions: { parse: [], users: ['999'] },
193
+ });
194
+ });
195
+ it('skips edit when mention content already matches', async () => {
196
+ const contentWithMention = buildTaskStarterContent(task, '999');
197
+ const thread = makeThread({ content: contentWithMention });
198
+ const result = await updateTaskStarterMessage(makeClient(thread), '123', task, '999');
199
+ expect(result).toBe(false);
200
+ expect(thread._editFn).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+ // ---------------------------------------------------------------------------
204
+ // closeTaskThread
205
+ // ---------------------------------------------------------------------------
206
+ describe('closeTaskThread', () => {
207
+ const task = {
208
+ id: 'ws-001',
209
+ title: 'Test',
210
+ description: 'A test task',
211
+ status: 'closed',
212
+ priority: 2,
213
+ issue_type: 'task',
214
+ owner: '',
215
+ external_ref: '',
216
+ labels: [],
217
+ comments: [],
218
+ created_at: '',
219
+ updated_at: '',
220
+ close_reason: 'Done',
221
+ };
222
+ function makeClient(thread) {
223
+ return {
224
+ channels: { cache: { get: () => thread } },
225
+ user: { id: 'bot-123' },
226
+ };
227
+ }
228
+ function makeCloseThread(opts) {
229
+ const editFn = vi.fn();
230
+ const sendFn = vi.fn();
231
+ const setNameFn = vi.fn();
232
+ const setArchivedFn = vi.fn();
233
+ const fetchStarterFn = vi.fn(async () => ({
234
+ author: { id: opts?.starterAuthorId ?? 'bot-123' },
235
+ content: opts?.starterContent ?? 'old content',
236
+ edit: editFn,
237
+ }));
238
+ return {
239
+ isThread: () => true,
240
+ archived: opts?.archived ?? false,
241
+ fetchStarterMessage: fetchStarterFn,
242
+ send: sendFn,
243
+ setName: setNameFn,
244
+ setArchived: setArchivedFn,
245
+ _editFn: editFn,
246
+ _sendFn: sendFn,
247
+ _setNameFn: setNameFn,
248
+ _setArchivedFn: setArchivedFn,
249
+ _fetchStarterFn: fetchStarterFn,
250
+ };
251
+ }
252
+ it('strips mention from starter message before archiving', async () => {
253
+ const contentWithMention = buildTaskStarterContent(task, '999');
254
+ const thread = makeCloseThread({ starterContent: contentWithMention });
255
+ const client = makeClient(thread);
256
+ await closeTaskThread(client, 'thread-1', task);
257
+ const cleanContent = buildTaskStarterContent(task);
258
+ expect(thread._editFn).toHaveBeenCalledWith({
259
+ content: cleanContent.slice(0, 2000),
260
+ allowedMentions: { parse: [], users: [] },
261
+ });
262
+ });
263
+ it('skips starter edit when content has no mention', async () => {
264
+ const cleanContent = buildTaskStarterContent(task);
265
+ const thread = makeCloseThread({ starterContent: cleanContent });
266
+ const client = makeClient(thread);
267
+ await closeTaskThread(client, 'thread-1', task);
268
+ expect(thread._editFn).not.toHaveBeenCalled();
269
+ });
270
+ it('proceeds with close even if fetchStarterMessage throws', async () => {
271
+ const thread = makeCloseThread();
272
+ thread.fetchStarterMessage = vi.fn(async () => { throw new Error('not found'); });
273
+ const client = makeClient(thread);
274
+ await closeTaskThread(client, 'thread-1', task);
275
+ expect(thread._sendFn).toHaveBeenCalled();
276
+ expect(thread._setNameFn).toHaveBeenCalled();
277
+ expect(thread._setArchivedFn).toHaveBeenCalledWith(true);
278
+ });
279
+ it('does nothing when thread is not found', async () => {
280
+ const client = {
281
+ channels: { cache: { get: () => undefined } },
282
+ user: { id: 'bot-123' },
283
+ };
284
+ await closeTaskThread(client, 'missing', task);
285
+ // No error thrown — function completes silently.
286
+ });
287
+ });
288
+ // ---------------------------------------------------------------------------
289
+ // getStatusTagIds
290
+ // ---------------------------------------------------------------------------
291
+ describe('getStatusTagIds', () => {
292
+ it('returns correct IDs for all statuses present', () => {
293
+ const tagMap = { open: '1', in_progress: '2', blocked: '3', closed: '4', feature: '5' };
294
+ const ids = getStatusTagIds(tagMap);
295
+ expect(ids).toEqual(new Set(['1', '2', '3', '4']));
296
+ });
297
+ it('handles partial entries', () => {
298
+ const tagMap = { blocked: '3', feature: '5' };
299
+ const ids = getStatusTagIds(tagMap);
300
+ expect(ids).toEqual(new Set(['3']));
301
+ });
302
+ it('returns empty set when no status tags configured', () => {
303
+ const tagMap = { feature: '5', bug: '6' };
304
+ const ids = getStatusTagIds(tagMap);
305
+ expect(ids.size).toBe(0);
306
+ });
307
+ it('ignores non-status keys', () => {
308
+ const tagMap = { feature: '5', open: '1' };
309
+ const ids = getStatusTagIds(tagMap);
310
+ expect(ids).toEqual(new Set(['1']));
311
+ expect(ids.has('5')).toBe(false);
312
+ });
313
+ });
314
+ // ---------------------------------------------------------------------------
315
+ // buildAppliedTagsWithStatus
316
+ // ---------------------------------------------------------------------------
317
+ describe('buildAppliedTagsWithStatus', () => {
318
+ it('swaps status tag preserving content tags', () => {
319
+ const tagMap = { open: 's1', closed: 's2', feature: 'c1' };
320
+ const result = buildAppliedTagsWithStatus(['c1', 's1'], 'closed', tagMap);
321
+ expect(result).toContain('s2');
322
+ expect(result).toContain('c1');
323
+ expect(result).not.toContain('s1');
324
+ });
325
+ it('handles missing status in tagMap — content tags unchanged', () => {
326
+ const tagMap = { feature: 'c1', bug: 'c2' };
327
+ const result = buildAppliedTagsWithStatus(['c1', 'c2'], 'open', tagMap);
328
+ expect(result).toEqual(['c1', 'c2']);
329
+ });
330
+ it('status tag gets priority: 5 content tags → 4 content + 1 status', () => {
331
+ const tagMap = { open: 's1', a: 'c1', b: 'c2', c: 'c3', d: 'c4', e: 'c5' };
332
+ const result = buildAppliedTagsWithStatus(['c1', 'c2', 'c3', 'c4', 'c5'], 'open', tagMap);
333
+ expect(result.length).toBe(5);
334
+ expect(result).toContain('s1');
335
+ expect(result.filter(id => id !== 's1').length).toBe(4);
336
+ });
337
+ it('no-op when correct status tag already present', () => {
338
+ const tagMap = { open: 's1', feature: 'c1' };
339
+ const result = buildAppliedTagsWithStatus(['c1', 's1'], 'open', tagMap);
340
+ expect(result).toContain('s1');
341
+ expect(result).toContain('c1');
342
+ expect(result.length).toBe(2);
343
+ });
344
+ it('strips old status tag even if new status not in tagMap', () => {
345
+ const tagMap = { open: 's1', feature: 'c1' };
346
+ // in_progress not in tagMap
347
+ const result = buildAppliedTagsWithStatus(['c1', 's1'], 'in_progress', tagMap);
348
+ expect(result).not.toContain('s1');
349
+ expect(result).toEqual(['c1']);
350
+ });
351
+ it('dedupes content tags', () => {
352
+ const tagMap = { open: 's1', feature: 'c1' };
353
+ const result = buildAppliedTagsWithStatus(['c1', 'c1', 'c1'], 'open', tagMap);
354
+ expect(result.filter(id => id === 'c1').length).toBe(1);
355
+ });
356
+ });
357
+ // ---------------------------------------------------------------------------
358
+ // createTaskThread — status tag application
359
+ // ---------------------------------------------------------------------------
360
+ describe('createTaskThread', () => {
361
+ const makeTask = (overrides) => ({
362
+ id: 'ws-001',
363
+ title: 'Test task',
364
+ description: 'A test',
365
+ status: 'open',
366
+ priority: 2,
367
+ issue_type: 'task',
368
+ owner: '',
369
+ external_ref: '',
370
+ labels: ['feature'],
371
+ comments: [],
372
+ created_at: '',
373
+ updated_at: '',
374
+ close_reason: '',
375
+ ...overrides,
376
+ });
377
+ function makeForum(createFn) {
378
+ return { threads: { create: createFn } };
379
+ }
380
+ it('includes status tag in appliedTags when tagMap has status entry', async () => {
381
+ const tagMap = { open: 's1', feature: 'c1' };
382
+ const task = makeTask({ status: 'open', labels: ['feature'] });
383
+ const createFn = vi.fn(async (_opts) => ({ id: 'new-thread' }));
384
+ await createTaskThread(makeForum(createFn), task, tagMap);
385
+ const args = createFn.mock.calls[0][0];
386
+ expect(args.appliedTags).toContain('s1');
387
+ expect(args.appliedTags).toContain('c1');
388
+ expect(args.appliedTags.length).toBeLessThanOrEqual(5);
389
+ });
390
+ it('omits status tag when tagMap has no status entry for task status', async () => {
391
+ const tagMap = { feature: 'c1' };
392
+ const task = makeTask({ status: 'open', labels: ['feature'] });
393
+ const createFn = vi.fn(async (_opts) => ({ id: 'new-thread' }));
394
+ await createTaskThread(makeForum(createFn), task, tagMap);
395
+ const args = createFn.mock.calls[0][0];
396
+ expect(args.appliedTags).toContain('c1');
397
+ expect(args.appliedTags).not.toContain(undefined);
398
+ expect(args.appliedTags.length).toBeLessThanOrEqual(5);
399
+ });
400
+ it('caps total appliedTags at 5 even with many labels', async () => {
401
+ const tagMap = { open: 's1', a: 'c1', b: 'c2', c: 'c3', d: 'c4', e: 'c5' };
402
+ const task = makeTask({ status: 'open', labels: ['a', 'b', 'c', 'd', 'e'] });
403
+ const createFn = vi.fn(async (_opts) => ({ id: 'new-thread' }));
404
+ await createTaskThread(makeForum(createFn), task, tagMap);
405
+ const args = createFn.mock.calls[0][0];
406
+ expect(args.appliedTags.length).toBe(5);
407
+ expect(args.appliedTags).toContain('s1');
408
+ });
409
+ it('passes mentionUserId to starter content and allowedMentions', async () => {
410
+ const tagMap = { open: 's1' };
411
+ const task = makeTask({ status: 'open', labels: [] });
412
+ const createFn = vi.fn(async (_opts) => ({ id: 'new-thread' }));
413
+ await createTaskThread(makeForum(createFn), task, tagMap, '999');
414
+ const args = createFn.mock.calls[0][0];
415
+ expect(args.message.content).toContain('<@999>');
416
+ expect(args.message.allowedMentions.users).toEqual(['999']);
417
+ });
418
+ });
419
+ // ---------------------------------------------------------------------------
420
+ // updateTaskThreadTags
421
+ // ---------------------------------------------------------------------------
422
+ describe('updateTaskThreadTags', () => {
423
+ const task = {
424
+ id: 'ws-001', title: 'Test', status: 'in_progress',
425
+ priority: 2, external_ref: '', labels: [],
426
+ };
427
+ function makeClient(thread) {
428
+ return {
429
+ channels: { cache: { get: () => thread } },
430
+ user: { id: 'bot-123' },
431
+ };
432
+ }
433
+ it('returns false when thread not found', async () => {
434
+ const client = { channels: { cache: { get: () => undefined } } };
435
+ const result = await updateTaskThreadTags(client, 'missing', task, { in_progress: 's2' });
436
+ expect(result).toBe(false);
437
+ });
438
+ it('returns false when tags already match (order-insensitive)', async () => {
439
+ const tagMap = { in_progress: 's2', feature: 'c1' };
440
+ const thread = {
441
+ isThread: () => true,
442
+ appliedTags: ['s2', 'c1'],
443
+ edit: vi.fn(),
444
+ };
445
+ const result = await updateTaskThreadTags(makeClient(thread), '123', task, tagMap);
446
+ expect(result).toBe(false);
447
+ expect(thread.edit).not.toHaveBeenCalled();
448
+ });
449
+ it('calls thread.edit when status tag differs', async () => {
450
+ const tagMap = { open: 's1', in_progress: 's2', feature: 'c1' };
451
+ const thread = {
452
+ isThread: () => true,
453
+ appliedTags: ['c1', 's1'],
454
+ edit: vi.fn(),
455
+ };
456
+ const result = await updateTaskThreadTags(makeClient(thread), '123', task, tagMap);
457
+ expect(result).toBe(true);
458
+ expect(thread.edit).toHaveBeenCalledWith({
459
+ appliedTags: expect.arrayContaining(['s2', 'c1']),
460
+ });
461
+ expect(thread.edit.mock.calls[0][0].appliedTags).not.toContain('s1');
462
+ });
463
+ });
464
+ // ---------------------------------------------------------------------------
465
+ // closeTaskThread with tagMap
466
+ // ---------------------------------------------------------------------------
467
+ describe('closeTaskThread with tagMap', () => {
468
+ const task = {
469
+ id: 'ws-001', title: 'Test', status: 'closed',
470
+ priority: 2, external_ref: '', labels: [], close_reason: 'Done',
471
+ };
472
+ function makeClient(thread) {
473
+ return {
474
+ channels: { cache: { get: () => thread } },
475
+ user: { id: 'bot-123' },
476
+ };
477
+ }
478
+ function makeCloseThread(opts) {
479
+ return {
480
+ isThread: () => true,
481
+ archived: false,
482
+ appliedTags: opts?.appliedTags ?? [],
483
+ fetchStarterMessage: vi.fn(async () => ({
484
+ author: { id: 'bot-123' },
485
+ content: 'old content',
486
+ edit: vi.fn(),
487
+ })),
488
+ send: vi.fn(),
489
+ setName: vi.fn(),
490
+ setArchived: vi.fn(),
491
+ edit: vi.fn(),
492
+ };
493
+ }
494
+ it('applies closed status tag before archiving when tagMap provided', async () => {
495
+ const tagMap = { closed: 'sc', feature: 'c1' };
496
+ const thread = makeCloseThread({ appliedTags: ['c1'] });
497
+ await closeTaskThread(makeClient(thread), 'thread-1', task, tagMap);
498
+ expect(thread.edit).toHaveBeenCalledWith({
499
+ appliedTags: expect.arrayContaining(['sc', 'c1']),
500
+ });
501
+ expect(thread.setArchived).toHaveBeenCalledWith(true);
502
+ });
503
+ it('skips tag edit when tags already match', async () => {
504
+ const tagMap = { closed: 'sc', feature: 'c1' };
505
+ const thread = makeCloseThread({ appliedTags: ['c1', 'sc'] });
506
+ await closeTaskThread(makeClient(thread), 'thread-1', task, tagMap);
507
+ expect(thread.edit).not.toHaveBeenCalled();
508
+ });
509
+ it('skips tag update when tagMap is undefined (backward compat)', async () => {
510
+ const thread = makeCloseThread({ appliedTags: ['c1'] });
511
+ await closeTaskThread(makeClient(thread), 'thread-1', task);
512
+ expect(thread.edit).not.toHaveBeenCalled();
513
+ });
514
+ });
515
+ // ---------------------------------------------------------------------------
516
+ // isTaskThreadAlreadyClosed with tagMap
517
+ // ---------------------------------------------------------------------------
518
+ describe('isTaskThreadAlreadyClosed with tagMap', () => {
519
+ const task = {
520
+ id: 'ws-001', title: 'Test', status: 'closed',
521
+ priority: 2, external_ref: '', labels: [],
522
+ };
523
+ function makeClient(thread) {
524
+ return {
525
+ channels: { cache: { get: () => thread } },
526
+ };
527
+ }
528
+ const closedName = '\u2611\uFE0F [001] Test';
529
+ it('returns false when archived+named but has wrong/missing status tag', async () => {
530
+ const tagMap = { closed: 'sc', open: 'so' };
531
+ const thread = {
532
+ isThread: () => true,
533
+ archived: true,
534
+ name: closedName,
535
+ appliedTags: ['c1'],
536
+ };
537
+ const result = await isTaskThreadAlreadyClosed(makeClient(thread), '123', task, tagMap);
538
+ expect(result).toBe(false);
539
+ });
540
+ it('returns false when thread has stale status tag', async () => {
541
+ const tagMap = { closed: 'sc', open: 'so' };
542
+ const thread = {
543
+ isThread: () => true,
544
+ archived: true,
545
+ name: closedName,
546
+ appliedTags: ['so', 'c1'], // has open tag instead of closed
547
+ };
548
+ const result = await isTaskThreadAlreadyClosed(makeClient(thread), '123', task, tagMap);
549
+ expect(result).toBe(false);
550
+ });
551
+ it('returns true when thread has correct closed tag', async () => {
552
+ const tagMap = { closed: 'sc', open: 'so' };
553
+ const thread = {
554
+ isThread: () => true,
555
+ archived: true,
556
+ name: closedName,
557
+ appliedTags: ['c1', 'sc'],
558
+ };
559
+ const result = await isTaskThreadAlreadyClosed(makeClient(thread), '123', task, tagMap);
560
+ expect(result).toBe(true);
561
+ });
562
+ it('returns true (backward compat) when tagMap omitted', async () => {
563
+ const thread = {
564
+ isThread: () => true,
565
+ archived: true,
566
+ name: closedName,
567
+ appliedTags: [],
568
+ };
569
+ const result = await isTaskThreadAlreadyClosed(makeClient(thread), '123', task);
570
+ expect(result).toBe(true);
571
+ });
572
+ it('returns true when tagMap has no status entries', async () => {
573
+ const tagMap = { feature: 'c1' };
574
+ const thread = {
575
+ isThread: () => true,
576
+ archived: true,
577
+ name: closedName,
578
+ appliedTags: [],
579
+ };
580
+ const result = await isTaskThreadAlreadyClosed(makeClient(thread), '123', task, tagMap);
581
+ expect(result).toBe(true);
582
+ });
583
+ });
584
+ // ---------------------------------------------------------------------------
585
+ // isThreadArchived
586
+ // ---------------------------------------------------------------------------
587
+ describe('isThreadArchived', () => {
588
+ function makeClient(thread) {
589
+ return {
590
+ channels: { cache: { get: () => thread } },
591
+ };
592
+ }
593
+ it('returns true when thread is archived', async () => {
594
+ const thread = { isThread: () => true, archived: true };
595
+ const result = await isThreadArchived(makeClient(thread), '123');
596
+ expect(result).toBe(true);
597
+ });
598
+ it('returns true when thread does not exist (missing)', async () => {
599
+ const client = { channels: { cache: { get: () => undefined } } };
600
+ const result = await isThreadArchived(client, 'missing');
601
+ expect(result).toBe(true);
602
+ });
603
+ it('returns false when thread is not archived', async () => {
604
+ const thread = { isThread: () => true, archived: false };
605
+ const result = await isThreadArchived(makeClient(thread), '123');
606
+ expect(result).toBe(false);
607
+ });
608
+ it('returns true for archived thread regardless of name/tag metadata', async () => {
609
+ // isThreadArchived only checks archived state, not name or tags.
610
+ // Phase 4 of task-sync uses isTaskThreadAlreadyClosed instead, which
611
+ // checks all three (archived + name + tags) for proper recovery.
612
+ const thread = {
613
+ isThread: () => true,
614
+ archived: true,
615
+ name: 'wrong name',
616
+ appliedTags: ['wrong-tag'],
617
+ };
618
+ const result = await isThreadArchived(makeClient(thread), '123');
619
+ expect(result).toBe(true);
620
+ });
621
+ });
622
+ // ---------------------------------------------------------------------------
623
+ // shortTaskId
624
+ // ---------------------------------------------------------------------------
625
+ describe('shortTaskId', () => {
626
+ it('strips project prefix', () => {
627
+ expect(shortTaskId('ws-001')).toBe('001');
628
+ });
629
+ it('returns full string when no dash', () => {
630
+ expect(shortTaskId('001')).toBe('001');
631
+ });
632
+ it('strips only first dash segment', () => {
633
+ expect(shortTaskId('my-proj-042')).toBe('proj-042');
634
+ });
635
+ });
636
+ // ---------------------------------------------------------------------------
637
+ // taskIdToken
638
+ // ---------------------------------------------------------------------------
639
+ describe('taskIdToken', () => {
640
+ it('wraps short ID in brackets', () => {
641
+ expect(taskIdToken('ws-001')).toBe('[001]');
642
+ });
643
+ it('works without prefix', () => {
644
+ expect(taskIdToken('042')).toBe('[042]');
645
+ });
646
+ });
647
+ // ---------------------------------------------------------------------------
648
+ // extractShortIdFromThreadName
649
+ // ---------------------------------------------------------------------------
650
+ describe('extractShortIdFromThreadName', () => {
651
+ it('extracts ID from open emoji thread name', () => {
652
+ expect(extractShortIdFromThreadName('\u{1F7E2} [001] Fix login bug')).toBe('001');
653
+ });
654
+ it('extracts ID from in_progress emoji thread name', () => {
655
+ expect(extractShortIdFromThreadName('\u{1F7E1} [042] Add feature')).toBe('042');
656
+ });
657
+ it('extracts ID from blocked emoji thread name (multi-codepoint ⚠️)', () => {
658
+ expect(extractShortIdFromThreadName('\u26A0\uFE0F [007] Blocked task')).toBe('007');
659
+ });
660
+ it('extracts ID from closed emoji thread name (multi-codepoint ☑️)', () => {
661
+ expect(extractShortIdFromThreadName('\u2611\uFE0F [123] Done task')).toBe('123');
662
+ });
663
+ it('returns null for non-task thread name', () => {
664
+ expect(extractShortIdFromThreadName('Bug [123] some issue')).toBeNull();
665
+ });
666
+ it('returns null for empty string', () => {
667
+ expect(extractShortIdFromThreadName('')).toBeNull();
668
+ });
669
+ it('returns null when bracket is not at start after emoji', () => {
670
+ expect(extractShortIdFromThreadName('General discussion about [001]')).toBeNull();
671
+ });
672
+ it('handles no space between emoji and bracket', () => {
673
+ expect(extractShortIdFromThreadName('\u{1F7E2}[001] Test')).toBe('001');
674
+ });
675
+ it('handles multiple spaces between emoji and bracket', () => {
676
+ expect(extractShortIdFromThreadName('\u{1F7E2} [001] Test')).toBe('001');
677
+ });
678
+ it('returns null for non-numeric bracket content', () => {
679
+ expect(extractShortIdFromThreadName('\u{1F7E2} [abc] Test')).toBeNull();
680
+ });
681
+ it('roundtrips with buildThreadName', () => {
682
+ const name = buildThreadName('ws-085', 'Plan execution', 'in_progress');
683
+ expect(extractShortIdFromThreadName(name)).toBe('085');
684
+ });
685
+ });