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,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isDirectTaskLifecycleActive, withDirectTaskLifecycle, withTaskLifecycleLock, } from './task-lifecycle.js';
3
+ describe('task lifecycle lock', () => {
4
+ it('serializes lifecycle work for the same task id', async () => {
5
+ const events = [];
6
+ let releaseFirst;
7
+ const firstGate = new Promise((resolve) => {
8
+ releaseFirst = resolve;
9
+ });
10
+ const first = withTaskLifecycleLock('ws-001', async () => {
11
+ events.push('first:start');
12
+ await firstGate;
13
+ events.push('first:end');
14
+ });
15
+ await Promise.resolve();
16
+ const second = withTaskLifecycleLock('ws-001', async () => {
17
+ events.push('second:start');
18
+ events.push('second:end');
19
+ });
20
+ await Promise.resolve();
21
+ expect(events).toEqual(['first:start']);
22
+ releaseFirst();
23
+ await Promise.all([first, second]);
24
+ expect(events).toEqual(['first:start', 'first:end', 'second:start', 'second:end']);
25
+ });
26
+ it('tracks direct action ownership only while work is active', async () => {
27
+ expect(isDirectTaskLifecycleActive('ws-002')).toBe(false);
28
+ await withDirectTaskLifecycle('ws-002', async () => {
29
+ expect(isDirectTaskLifecycleActive('ws-002')).toBe(true);
30
+ await Promise.resolve();
31
+ expect(isDirectTaskLifecycleActive('ws-002')).toBe(true);
32
+ });
33
+ expect(isDirectTaskLifecycleActive('ws-002')).toBe(false);
34
+ });
35
+ });
@@ -0,0 +1,95 @@
1
+ import { getThreadIdFromTask } from './thread-helpers.js';
2
+ function hasLabel(task, label) {
3
+ return (task.labels ?? []).includes(label);
4
+ }
5
+ function operationKey(phase, taskId) {
6
+ return `task-sync:${phase}:${taskId}`;
7
+ }
8
+ /**
9
+ * Stage: ingest
10
+ * Copy list shape while preserving task object references.
11
+ */
12
+ export function ingestTaskSyncSnapshot(allTasks) {
13
+ return [...allTasks];
14
+ }
15
+ /**
16
+ * Stage: normalize
17
+ * Split tasks into deterministic phase buckets from the ingested snapshot.
18
+ */
19
+ export function normalizeTaskSyncBuckets(allTasks) {
20
+ const tasksMissingRef = allTasks.filter((task) => !getThreadIdFromTask(task) &&
21
+ task.status !== 'closed' &&
22
+ !hasLabel(task, 'no-thread'));
23
+ const needsBlockedTasks = allTasks.filter((task) => task.status === 'open' && (task.labels ?? []).some((l) => /^(waiting|blocked)-/.test(l)));
24
+ const tasksWithRef = allTasks.filter((task) => getThreadIdFromTask(task) && task.status !== 'closed');
25
+ const closedTasks = allTasks.filter((task) => task.status === 'closed' && Boolean(getThreadIdFromTask(task)));
26
+ return { tasksMissingRef, needsBlockedTasks, tasksWithRef, closedTasks };
27
+ }
28
+ /**
29
+ * Stage: diff
30
+ * Build an idempotent operation plan from normalized buckets.
31
+ */
32
+ export function planTaskSyncOperations(buckets) {
33
+ const operations = [];
34
+ const seen = new Set();
35
+ const pushOps = (phase, tasks) => {
36
+ for (const task of tasks) {
37
+ const key = operationKey(phase, task.id);
38
+ if (seen.has(key))
39
+ continue;
40
+ seen.add(key);
41
+ operations.push({ phase, taskId: task.id, key });
42
+ }
43
+ };
44
+ pushOps('phase1', buckets.tasksMissingRef);
45
+ pushOps('phase2', buckets.needsBlockedTasks);
46
+ pushOps('phase3', buckets.tasksWithRef);
47
+ pushOps('phase4', buckets.closedTasks);
48
+ return operations;
49
+ }
50
+ export function operationTaskIdSet(operations, phase) {
51
+ const ids = new Set();
52
+ for (const operation of operations) {
53
+ if (operation.phase === phase)
54
+ ids.add(operation.taskId);
55
+ }
56
+ return ids;
57
+ }
58
+ /**
59
+ * Stage: apply dispatch
60
+ * Ordered task IDs for a given phase, preserving diff-plan sequence.
61
+ */
62
+ export function operationTaskIdList(operations, phase) {
63
+ const ids = [];
64
+ for (const operation of operations) {
65
+ if (operation.phase === phase)
66
+ ids.push(operation.taskId);
67
+ }
68
+ return ids;
69
+ }
70
+ const DEFAULT_PHASE_ORDER = ['phase1', 'phase2', 'phase3', 'phase4'];
71
+ /**
72
+ * Stage: apply plan
73
+ * Build ordered phase dispatch inputs directly from diff-plan operations.
74
+ */
75
+ export function planTaskApplyPhases(operations, phaseOrder = DEFAULT_PHASE_ORDER) {
76
+ const phases = [];
77
+ for (const phase of phaseOrder) {
78
+ phases.push({
79
+ phase,
80
+ taskIds: operationTaskIdList(operations, phase),
81
+ });
82
+ }
83
+ return phases;
84
+ }
85
+ /**
86
+ * Stage: compose (stages 2-4)
87
+ * Build diff operations, phase dispatch plans, and task lookup from a task snapshot.
88
+ */
89
+ export function planTaskSyncApplyExecution(allTasks) {
90
+ const normalized = normalizeTaskSyncBuckets(allTasks);
91
+ const operations = planTaskSyncOperations(normalized);
92
+ const phasePlans = planTaskApplyPhases(operations);
93
+ const tasksById = new Map(allTasks.map((task) => [task.id, task]));
94
+ return { tasksById, operations, phasePlans };
95
+ }
@@ -0,0 +1,12 @@
1
+ export function createTaskSyncApplyCounters() {
2
+ return {
3
+ threadsCreated: 0,
4
+ emojisUpdated: 0,
5
+ starterMessagesUpdated: 0,
6
+ threadsArchived: 0,
7
+ statusesUpdated: 0,
8
+ tagsUpdated: 0,
9
+ warnings: 0,
10
+ closesDeferred: 0,
11
+ };
12
+ }
@@ -0,0 +1,319 @@
1
+ import { planTaskReconcileFromThreadSources, } from './task-sync-pipeline.js';
2
+ import { withTaskLifecycleLock } from './task-lifecycle.js';
3
+ import { closeTaskThread, createTaskThread, ensureUnarchived, findExistingThreadForTask, isTaskThreadAlreadyClosed, isThreadArchived, updateTaskStarterMessage, updateTaskThreadName, updateTaskThreadTags, } from './thread-ops.js';
4
+ import { extractShortIdFromThreadName, getThreadIdFromTask, shortTaskId, } from './thread-helpers.js';
5
+ function sleep(ms) {
6
+ const n = ms ?? 0;
7
+ if (n <= 0)
8
+ return Promise.resolve();
9
+ return new Promise((r) => setTimeout(r, n));
10
+ }
11
+ export function createTaskSyncApplyCounters() {
12
+ return {
13
+ threadsCreated: 0,
14
+ emojisUpdated: 0,
15
+ starterMessagesUpdated: 0,
16
+ threadsArchived: 0,
17
+ statusesUpdated: 0,
18
+ tagsUpdated: 0,
19
+ warnings: 0,
20
+ closesDeferred: 0,
21
+ };
22
+ }
23
+ async function applyPhase1CreateMissingThreads(ctx, tasksById, plannedTaskIds) {
24
+ for (const taskId of plannedTaskIds) {
25
+ const task = tasksById.get(taskId);
26
+ if (!task)
27
+ continue;
28
+ await withTaskLifecycleLock(task.id, async () => {
29
+ const latestTask = ctx.store.get(task.id) ?? task;
30
+ if (getThreadIdFromTask(latestTask) ||
31
+ latestTask.status === 'closed' ||
32
+ (latestTask.labels ?? []).includes('no-thread')) {
33
+ return;
34
+ }
35
+ try {
36
+ const existing = await findExistingThreadForTask(ctx.forum, latestTask.id, { archivedLimit: ctx.archivedDedupeLimit });
37
+ if (existing) {
38
+ try {
39
+ ctx.taskService.update(latestTask.id, { externalRef: `discord:${existing}` });
40
+ ctx.log?.info({ taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfilled');
41
+ }
42
+ catch (err) {
43
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfill failed');
44
+ ctx.counters.warnings++;
45
+ }
46
+ return;
47
+ }
48
+ const threadId = await createTaskThread(ctx.forum, latestTask, ctx.tagMap, ctx.mentionUserId);
49
+ try {
50
+ ctx.taskService.update(latestTask.id, { externalRef: `discord:${threadId}` });
51
+ }
52
+ catch (err) {
53
+ ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 external-ref update failed');
54
+ ctx.counters.warnings++;
55
+ }
56
+ ctx.counters.threadsCreated++;
57
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase1 thread created');
58
+ }
59
+ catch (err) {
60
+ ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 failed');
61
+ ctx.counters.warnings++;
62
+ }
63
+ });
64
+ await sleep(ctx.throttleMs);
65
+ }
66
+ }
67
+ async function applyPhase2FixBlockedStatus(ctx, tasksById, plannedTaskIds) {
68
+ for (const taskId of plannedTaskIds) {
69
+ const task = tasksById.get(taskId);
70
+ if (!task)
71
+ continue;
72
+ try {
73
+ ctx.taskService.update(task.id, { status: 'blocked' });
74
+ task.status = 'blocked';
75
+ ctx.counters.statusesUpdated++;
76
+ ctx.log?.info({ taskId: task.id }, 'task-sync:phase2 status updated to blocked');
77
+ }
78
+ catch (err) {
79
+ ctx.log?.warn({ err, taskId: task.id }, 'task-sync:phase2 failed');
80
+ ctx.counters.warnings++;
81
+ }
82
+ await sleep(ctx.throttleMs);
83
+ }
84
+ }
85
+ async function applyPhase3SyncActiveThreads(ctx, tasksById, plannedTaskIds) {
86
+ for (const taskId of plannedTaskIds) {
87
+ const task = tasksById.get(taskId);
88
+ if (!task)
89
+ continue;
90
+ await withTaskLifecycleLock(task.id, async () => {
91
+ const latestTask = ctx.store.get(task.id) ?? task;
92
+ const threadId = getThreadIdFromTask(latestTask);
93
+ if (!threadId || latestTask.status === 'closed') {
94
+ return;
95
+ }
96
+ if (await isThreadArchived(ctx.client, threadId)) {
97
+ return;
98
+ }
99
+ try {
100
+ await ensureUnarchived(ctx.client, threadId);
101
+ }
102
+ catch { }
103
+ try {
104
+ const changed = await updateTaskThreadName(ctx.client, threadId, latestTask);
105
+ if (changed) {
106
+ ctx.counters.emojisUpdated++;
107
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 name updated');
108
+ }
109
+ }
110
+ catch (err) {
111
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 failed');
112
+ ctx.counters.warnings++;
113
+ }
114
+ try {
115
+ const starterChanged = await updateTaskStarterMessage(ctx.client, threadId, latestTask, ctx.mentionUserId);
116
+ if (starterChanged) {
117
+ ctx.counters.starterMessagesUpdated++;
118
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 starter updated');
119
+ }
120
+ }
121
+ catch (err) {
122
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 starter update failed');
123
+ ctx.counters.warnings++;
124
+ }
125
+ try {
126
+ const tagChanged = await updateTaskThreadTags(ctx.client, threadId, latestTask, ctx.tagMap);
127
+ if (tagChanged) {
128
+ ctx.counters.tagsUpdated++;
129
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 tags updated');
130
+ }
131
+ }
132
+ catch (err) {
133
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 tag update failed');
134
+ ctx.counters.warnings++;
135
+ }
136
+ });
137
+ await sleep(ctx.throttleMs);
138
+ }
139
+ }
140
+ async function applyPhase4ArchiveClosedThreads(ctx, tasksById, plannedTaskIds) {
141
+ for (const taskId of plannedTaskIds) {
142
+ const task = tasksById.get(taskId);
143
+ if (!task)
144
+ continue;
145
+ await withTaskLifecycleLock(task.id, async () => {
146
+ const latestTask = ctx.store.get(task.id) ?? task;
147
+ const threadId = getThreadIdFromTask(latestTask);
148
+ if (!threadId || latestTask.status !== 'closed') {
149
+ return;
150
+ }
151
+ try {
152
+ if (await isTaskThreadAlreadyClosed(ctx.client, threadId, latestTask, ctx.tagMap)) {
153
+ return;
154
+ }
155
+ if (ctx.hasInFlightForChannel(threadId)) {
156
+ ctx.counters.closesDeferred++;
157
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 close deferred (in-flight reply active)');
158
+ return;
159
+ }
160
+ await closeTaskThread(ctx.client, threadId, latestTask, ctx.tagMap, ctx.log);
161
+ ctx.counters.threadsArchived++;
162
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 archived');
163
+ }
164
+ catch (err) {
165
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase4 failed');
166
+ ctx.counters.warnings++;
167
+ }
168
+ });
169
+ await sleep(ctx.throttleMs);
170
+ }
171
+ }
172
+ const PHASE_EXECUTORS = {
173
+ phase1: applyPhase1CreateMissingThreads,
174
+ phase2: applyPhase2FixBlockedStatus,
175
+ phase3: applyPhase3SyncActiveThreads,
176
+ phase4: applyPhase4ArchiveClosedThreads,
177
+ };
178
+ async function applyReconcileOrphanThread(ctx, operation, state) {
179
+ state.orphanThreadsFound++;
180
+ ctx.log?.info({ threadId: operation.thread.id, threadName: operation.thread.name, shortId: operation.shortId }, 'task-sync:phase5 orphan thread detected');
181
+ }
182
+ async function applyReconcileCollision(ctx, operation) {
183
+ ctx.log?.info({ threadId: operation.thread.id, shortId: operation.shortId, count: operation.collisionCount }, 'task-sync:phase5 short-id collision, skipping');
184
+ }
185
+ async function applyReconcileSkipMismatch(ctx, operation) {
186
+ ctx.log?.info({ taskId: operation.task?.id, threadId: operation.thread.id, existingThreadId: operation.existingThreadId }, 'task-sync:phase5 external_ref points to different thread, skipping');
187
+ }
188
+ async function applyReconcileArchiveActiveClosed(ctx, operation, state) {
189
+ const task = operation.task;
190
+ if (!task)
191
+ return;
192
+ if (ctx.hasInFlightForChannel(operation.thread.id)) {
193
+ ctx.counters.closesDeferred++;
194
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 close deferred (in-flight reply active)');
195
+ return;
196
+ }
197
+ if (!operation.existingThreadId) {
198
+ try {
199
+ ctx.taskService.update(task.id, { externalRef: `discord:${operation.thread.id}` });
200
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 external_ref backfilled');
201
+ }
202
+ catch (err) {
203
+ ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 external_ref backfill failed');
204
+ ctx.counters.warnings++;
205
+ }
206
+ }
207
+ try {
208
+ await closeTaskThread(ctx.client, operation.thread.id, task, ctx.tagMap, ctx.log);
209
+ state.threadsReconciled++;
210
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 reconciled (archived)');
211
+ }
212
+ catch (err) {
213
+ ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archive failed');
214
+ ctx.counters.warnings++;
215
+ }
216
+ }
217
+ async function applyReconcileArchivedClosed(ctx, operation, state) {
218
+ const task = operation.task;
219
+ if (!task)
220
+ return;
221
+ try {
222
+ const alreadyClosed = await isTaskThreadAlreadyClosed(ctx.client, operation.thread.id, task, ctx.tagMap);
223
+ if (!alreadyClosed) {
224
+ if (ctx.hasInFlightForChannel(operation.thread.id)) {
225
+ ctx.counters.closesDeferred++;
226
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 close deferred (in-flight reply active)');
227
+ }
228
+ else {
229
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archived thread is stale, unarchiving to reconcile');
230
+ await closeTaskThread(ctx.client, operation.thread.id, task, ctx.tagMap, ctx.log);
231
+ state.threadsReconciled++;
232
+ ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 reconciled (re-archived)');
233
+ }
234
+ }
235
+ }
236
+ catch (err) {
237
+ ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archived reconcile failed');
238
+ ctx.counters.warnings++;
239
+ }
240
+ }
241
+ const RECONCILE_EXECUTORS = {
242
+ orphan: applyReconcileOrphanThread,
243
+ collision: applyReconcileCollision,
244
+ skip_external_ref_mismatch: applyReconcileSkipMismatch,
245
+ archive_active_closed: applyReconcileArchiveActiveClosed,
246
+ reconcile_archived_closed: applyReconcileArchivedClosed,
247
+ };
248
+ async function fetchPhase5ThreadSources(ctx) {
249
+ let activeThreads;
250
+ try {
251
+ const fetchedActive = await ctx.forum.threads.fetchActive();
252
+ activeThreads = fetchedActive.threads;
253
+ }
254
+ catch (err) {
255
+ ctx.log?.warn({ err }, 'task-sync:phase5 failed to fetch active threads');
256
+ ctx.counters.warnings++;
257
+ return null;
258
+ }
259
+ let archivedThreads = new Map();
260
+ try {
261
+ const fetchedArchived = await ctx.forum.threads.fetchArchived();
262
+ archivedThreads = new Map(fetchedArchived.threads);
263
+ }
264
+ catch (err) {
265
+ ctx.log?.warn({ err }, 'task-sync:phase5 failed to fetch archived threads');
266
+ ctx.counters.warnings++;
267
+ }
268
+ return { activeThreads, archivedThreads };
269
+ }
270
+ async function planPhase5ReconcileOperations(ctx, allTasks) {
271
+ const threadSources = await fetchPhase5ThreadSources(ctx);
272
+ if (!threadSources)
273
+ return null;
274
+ return planTaskReconcileFromThreadSources({
275
+ tasks: allTasks,
276
+ archivedThreads: threadSources.archivedThreads.values(),
277
+ activeThreads: threadSources.activeThreads.values(),
278
+ shortIdOfTaskId: shortTaskId,
279
+ shortIdFromThreadName: extractShortIdFromThreadName,
280
+ threadIdFromTask: getThreadIdFromTask,
281
+ });
282
+ }
283
+ async function applyPhase5ReconcileOperations(ctx, operations, state) {
284
+ for (const operation of operations) {
285
+ await RECONCILE_EXECUTORS[operation.action](ctx, operation, state);
286
+ await sleep(ctx.throttleMs);
287
+ }
288
+ }
289
+ async function applyPhase5ReconcileThreads(ctx, allTasks) {
290
+ const reconcileState = {
291
+ threadsReconciled: 0,
292
+ orphanThreadsFound: 0,
293
+ };
294
+ const plannedReconcileOps = await planPhase5ReconcileOperations(ctx, allTasks);
295
+ if (!plannedReconcileOps) {
296
+ return {
297
+ threadsReconciled: reconcileState.threadsReconciled,
298
+ orphanThreadsFound: reconcileState.orphanThreadsFound,
299
+ };
300
+ }
301
+ await applyPhase5ReconcileOperations(ctx, plannedReconcileOps, reconcileState);
302
+ return {
303
+ threadsReconciled: reconcileState.threadsReconciled,
304
+ orphanThreadsFound: reconcileState.orphanThreadsFound,
305
+ };
306
+ }
307
+ export async function applyTaskSyncExecutionPlan(ctx, applyPlan) {
308
+ for (const phasePlan of applyPlan.phasePlans) {
309
+ if (phasePlan.taskIds.length === 0)
310
+ continue;
311
+ await PHASE_EXECUTORS[phasePlan.phase](ctx, applyPlan.tasksById, phasePlan.taskIds);
312
+ }
313
+ }
314
+ export async function runTaskSyncReconcilePhase(ctx, allTasks, opts) {
315
+ if (opts?.skipPhase5) {
316
+ return { threadsReconciled: 0, orphanThreadsFound: 0 };
317
+ }
318
+ return applyPhase5ReconcileThreads(ctx, allTasks);
319
+ }
@@ -0,0 +1,89 @@
1
+ import { TaskDiscordClientCtor, TaskDiscordGatewayIntentBits } from './discord-types.js';
2
+ import { loadTagMap } from './tag-map.js';
3
+ import { runTaskSync } from './task-sync-engine.js';
4
+ import { resolveTaskDataLoadPath, resolveTaskDataPath } from './path-defaults.js';
5
+ function env(name) {
6
+ const v = (process.env[name] ?? '').trim();
7
+ if (!v)
8
+ throw new Error(`${name} is required`);
9
+ return v;
10
+ }
11
+ function envOpt(name) {
12
+ const v = (process.env[name] ?? '').trim();
13
+ return v || undefined;
14
+ }
15
+ export function parseArgInt(args, name) {
16
+ const idx = args.indexOf(name);
17
+ if (idx < 0)
18
+ return undefined;
19
+ const v = args[idx + 1];
20
+ if (!v)
21
+ throw new Error(`${name} requires a value`);
22
+ const n = Number(v);
23
+ if (!Number.isFinite(n))
24
+ throw new Error(`${name} must be a number`);
25
+ return n;
26
+ }
27
+ /**
28
+ * Core sync execution — separated from Discord login/teardown so it can be
29
+ * unit-tested with mocked dependencies.
30
+ */
31
+ export async function runTaskSyncWithStore(opts) {
32
+ return runTaskSync({
33
+ client: opts.client,
34
+ guild: opts.guild,
35
+ forumId: opts.forumId,
36
+ tagMap: opts.tagMap,
37
+ store: opts.store,
38
+ throttleMs: opts.throttleMs ?? 250,
39
+ archivedDedupeLimit: opts.archivedDedupeLimit ?? 200,
40
+ mentionUserId: opts.mentionUserId,
41
+ });
42
+ }
43
+ export async function runTaskSyncCliMain() {
44
+ const args = process.argv.slice(2);
45
+ const discordToken = env('DISCORD_TOKEN');
46
+ const guildId = env('DISCORD_GUILD_ID');
47
+ const forumId = env('DISCOCLAW_TASKS_FORUM');
48
+ const dataDir = envOpt('DISCOCLAW_DATA_DIR');
49
+ const tagMapPath = envOpt('DISCOCLAW_TASKS_TAG_MAP')
50
+ ?? resolveTaskDataPath(dataDir, 'tag-map.json');
51
+ const tasksPath = envOpt('DISCOCLAW_TASKS_PATH')
52
+ ?? await resolveTaskDataLoadPath(dataDir, 'tasks.jsonl')
53
+ ?? resolveTaskDataPath(dataDir, 'tasks.jsonl');
54
+ const throttleMs = parseArgInt(args, '--throttle-ms') ?? 250;
55
+ const archivedLimit = parseArgInt(args, '--archived-limit') ?? 200;
56
+ const { TaskStore: TaskStoreImpl } = await import('./store.js');
57
+ const store = new TaskStoreImpl(tasksPath ? { persistPath: tasksPath } : {});
58
+ if (tasksPath)
59
+ await store.load();
60
+ const client = new TaskDiscordClientCtor({ intents: [TaskDiscordGatewayIntentBits.Guilds] });
61
+ await client.login(discordToken);
62
+ await new Promise((resolve) => client.once('ready', () => resolve()));
63
+ try {
64
+ const guild = await client.guilds.fetch(guildId);
65
+ const tagMap = tagMapPath ? await loadTagMap(tagMapPath) : {};
66
+ const mentionUserId = envOpt('DISCOCLAW_TASKS_MENTION_USER');
67
+ const sidebarRaw = envOpt('DISCOCLAW_TASKS_SIDEBAR');
68
+ const sidebarEnabled = sidebarRaw === '1' || sidebarRaw?.toLowerCase() === 'true';
69
+ const sidebarMentionUserId = sidebarEnabled ? mentionUserId : undefined;
70
+ const result = await runTaskSyncWithStore({
71
+ client,
72
+ guild,
73
+ forumId,
74
+ tagMap,
75
+ store,
76
+ throttleMs,
77
+ archivedDedupeLimit: archivedLimit,
78
+ mentionUserId: sidebarMentionUserId,
79
+ });
80
+ process.stdout.write(JSON.stringify(result) + '\n');
81
+ }
82
+ finally {
83
+ client.destroy();
84
+ }
85
+ }
86
+ export const runSyncWithStore = runTaskSyncWithStore;
87
+ if (import.meta.url === new URL(process.argv[1] ?? '', 'file:').href) {
88
+ await runTaskSyncCliMain();
89
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { parseArgInt, runSyncWithStore, runTaskSyncWithStore } from './task-sync-cli.js';
3
+ vi.mock('./task-sync-engine.js', () => {
4
+ const runTaskSync = vi.fn().mockResolvedValue({ created: 0, updated: 0, closed: 0 });
5
+ return { runTaskSync };
6
+ });
7
+ // task-sync-cli.ts uses an import.meta.url guard so that main() does NOT run
8
+ // when the module is imported (only when invoked as a script). This lets us
9
+ // import and test the exported helpers without triggering Discord connections
10
+ // or env-var validation.
11
+ describe('parseArgInt', () => {
12
+ it('keeps runSyncWithStore as an alias for runTaskSyncWithStore', () => {
13
+ expect(runSyncWithStore).toBe(runTaskSyncWithStore);
14
+ });
15
+ it('returns undefined when the flag is not in args', () => {
16
+ expect(parseArgInt(['--foo', '1'], '--bar')).toBeUndefined();
17
+ });
18
+ it('returns undefined for an empty args array', () => {
19
+ expect(parseArgInt([], '--throttle-ms')).toBeUndefined();
20
+ });
21
+ it('parses a positive integer', () => {
22
+ expect(parseArgInt(['--throttle-ms', '500'], '--throttle-ms')).toBe(500);
23
+ });
24
+ it('parses zero', () => {
25
+ expect(parseArgInt(['--throttle-ms', '0'], '--throttle-ms')).toBe(0);
26
+ });
27
+ it('parses a negative integer', () => {
28
+ expect(parseArgInt(['--limit', '-1'], '--limit')).toBe(-1);
29
+ });
30
+ it('parses a decimal value', () => {
31
+ expect(parseArgInt(['--throttle-ms', '2.5'], '--throttle-ms')).toBe(2.5);
32
+ });
33
+ it('finds the flag among mixed args', () => {
34
+ expect(parseArgInt(['--archived-limit', '200', '--throttle-ms', '100'], '--throttle-ms')).toBe(100);
35
+ });
36
+ it('returns the value for the first occurrence when the flag appears more than once', () => {
37
+ expect(parseArgInt(['--limit', '10', '--limit', '20'], '--limit')).toBe(10);
38
+ });
39
+ it('throws when the flag is present but no value follows', () => {
40
+ expect(() => parseArgInt(['--throttle-ms'], '--throttle-ms')).toThrow('--throttle-ms requires a value');
41
+ });
42
+ it('throws when the value is a non-numeric string', () => {
43
+ expect(() => parseArgInt(['--limit', 'abc'], '--limit')).toThrow('--limit must be a number');
44
+ });
45
+ it('throws when the value is "NaN"', () => {
46
+ expect(() => parseArgInt(['--limit', 'NaN'], '--limit')).toThrow('must be a number');
47
+ });
48
+ it('throws when the value is "Infinity"', () => {
49
+ expect(() => parseArgInt(['--limit', 'Infinity'], '--limit')).toThrow('must be a number');
50
+ });
51
+ });
52
+ describe('runSyncWithStore', () => {
53
+ it('passes store through to runTaskSync', async () => {
54
+ const { runTaskSync } = await import('./task-sync-engine.js');
55
+ const { TaskStore } = await import('./store.js');
56
+ const store = new TaskStore();
57
+ const fakeClient = {};
58
+ const fakeGuild = {};
59
+ await runSyncWithStore({
60
+ client: fakeClient,
61
+ guild: fakeGuild,
62
+ forumId: 'forum-123',
63
+ tagMap: {},
64
+ store,
65
+ throttleMs: 100,
66
+ archivedDedupeLimit: 50,
67
+ });
68
+ expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ store }));
69
+ });
70
+ });