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,171 @@
1
+ import { withTaskLifecycleLock } from './task-lifecycle.js';
2
+ import { closeTaskThread, createTaskThread, ensureUnarchived, findExistingThreadForTask, isTaskThreadAlreadyClosed, isThreadArchived, updateTaskStarterMessage, updateTaskThreadName, updateTaskThreadTags, } from './thread-ops.js';
3
+ import { getThreadIdFromTask } from './thread-helpers.js';
4
+ function sleep(ms) {
5
+ const n = ms ?? 0;
6
+ if (n <= 0)
7
+ return Promise.resolve();
8
+ return new Promise((r) => setTimeout(r, n));
9
+ }
10
+ async function applyPhase1CreateMissingThreads(ctx, tasksById, plannedTaskIds) {
11
+ for (const taskId of plannedTaskIds) {
12
+ const task = tasksById.get(taskId);
13
+ if (!task)
14
+ continue;
15
+ await withTaskLifecycleLock(task.id, async () => {
16
+ const latestTask = ctx.store.get(task.id) ?? task;
17
+ if (getThreadIdFromTask(latestTask) ||
18
+ latestTask.status === 'closed' ||
19
+ (latestTask.labels ?? []).includes('no-thread')) {
20
+ return;
21
+ }
22
+ try {
23
+ const existing = await findExistingThreadForTask(ctx.forum, latestTask.id, { archivedLimit: ctx.archivedDedupeLimit });
24
+ if (existing) {
25
+ try {
26
+ ctx.taskService.update(latestTask.id, { externalRef: `discord:${existing}` });
27
+ ctx.log?.info({ taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfilled');
28
+ }
29
+ catch (err) {
30
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId: existing }, 'task-sync:phase1 external-ref backfill failed');
31
+ ctx.counters.warnings++;
32
+ }
33
+ return;
34
+ }
35
+ const threadId = await createTaskThread(ctx.forum, latestTask, ctx.tagMap, ctx.mentionUserId);
36
+ try {
37
+ ctx.taskService.update(latestTask.id, { externalRef: `discord:${threadId}` });
38
+ }
39
+ catch (err) {
40
+ ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 external-ref update failed');
41
+ ctx.counters.warnings++;
42
+ }
43
+ ctx.counters.threadsCreated++;
44
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase1 thread created');
45
+ }
46
+ catch (err) {
47
+ ctx.log?.warn({ err, taskId: latestTask.id }, 'task-sync:phase1 failed');
48
+ ctx.counters.warnings++;
49
+ }
50
+ });
51
+ await sleep(ctx.throttleMs);
52
+ }
53
+ }
54
+ async function applyPhase2FixBlockedStatus(ctx, tasksById, plannedTaskIds) {
55
+ for (const taskId of plannedTaskIds) {
56
+ const task = tasksById.get(taskId);
57
+ if (!task)
58
+ continue;
59
+ try {
60
+ ctx.taskService.update(task.id, { status: 'blocked' });
61
+ task.status = 'blocked';
62
+ ctx.counters.statusesUpdated++;
63
+ ctx.log?.info({ taskId: task.id }, 'task-sync:phase2 status updated to blocked');
64
+ }
65
+ catch (err) {
66
+ ctx.log?.warn({ err, taskId: task.id }, 'task-sync:phase2 failed');
67
+ ctx.counters.warnings++;
68
+ }
69
+ await sleep(ctx.throttleMs);
70
+ }
71
+ }
72
+ async function applyPhase3SyncActiveThreads(ctx, tasksById, plannedTaskIds) {
73
+ for (const taskId of plannedTaskIds) {
74
+ const task = tasksById.get(taskId);
75
+ if (!task)
76
+ continue;
77
+ await withTaskLifecycleLock(task.id, async () => {
78
+ const latestTask = ctx.store.get(task.id) ?? task;
79
+ const threadId = getThreadIdFromTask(latestTask);
80
+ if (!threadId || latestTask.status === 'closed') {
81
+ return;
82
+ }
83
+ if (await isThreadArchived(ctx.client, threadId)) {
84
+ return;
85
+ }
86
+ try {
87
+ await ensureUnarchived(ctx.client, threadId);
88
+ }
89
+ catch { }
90
+ try {
91
+ const changed = await updateTaskThreadName(ctx.client, threadId, latestTask);
92
+ if (changed) {
93
+ ctx.counters.emojisUpdated++;
94
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 name updated');
95
+ }
96
+ }
97
+ catch (err) {
98
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 failed');
99
+ ctx.counters.warnings++;
100
+ }
101
+ try {
102
+ const starterChanged = await updateTaskStarterMessage(ctx.client, threadId, latestTask, ctx.mentionUserId);
103
+ if (starterChanged) {
104
+ ctx.counters.starterMessagesUpdated++;
105
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 starter updated');
106
+ }
107
+ }
108
+ catch (err) {
109
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 starter update failed');
110
+ ctx.counters.warnings++;
111
+ }
112
+ try {
113
+ const tagChanged = await updateTaskThreadTags(ctx.client, threadId, latestTask, ctx.tagMap);
114
+ if (tagChanged) {
115
+ ctx.counters.tagsUpdated++;
116
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase3 tags updated');
117
+ }
118
+ }
119
+ catch (err) {
120
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase3 tag update failed');
121
+ ctx.counters.warnings++;
122
+ }
123
+ });
124
+ await sleep(ctx.throttleMs);
125
+ }
126
+ }
127
+ async function applyPhase4ArchiveClosedThreads(ctx, tasksById, plannedTaskIds) {
128
+ for (const taskId of plannedTaskIds) {
129
+ const task = tasksById.get(taskId);
130
+ if (!task)
131
+ continue;
132
+ await withTaskLifecycleLock(task.id, async () => {
133
+ const latestTask = ctx.store.get(task.id) ?? task;
134
+ const threadId = getThreadIdFromTask(latestTask);
135
+ if (!threadId || latestTask.status !== 'closed') {
136
+ return;
137
+ }
138
+ try {
139
+ if (await isTaskThreadAlreadyClosed(ctx.client, threadId, latestTask, ctx.tagMap)) {
140
+ return;
141
+ }
142
+ if (ctx.hasInFlightForChannel(threadId)) {
143
+ ctx.counters.closesDeferred++;
144
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 close deferred (in-flight reply active)');
145
+ return;
146
+ }
147
+ await closeTaskThread(ctx.client, threadId, latestTask, ctx.tagMap, ctx.log);
148
+ ctx.counters.threadsArchived++;
149
+ ctx.log?.info({ taskId: latestTask.id, threadId }, 'task-sync:phase4 archived');
150
+ }
151
+ catch (err) {
152
+ ctx.log?.warn({ err, taskId: latestTask.id, threadId }, 'task-sync:phase4 failed');
153
+ ctx.counters.warnings++;
154
+ }
155
+ });
156
+ await sleep(ctx.throttleMs);
157
+ }
158
+ }
159
+ const PHASE_EXECUTORS = {
160
+ phase1: applyPhase1CreateMissingThreads,
161
+ phase2: applyPhase2FixBlockedStatus,
162
+ phase3: applyPhase3SyncActiveThreads,
163
+ phase4: applyPhase4ArchiveClosedThreads,
164
+ };
165
+ export async function applyTaskSyncExecutionPlan(ctx, applyPlan) {
166
+ for (const phasePlan of applyPlan.phasePlans) {
167
+ if (phasePlan.taskIds.length === 0)
168
+ continue;
169
+ await PHASE_EXECUTORS[phasePlan.phase](ctx, applyPlan.tasksById, phasePlan.taskIds);
170
+ }
171
+ }
@@ -0,0 +1,2 @@
1
+ export * from './task-sync-apply-plan.js';
2
+ export * from './task-sync-reconcile-plan.js';
@@ -0,0 +1,265 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildTasksByShortIdMap, buildTasksByThreadIdMap, ingestTaskThreadSnapshots, ingestTaskSyncSnapshot, planTaskSyncApplyExecution, planTaskReconcileFromThreadSources, planTaskReconcileFromSnapshots, planTaskReconcileOperations, planTaskApplyPhases, operationTaskIdList, normalizeTaskSyncBuckets, operationTaskIdSet, planTaskSyncOperations, } from './task-sync-pipeline.js';
3
+ function task(overrides) {
4
+ return {
5
+ id: overrides.id,
6
+ title: overrides.title,
7
+ status: overrides.status,
8
+ labels: overrides.labels ?? [],
9
+ external_ref: overrides.external_ref,
10
+ };
11
+ }
12
+ describe('task-sync pipeline helpers', () => {
13
+ it('ingest copies list shape without cloning task objects', () => {
14
+ const originalTask = task({ id: 'ws-001', title: 'A', status: 'open' });
15
+ const input = [originalTask];
16
+ const snapshot = ingestTaskSyncSnapshot(input);
17
+ expect(snapshot).not.toBe(input);
18
+ expect(snapshot[0]).toBe(originalTask);
19
+ });
20
+ it('normalizes tasks into expected phase buckets', () => {
21
+ const allTasks = [
22
+ task({ id: 'ws-001', title: 'Missing ref', status: 'open' }),
23
+ task({ id: 'ws-002', title: 'No thread', status: 'open', labels: ['no-thread'] }),
24
+ task({ id: 'ws-003', title: 'Blocked label', status: 'open', labels: ['blocked-api'] }),
25
+ task({ id: 'ws-004', title: 'Has ref', status: 'in_progress', external_ref: 'discord:123' }),
26
+ task({ id: 'ws-005', title: 'Closed ref', status: 'closed', external_ref: 'discord:124' }),
27
+ ];
28
+ const buckets = normalizeTaskSyncBuckets(allTasks);
29
+ expect(buckets.tasksMissingRef.map((t) => t.id)).toEqual(['ws-001', 'ws-003']);
30
+ expect(buckets.needsBlockedTasks.map((t) => t.id)).toEqual(['ws-003']);
31
+ expect(buckets.tasksWithRef.map((t) => t.id)).toEqual(['ws-004']);
32
+ expect(buckets.closedTasks.map((t) => t.id)).toEqual(['ws-005']);
33
+ });
34
+ it('builds deterministic idempotent operation plans and phase id sets', () => {
35
+ const buckets = {
36
+ tasksMissingRef: [
37
+ task({ id: 'ws-001', title: 'A', status: 'open' }),
38
+ task({ id: 'ws-002', title: 'B', status: 'open' }),
39
+ ],
40
+ needsBlockedTasks: [
41
+ task({ id: 'ws-003', title: 'C', status: 'open', labels: ['blocked-db'] }),
42
+ ],
43
+ tasksWithRef: [
44
+ task({ id: 'ws-004', title: 'D', status: 'in_progress', external_ref: 'discord:11' }),
45
+ ],
46
+ closedTasks: [
47
+ task({ id: 'ws-005', title: 'E', status: 'closed', external_ref: 'discord:12' }),
48
+ ],
49
+ };
50
+ const operations = planTaskSyncOperations(buckets);
51
+ expect(operations.map((op) => op.key)).toEqual([
52
+ 'task-sync:phase1:ws-001',
53
+ 'task-sync:phase1:ws-002',
54
+ 'task-sync:phase2:ws-003',
55
+ 'task-sync:phase3:ws-004',
56
+ 'task-sync:phase4:ws-005',
57
+ ]);
58
+ expect(operationTaskIdSet(operations, 'phase1')).toEqual(new Set(['ws-001', 'ws-002']));
59
+ expect(operationTaskIdSet(operations, 'phase4')).toEqual(new Set(['ws-005']));
60
+ expect(operationTaskIdList(operations, 'phase1')).toEqual(['ws-001', 'ws-002']);
61
+ expect(operationTaskIdList(operations, 'phase3')).toEqual(['ws-004']);
62
+ });
63
+ it('operationTaskIdList preserves in-plan order for the selected phase', () => {
64
+ const operations = [
65
+ { phase: 'phase2', taskId: 'ws-010', key: 'task-sync:phase2:ws-010' },
66
+ { phase: 'phase1', taskId: 'ws-001', key: 'task-sync:phase1:ws-001' },
67
+ { phase: 'phase3', taskId: 'ws-020', key: 'task-sync:phase3:ws-020' },
68
+ { phase: 'phase1', taskId: 'ws-002', key: 'task-sync:phase1:ws-002' },
69
+ ];
70
+ expect(operationTaskIdList(operations, 'phase1')).toEqual(['ws-001', 'ws-002']);
71
+ });
72
+ it('builds ordered apply-phase plans from the diff operation list', () => {
73
+ const operations = [
74
+ { phase: 'phase3', taskId: 'ws-020', key: 'task-sync:phase3:ws-020' },
75
+ { phase: 'phase1', taskId: 'ws-001', key: 'task-sync:phase1:ws-001' },
76
+ { phase: 'phase4', taskId: 'ws-030', key: 'task-sync:phase4:ws-030' },
77
+ { phase: 'phase1', taskId: 'ws-002', key: 'task-sync:phase1:ws-002' },
78
+ ];
79
+ const phasePlans = planTaskApplyPhases(operations);
80
+ expect(phasePlans).toEqual([
81
+ { phase: 'phase1', taskIds: ['ws-001', 'ws-002'] },
82
+ { phase: 'phase2', taskIds: [] },
83
+ { phase: 'phase3', taskIds: ['ws-020'] },
84
+ { phase: 'phase4', taskIds: ['ws-030'] },
85
+ ]);
86
+ });
87
+ it('composes stage2-4 apply execution plan from a task snapshot', () => {
88
+ const allTasks = [
89
+ task({ id: 'ws-001', title: 'Missing ref', status: 'open' }),
90
+ task({ id: 'ws-003', title: 'Blocked label', status: 'open', labels: ['blocked-api'] }),
91
+ task({ id: 'ws-004', title: 'Has ref', status: 'in_progress', external_ref: 'discord:123' }),
92
+ task({ id: 'ws-005', title: 'Closed ref', status: 'closed', external_ref: 'discord:124' }),
93
+ ];
94
+ const plan = planTaskSyncApplyExecution(allTasks);
95
+ expect(plan.operations.map((op) => op.key)).toEqual([
96
+ 'task-sync:phase1:ws-001',
97
+ 'task-sync:phase1:ws-003',
98
+ 'task-sync:phase2:ws-003',
99
+ 'task-sync:phase3:ws-004',
100
+ 'task-sync:phase4:ws-005',
101
+ ]);
102
+ expect(plan.phasePlans).toEqual([
103
+ { phase: 'phase1', taskIds: ['ws-001', 'ws-003'] },
104
+ { phase: 'phase2', taskIds: ['ws-003'] },
105
+ { phase: 'phase3', taskIds: ['ws-004'] },
106
+ { phase: 'phase4', taskIds: ['ws-005'] },
107
+ ]);
108
+ expect(plan.tasksById.get('ws-004')).toBe(allTasks[2]);
109
+ });
110
+ it('builds a short-id lookup map for reconciliation', () => {
111
+ const map = buildTasksByShortIdMap([
112
+ task({ id: 'ws-001', title: 'A', status: 'open' }),
113
+ task({ id: 'dev-001', title: 'B', status: 'open' }),
114
+ task({ id: 'ws-002', title: 'C', status: 'closed' }),
115
+ ], (id) => id.split('-')[1] ?? id);
116
+ expect(map.get('001')?.map((t) => t.id)).toEqual(['ws-001', 'dev-001']);
117
+ expect(map.get('002')?.map((t) => t.id)).toEqual(['ws-002']);
118
+ });
119
+ it('builds a thread-id lookup map for reconciliation', () => {
120
+ const map = buildTasksByThreadIdMap([
121
+ task({ id: 'ws-001', title: 'A', status: 'closed', external_ref: 'discord:thread-1' }),
122
+ task({ id: 'ws-002', title: 'B', status: 'open', external_ref: 'discord:thread-2' }),
123
+ task({ id: 'ws-003', title: 'C', status: 'open' }),
124
+ ], (t) => {
125
+ const ref = t.external_ref ?? '';
126
+ return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
127
+ });
128
+ expect(map.get('thread-1')?.map((t) => t.id)).toEqual(['ws-001']);
129
+ expect(map.get('thread-2')?.map((t) => t.id)).toEqual(['ws-002']);
130
+ expect(map.get('thread-3')).toBeUndefined();
131
+ });
132
+ it('normalizes and merges phase5 thread snapshots with active-over-archived precedence', () => {
133
+ const snapshots = ingestTaskThreadSnapshots([
134
+ { id: 'thread-1', name: 'Archived One', archived: true },
135
+ { id: 'shared', name: 'Archived Shared', archived: true },
136
+ ], [
137
+ { id: 'shared', name: 'Active Shared', archived: false },
138
+ { id: 2, name: null, archived: null },
139
+ ]);
140
+ expect(snapshots).toEqual([
141
+ { id: 'thread-1', name: 'Archived One', archived: true },
142
+ { id: 'shared', name: 'Active Shared', archived: false },
143
+ { id: '2', name: '', archived: false },
144
+ ]);
145
+ });
146
+ it('plans phase5 reconcile operations from thread snapshots', () => {
147
+ const tasksByShortId = buildTasksByShortIdMap([
148
+ task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
149
+ task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
150
+ task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
151
+ task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
152
+ ], (id) => id.split('-')[1] ?? id);
153
+ const ops = planTaskReconcileOperations({
154
+ threads: [
155
+ { id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
156
+ { id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
157
+ { id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
158
+ { id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
159
+ { id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
160
+ ],
161
+ tasksByShortId,
162
+ shortIdFromThreadName: (name) => {
163
+ const match = name.match(/\[(\d+)\]/);
164
+ return match ? match[1] : null;
165
+ },
166
+ threadIdFromTask: (t) => {
167
+ const ref = t.external_ref ?? '';
168
+ return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
169
+ },
170
+ });
171
+ expect(ops.map((op) => op.action)).toEqual([
172
+ 'orphan',
173
+ 'collision',
174
+ 'skip_external_ref_mismatch',
175
+ 'archive_active_closed',
176
+ 'reconcile_archived_closed',
177
+ ]);
178
+ });
179
+ it('plans phase5 reconcile operations directly from task and thread snapshots', () => {
180
+ const ops = planTaskReconcileFromSnapshots({
181
+ tasks: [
182
+ task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
183
+ task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
184
+ task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
185
+ task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
186
+ ],
187
+ threads: [
188
+ { id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
189
+ { id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
190
+ { id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
191
+ { id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
192
+ { id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
193
+ ],
194
+ shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
195
+ shortIdFromThreadName: (name) => {
196
+ const match = name.match(/\[(\d+)\]/);
197
+ return match ? match[1] : null;
198
+ },
199
+ threadIdFromTask: (t) => {
200
+ const ref = t.external_ref ?? '';
201
+ return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
202
+ },
203
+ });
204
+ expect(ops.map((op) => op.action)).toEqual([
205
+ 'orphan',
206
+ 'collision',
207
+ 'skip_external_ref_mismatch',
208
+ 'archive_active_closed',
209
+ 'reconcile_archived_closed',
210
+ ]);
211
+ });
212
+ it('prefers thread-id mapping before thread-name parsing in reconcile planning', () => {
213
+ const ops = planTaskReconcileFromSnapshots({
214
+ tasks: [
215
+ task({ id: 'ws-010', title: 'Closed mapped', status: 'closed', external_ref: 'discord:thread-linked' }),
216
+ ],
217
+ threads: [
218
+ { id: 'thread-linked', name: 'General thread without token', archived: false },
219
+ ],
220
+ shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
221
+ shortIdFromThreadName: () => null,
222
+ threadIdFromTask: (t) => {
223
+ const ref = t.external_ref ?? '';
224
+ return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
225
+ },
226
+ });
227
+ expect(ops.map((op) => op.action)).toEqual(['archive_active_closed']);
228
+ expect(ops[0]?.task?.id).toBe('ws-010');
229
+ });
230
+ it('plans phase5 reconcile operations directly from archived and active thread sources', () => {
231
+ const ops = planTaskReconcileFromThreadSources({
232
+ tasks: [
233
+ task({ id: 'ws-001', title: 'Closed A', status: 'closed', external_ref: 'discord:thread-001' }),
234
+ task({ id: 'ws-002', title: 'Closed B', status: 'closed' }),
235
+ task({ id: 'ws-777', title: 'Collision A', status: 'open' }),
236
+ task({ id: 'dev-777', title: 'Collision B', status: 'open' }),
237
+ ],
238
+ archivedThreads: [
239
+ { id: 'thread-reconcile', name: '☑️ [002] Closed archived', archived: true },
240
+ ],
241
+ activeThreads: [
242
+ { id: 'thread-orphan', name: '🟢 [999] Orphan', archived: false },
243
+ { id: 'thread-collision', name: '🟢 [777] Collision', archived: false },
244
+ { id: 'thread-mismatch', name: '🟢 [001] Mismatch', archived: false },
245
+ { id: 'thread-archive', name: '🟢 [002] Closed active', archived: false },
246
+ ],
247
+ shortIdOfTaskId: (id) => id.split('-')[1] ?? id,
248
+ shortIdFromThreadName: (name) => {
249
+ const match = name.match(/\[(\d+)\]/);
250
+ return match ? match[1] : null;
251
+ },
252
+ threadIdFromTask: (t) => {
253
+ const ref = t.external_ref ?? '';
254
+ return ref.startsWith('discord:') ? ref.slice('discord:'.length) : null;
255
+ },
256
+ });
257
+ expect(ops.map((op) => op.action)).toEqual([
258
+ 'reconcile_archived_closed',
259
+ 'orphan',
260
+ 'collision',
261
+ 'skip_external_ref_mismatch',
262
+ 'archive_active_closed',
263
+ ]);
264
+ });
265
+ });
@@ -0,0 +1,182 @@
1
+ export function buildTasksByShortIdMap(allTasks, shortIdOf) {
2
+ const tasksByShortId = new Map();
3
+ for (const task of allTasks) {
4
+ const shortId = shortIdOf(task.id);
5
+ const existing = tasksByShortId.get(shortId);
6
+ if (existing)
7
+ existing.push(task);
8
+ else
9
+ tasksByShortId.set(shortId, [task]);
10
+ }
11
+ return tasksByShortId;
12
+ }
13
+ export function buildTasksByThreadIdMap(allTasks, threadIdFromTask) {
14
+ const tasksByThreadId = new Map();
15
+ for (const task of allTasks) {
16
+ const threadId = threadIdFromTask(task);
17
+ if (!threadId)
18
+ continue;
19
+ const existing = tasksByThreadId.get(threadId);
20
+ if (existing)
21
+ existing.push(task);
22
+ else
23
+ tasksByThreadId.set(threadId, [task]);
24
+ }
25
+ return tasksByThreadId;
26
+ }
27
+ /**
28
+ * Stage: ingest (phase 5)
29
+ * Merge archived+active thread sources into a normalized snapshot list.
30
+ * When a thread ID appears in both sources, the active source wins.
31
+ */
32
+ export function ingestTaskThreadSnapshots(archivedThreads, activeThreads) {
33
+ const byThreadId = new Map();
34
+ const push = (thread) => {
35
+ const id = String(thread.id);
36
+ byThreadId.set(id, {
37
+ id,
38
+ name: String(thread.name ?? ''),
39
+ archived: Boolean(thread.archived),
40
+ });
41
+ };
42
+ for (const thread of archivedThreads)
43
+ push(thread);
44
+ for (const thread of activeThreads)
45
+ push(thread);
46
+ return [...byThreadId.values()];
47
+ }
48
+ /**
49
+ * Stage: diff (phase 5)
50
+ * Plan reconciliation operations for forum threads vs local task snapshot.
51
+ */
52
+ export function planTaskReconcileOperations(opts) {
53
+ const operations = [];
54
+ const tasksByThreadId = opts.tasksByThreadId ?? new Map();
55
+ for (const thread of opts.threads) {
56
+ const linkedTasks = tasksByThreadId.get(thread.id) ?? [];
57
+ if (linkedTasks.length > 1) {
58
+ operations.push({
59
+ action: 'collision',
60
+ key: `task-sync:phase5:collision:${thread.id}`,
61
+ thread,
62
+ shortId: opts.shortIdFromThreadName(thread.name) ?? '',
63
+ collisionCount: linkedTasks.length,
64
+ });
65
+ continue;
66
+ }
67
+ if (linkedTasks.length === 1) {
68
+ const task = linkedTasks[0];
69
+ const existingThreadId = opts.threadIdFromTask(task);
70
+ if (task.status === 'closed' && !thread.archived) {
71
+ operations.push({
72
+ action: 'archive_active_closed',
73
+ key: `task-sync:phase5:archive:${thread.id}`,
74
+ thread,
75
+ shortId: opts.shortIdFromThreadName(thread.name) ?? '',
76
+ task,
77
+ existingThreadId: existingThreadId ?? undefined,
78
+ });
79
+ continue;
80
+ }
81
+ if (task.status === 'closed' && thread.archived) {
82
+ operations.push({
83
+ action: 'reconcile_archived_closed',
84
+ key: `task-sync:phase5:reconcile:${thread.id}`,
85
+ thread,
86
+ shortId: opts.shortIdFromThreadName(thread.name) ?? '',
87
+ task,
88
+ existingThreadId: existingThreadId ?? undefined,
89
+ });
90
+ }
91
+ continue;
92
+ }
93
+ // Fallback for legacy threads missing task external_ref mapping.
94
+ const shortId = opts.shortIdFromThreadName(thread.name);
95
+ if (!shortId)
96
+ continue;
97
+ const tasks = opts.tasksByShortId.get(shortId);
98
+ if (!tasks || tasks.length === 0) {
99
+ operations.push({
100
+ action: 'orphan',
101
+ key: `task-sync:phase5:orphan:${thread.id}`,
102
+ thread,
103
+ shortId,
104
+ });
105
+ continue;
106
+ }
107
+ if (tasks.length > 1) {
108
+ operations.push({
109
+ action: 'collision',
110
+ key: `task-sync:phase5:collision:${thread.id}`,
111
+ thread,
112
+ shortId,
113
+ collisionCount: tasks.length,
114
+ });
115
+ continue;
116
+ }
117
+ const task = tasks[0];
118
+ const existingThreadId = opts.threadIdFromTask(task);
119
+ if (existingThreadId && existingThreadId !== thread.id) {
120
+ operations.push({
121
+ action: 'skip_external_ref_mismatch',
122
+ key: `task-sync:phase5:skip-mismatch:${thread.id}`,
123
+ thread,
124
+ shortId,
125
+ task,
126
+ existingThreadId,
127
+ });
128
+ continue;
129
+ }
130
+ if (task.status === 'closed' && !thread.archived) {
131
+ operations.push({
132
+ action: 'archive_active_closed',
133
+ key: `task-sync:phase5:archive:${thread.id}`,
134
+ thread,
135
+ shortId,
136
+ task,
137
+ existingThreadId: existingThreadId ?? undefined,
138
+ });
139
+ continue;
140
+ }
141
+ if (task.status === 'closed' && thread.archived) {
142
+ operations.push({
143
+ action: 'reconcile_archived_closed',
144
+ key: `task-sync:phase5:reconcile:${thread.id}`,
145
+ thread,
146
+ shortId,
147
+ task,
148
+ existingThreadId: existingThreadId ?? undefined,
149
+ });
150
+ }
151
+ }
152
+ return operations;
153
+ }
154
+ /**
155
+ * Stage: diff (phase 5)
156
+ * Build phase-5 reconcile operations directly from task+thread snapshots.
157
+ */
158
+ export function planTaskReconcileFromSnapshots(opts) {
159
+ const tasksByShortId = buildTasksByShortIdMap(opts.tasks, opts.shortIdOfTaskId);
160
+ const tasksByThreadId = buildTasksByThreadIdMap(opts.tasks, opts.threadIdFromTask);
161
+ return planTaskReconcileOperations({
162
+ threads: opts.threads,
163
+ tasksByShortId,
164
+ tasksByThreadId,
165
+ shortIdFromThreadName: opts.shortIdFromThreadName,
166
+ threadIdFromTask: opts.threadIdFromTask,
167
+ });
168
+ }
169
+ /**
170
+ * Stage: diff (phase 5)
171
+ * Compose thread-source ingest + reconcile diff planning.
172
+ */
173
+ export function planTaskReconcileFromThreadSources(opts) {
174
+ const threads = ingestTaskThreadSnapshots(opts.archivedThreads, opts.activeThreads);
175
+ return planTaskReconcileFromSnapshots({
176
+ tasks: opts.tasks,
177
+ threads,
178
+ shortIdOfTaskId: opts.shortIdOfTaskId,
179
+ shortIdFromThreadName: opts.shortIdFromThreadName,
180
+ threadIdFromTask: opts.threadIdFromTask,
181
+ });
182
+ }