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,205 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ChannelType } from 'discord.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Forum channel resolution
6
+ // ---------------------------------------------------------------------------
7
+ export async function resolveForumChannel(client, forumId) {
8
+ const ch = client.channels.cache.get(forumId);
9
+ if (ch && ch.type === ChannelType.GuildForum)
10
+ return ch;
11
+ try {
12
+ const fetched = await client.channels.fetch(forumId);
13
+ if (fetched && fetched.type === ChannelType.GuildForum)
14
+ return fetched;
15
+ }
16
+ catch { }
17
+ return null;
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // Cadence emojis
21
+ // ---------------------------------------------------------------------------
22
+ export const CADENCE_EMOJI = {
23
+ frequent: '\u23F1\uFE0F', // ⏱️ (with VS16 to match Discord's normalization)
24
+ hourly: '\uD83D\uDD50', // 🕐
25
+ daily: '\uD83C\uDF05', // 🌅
26
+ weekly: '\uD83D\uDCC5', // 📅
27
+ monthly: '\uD83D\uDCC6', // 📆
28
+ };
29
+ // ---------------------------------------------------------------------------
30
+ // Cadence prefix stripping
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * All known cadence emoji values with VS16 stripped, used to match prefixes
34
+ * regardless of whether the input contains variation selectors.
35
+ */
36
+ const CADENCE_EMOJI_STRIPPED = new Set(Object.values(CADENCE_EMOJI).map((e) => e.replaceAll('\uFE0F', '')));
37
+ /**
38
+ * Strip any leading cadence emoji prefix(es) from a thread name.
39
+ * Handles accumulated prefixes (e.g., "🌅 🌅 🌅 Test") by stripping
40
+ * repeatedly until no cadence emoji prefix remains.
41
+ *
42
+ * Matching is performed on a VS16-stripped "shadow" of the input to handle
43
+ * Discord's emoji normalization (Discord may add/remove U+FE0F). The
44
+ * remainder is sliced from the *original* string to preserve any VS16
45
+ * characters in the user-authored base name.
46
+ */
47
+ export function stripCadencePrefix(name) {
48
+ // Shadow copy with VS16 removed — used only for prefix matching.
49
+ const shadow = name.replaceAll('\uFE0F', '');
50
+ // Track how many characters we've consumed in the shadow string.
51
+ let shadowOffset = 0;
52
+ while (shadowOffset < shadow.length) {
53
+ let matched = false;
54
+ for (const emoji of CADENCE_EMOJI_STRIPPED) {
55
+ const prefix = `${emoji} `;
56
+ if (shadow.startsWith(prefix, shadowOffset)) {
57
+ shadowOffset += prefix.length;
58
+ matched = true;
59
+ break;
60
+ }
61
+ }
62
+ if (!matched)
63
+ break;
64
+ }
65
+ if (shadowOffset === 0)
66
+ return name;
67
+ // Map the shadow offset back to the original string.
68
+ // The shadow has all \uFE0F removed, so we advance through the original
69
+ // string, skipping \uFE0F characters, until we've consumed `shadowOffset`
70
+ // non-VS16 characters.
71
+ let origOffset = 0;
72
+ let consumed = 0;
73
+ while (consumed < shadowOffset && origOffset < name.length) {
74
+ if (name[origOffset] === '\uFE0F') {
75
+ origOffset++;
76
+ }
77
+ else {
78
+ origOffset++;
79
+ consumed++;
80
+ }
81
+ }
82
+ // Also skip any trailing VS16 at the boundary.
83
+ while (origOffset < name.length && name[origOffset] === '\uFE0F') {
84
+ origOffset++;
85
+ }
86
+ return name.slice(origOffset);
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Thread name builder
90
+ // ---------------------------------------------------------------------------
91
+ const THREAD_NAME_MAX = 100;
92
+ export function buildCronThreadName(name, cadence) {
93
+ const stripped = stripCadencePrefix(name);
94
+ const emoji = cadence ? (CADENCE_EMOJI[cadence] ?? '') : '';
95
+ const prefix = emoji ? `${emoji} ` : '';
96
+ const maxName = THREAD_NAME_MAX - prefix.length;
97
+ const trimmed = stripped.length > maxName ? stripped.slice(0, maxName - 1) + '\u2026' : stripped;
98
+ return `${prefix}${trimmed}`;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Status message formatting
102
+ // ---------------------------------------------------------------------------
103
+ // Running indicator is per-process in-memory state and not cross-process;
104
+ // it clears on restart and may be stale after crashes/restarts.
105
+ export function formatStatusMessage(cronId, record, running) {
106
+ const lines = [];
107
+ lines.push(`\uD83D\uDCCA **Cron Status** [cronId:${cronId}]`);
108
+ if (running) {
109
+ lines.push('\uD83D\uDD04 **Currently running**');
110
+ }
111
+ const lastRun = record.lastRunAt
112
+ ? `<t:${Math.floor(new Date(record.lastRunAt).getTime() / 1000)}:R>`
113
+ : 'Never';
114
+ const statusEmoji = record.lastRunStatus === 'success' ? '\u2705' : record.lastRunStatus === 'error' ? '\u274C' : '\u2796';
115
+ const statusText = record.lastRunStatus ?? 'N/A';
116
+ lines.push(`**Last run:** ${lastRun} | **Status:** ${statusEmoji} ${statusText} | **Runs:** ${record.runCount}`);
117
+ const model = record.modelOverride ?? record.model ?? 'N/A';
118
+ const cadence = record.cadence ?? 'N/A';
119
+ lines.push(`**Model:** ${model} | **Cadence:** ${cadence}`);
120
+ if (record.purposeTags.length > 0) {
121
+ lines.push(`**Tags:** ${record.purposeTags.join(', ')}`);
122
+ }
123
+ if (record.lastRunStatus === 'error' && record.lastErrorMessage) {
124
+ lines.push(`**Last error:** ${record.lastErrorMessage}`);
125
+ }
126
+ return lines.join('\n');
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Status message lifecycle
130
+ // ---------------------------------------------------------------------------
131
+ async function fetchThreadChannel(client, threadId) {
132
+ const cached = client.channels.cache.get(threadId);
133
+ if (cached && cached.isThread())
134
+ return cached;
135
+ try {
136
+ const fetched = await client.channels.fetch(threadId);
137
+ if (fetched && fetched.isThread())
138
+ return fetched;
139
+ return null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ export async function ensureStatusMessage(client, threadId, cronId, record, stats, opts) {
146
+ const { log, running } = opts ?? {};
147
+ const thread = await fetchThreadChannel(client, threadId);
148
+ if (!thread) {
149
+ log?.warn({ threadId, cronId }, 'cron:status-msg thread not found');
150
+ return undefined;
151
+ }
152
+ const content = formatStatusMessage(cronId, record, running);
153
+ // Try to edit existing status message.
154
+ if (record.statusMessageId) {
155
+ try {
156
+ const msg = await thread.messages.fetch(record.statusMessageId);
157
+ if (msg) {
158
+ await msg.edit({ content, allowedMentions: { parse: [] } });
159
+ return record.statusMessageId;
160
+ }
161
+ }
162
+ catch {
163
+ // Message may have been deleted; fall through to create.
164
+ }
165
+ }
166
+ // Create new status message.
167
+ try {
168
+ const msg = await thread.send({ content, allowedMentions: { parse: [] } });
169
+ // Best-effort pin.
170
+ try {
171
+ await msg.pin();
172
+ }
173
+ catch {
174
+ // Non-fatal if pin fails.
175
+ }
176
+ // Store the message ID.
177
+ await stats.upsertRecord(cronId, threadId, { statusMessageId: msg.id });
178
+ return msg.id;
179
+ }
180
+ catch (err) {
181
+ log?.warn({ err, threadId, cronId }, 'cron:status-msg creation failed');
182
+ return undefined;
183
+ }
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Tag map seeding
187
+ // ---------------------------------------------------------------------------
188
+ export async function seedTagMap(seedPath, targetPath) {
189
+ try {
190
+ await fs.access(targetPath);
191
+ return false; // Already exists.
192
+ }
193
+ catch {
194
+ // Doesn't exist yet; seed it.
195
+ }
196
+ try {
197
+ const dir = path.dirname(targetPath);
198
+ await fs.mkdir(dir, { recursive: true });
199
+ await fs.copyFile(seedPath, targetPath);
200
+ return true;
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ }
@@ -0,0 +1,353 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { buildCronThreadName, stripCadencePrefix, formatStatusMessage, seedTagMap, ensureStatusMessage, resolveForumChannel } from './discord-sync.js';
6
+ describe('buildCronThreadName', () => {
7
+ it('prefixes with cadence emoji', () => {
8
+ expect(buildCronThreadName('Morning Report', 'daily')).toBe('\uD83C\uDF05 Morning Report');
9
+ });
10
+ it('uses frequent emoji', () => {
11
+ expect(buildCronThreadName('Health Check', 'frequent')).toBe('\u23F1\uFE0F Health Check');
12
+ });
13
+ it('omits emoji when cadence is null', () => {
14
+ expect(buildCronThreadName('Some Job', null)).toBe('Some Job');
15
+ });
16
+ it('truncates long names to 100 chars', () => {
17
+ const longName = 'x'.repeat(120);
18
+ const result = buildCronThreadName(longName, 'daily');
19
+ expect(result.length).toBeLessThanOrEqual(100);
20
+ expect(result).toContain('\u2026');
21
+ });
22
+ it('strips existing emoji prefix before prepending', () => {
23
+ expect(buildCronThreadName('\uD83C\uDF05 Morning Report', 'daily')).toBe('\uD83C\uDF05 Morning Report');
24
+ });
25
+ it('strips accumulated prefixes', () => {
26
+ expect(buildCronThreadName('\uD83C\uDF05 \uD83C\uDF05 \uD83C\uDF05 Morning Report', 'daily')).toBe('\uD83C\uDF05 Morning Report');
27
+ });
28
+ it('corrects wrong cadence emoji', () => {
29
+ expect(buildCronThreadName('\uD83D\uDD50 Morning Report', 'daily')).toBe('\uD83C\uDF05 Morning Report');
30
+ });
31
+ it('strips mixed accumulated prefixes', () => {
32
+ expect(buildCronThreadName('\uD83C\uDF05 \uD83D\uDD50 Morning Report', 'daily')).toBe('\uD83C\uDF05 Morning Report');
33
+ });
34
+ it('is idempotent', () => {
35
+ const once = buildCronThreadName('Report', 'daily');
36
+ const twice = buildCronThreadName(once, 'daily');
37
+ expect(twice).toBe(once);
38
+ });
39
+ it('handles null cadence with prefixed name', () => {
40
+ expect(buildCronThreadName('\uD83C\uDF05 Morning Report', null)).toBe('Morning Report');
41
+ });
42
+ it('handles emoji with VS16 from Discord normalization', () => {
43
+ expect(buildCronThreadName('\u23F1\uFE0F Health Check', 'frequent')).toBe('\u23F1\uFE0F Health Check');
44
+ });
45
+ it('handles emoji without VS16 from old data', () => {
46
+ expect(buildCronThreadName('\u23F1 Health Check', 'frequent')).toBe('\u23F1\uFE0F Health Check');
47
+ });
48
+ it('preserves VS16 in base name', () => {
49
+ expect(buildCronThreadName('\uD83C\uDF05 Test \u2600\uFE0F Dashboard', 'daily')).toBe('\uD83C\uDF05 Test \u2600\uFE0F Dashboard');
50
+ });
51
+ });
52
+ describe('stripCadencePrefix', () => {
53
+ it('strips single prefix', () => {
54
+ expect(stripCadencePrefix('\uD83C\uDF05 Test')).toBe('Test');
55
+ });
56
+ it('strips multiple accumulated', () => {
57
+ expect(stripCadencePrefix('\uD83C\uDF05 \uD83C\uDF05 \uD83C\uDF05 Test')).toBe('Test');
58
+ });
59
+ it('strips mixed cadence emojis', () => {
60
+ expect(stripCadencePrefix('\uD83D\uDD50 \uD83C\uDF05 Test')).toBe('Test');
61
+ });
62
+ it('no-op for clean name', () => {
63
+ expect(stripCadencePrefix('Test')).toBe('Test');
64
+ });
65
+ it('no-op for empty string', () => {
66
+ expect(stripCadencePrefix('')).toBe('');
67
+ });
68
+ it('preserves non-cadence emoji', () => {
69
+ expect(stripCadencePrefix('\u26A1 Test')).toBe('\u26A1 Test');
70
+ });
71
+ it('strips prefix with VS16 variation selector', () => {
72
+ expect(stripCadencePrefix('\u23F1\uFE0F Test')).toBe('Test');
73
+ });
74
+ it('strips accumulated prefixes with mixed VS16', () => {
75
+ expect(stripCadencePrefix('\u23F1\uFE0F \u23F1 Test')).toBe('Test');
76
+ });
77
+ it('preserves VS16 in base name', () => {
78
+ expect(stripCadencePrefix('\uD83C\uDF05 Test \u2600\uFE0F Dashboard')).toBe('Test \u2600\uFE0F Dashboard');
79
+ });
80
+ it('preserves VS16 in base name when prefix also has VS16', () => {
81
+ expect(stripCadencePrefix('\u23F1\uFE0F Test \u2600\uFE0F End')).toBe('Test \u2600\uFE0F End');
82
+ });
83
+ });
84
+ describe('formatStatusMessage', () => {
85
+ it('includes cronId token', () => {
86
+ const record = {
87
+ cronId: 'cron-abc12345',
88
+ threadId: 'thread-1',
89
+ runCount: 5,
90
+ lastRunAt: '2025-01-15T10:00:00Z',
91
+ lastRunStatus: 'success',
92
+ cadence: 'daily',
93
+ purposeTags: ['monitoring', 'cleanup'],
94
+ disabled: false,
95
+ model: 'haiku',
96
+ };
97
+ const msg = formatStatusMessage('cron-abc12345', record);
98
+ expect(msg).toContain('[cronId:cron-abc12345]');
99
+ expect(msg).toContain('Runs:** 5');
100
+ expect(msg).toContain('haiku');
101
+ expect(msg).toContain('daily');
102
+ expect(msg).toContain('monitoring, cleanup');
103
+ });
104
+ it('shows error details', () => {
105
+ const record = {
106
+ cronId: 'cron-err1',
107
+ threadId: 'thread-2',
108
+ runCount: 1,
109
+ lastRunAt: '2025-01-15T10:00:00Z',
110
+ lastRunStatus: 'error',
111
+ lastErrorMessage: 'timeout exceeded',
112
+ cadence: null,
113
+ purposeTags: [],
114
+ disabled: false,
115
+ model: null,
116
+ };
117
+ const msg = formatStatusMessage('cron-err1', record);
118
+ expect(msg).toContain('\u274C');
119
+ expect(msg).toContain('timeout exceeded');
120
+ });
121
+ it('shows model override when present', () => {
122
+ const record = {
123
+ cronId: 'cron-ovr',
124
+ threadId: 'thread-3',
125
+ runCount: 0,
126
+ lastRunAt: null,
127
+ lastRunStatus: null,
128
+ cadence: 'hourly',
129
+ purposeTags: [],
130
+ disabled: false,
131
+ model: 'haiku',
132
+ modelOverride: 'opus',
133
+ };
134
+ const msg = formatStatusMessage('cron-ovr', record);
135
+ expect(msg).toContain('opus');
136
+ });
137
+ it('includes "Currently running" when running is true', () => {
138
+ const record = {
139
+ cronId: 'cron-run1',
140
+ threadId: 'thread-1',
141
+ runCount: 3,
142
+ lastRunAt: '2025-01-15T10:00:00Z',
143
+ lastRunStatus: 'success',
144
+ cadence: 'daily',
145
+ purposeTags: [],
146
+ disabled: false,
147
+ model: 'haiku',
148
+ };
149
+ const msg = formatStatusMessage('cron-run1', record, true);
150
+ expect(msg).toContain('Currently running');
151
+ });
152
+ it('does not include "Currently running" when running is false', () => {
153
+ const record = {
154
+ cronId: 'cron-run2',
155
+ threadId: 'thread-1',
156
+ runCount: 3,
157
+ lastRunAt: '2025-01-15T10:00:00Z',
158
+ lastRunStatus: 'success',
159
+ cadence: 'daily',
160
+ purposeTags: [],
161
+ disabled: false,
162
+ model: 'haiku',
163
+ };
164
+ const msg = formatStatusMessage('cron-run2', record, false);
165
+ expect(msg).not.toContain('Currently running');
166
+ });
167
+ it('shows N/A when no model or cadence', () => {
168
+ const record = {
169
+ cronId: 'cron-na',
170
+ threadId: 'thread-4',
171
+ runCount: 0,
172
+ lastRunAt: null,
173
+ lastRunStatus: null,
174
+ cadence: null,
175
+ purposeTags: [],
176
+ disabled: false,
177
+ model: null,
178
+ };
179
+ const msg = formatStatusMessage('cron-na', record);
180
+ expect(msg).toContain('N/A');
181
+ });
182
+ });
183
+ describe('seedTagMap', () => {
184
+ let tmpDir;
185
+ beforeEach(async () => {
186
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cron-seed-'));
187
+ });
188
+ afterEach(async () => {
189
+ await fs.rm(tmpDir, { recursive: true, force: true });
190
+ });
191
+ it('copies seed file when target does not exist', async () => {
192
+ const seedPath = path.join(tmpDir, 'seed.json');
193
+ const targetPath = path.join(tmpDir, 'sub', 'target.json');
194
+ await fs.writeFile(seedPath, '{"test": ""}', 'utf8');
195
+ const seeded = await seedTagMap(seedPath, targetPath);
196
+ expect(seeded).toBe(true);
197
+ const content = await fs.readFile(targetPath, 'utf8');
198
+ expect(content).toBe('{"test": ""}');
199
+ });
200
+ it('does not overwrite existing target', async () => {
201
+ const seedPath = path.join(tmpDir, 'seed.json');
202
+ const targetPath = path.join(tmpDir, 'target.json');
203
+ await fs.writeFile(seedPath, '{"new": ""}', 'utf8');
204
+ await fs.writeFile(targetPath, '{"existing": "123"}', 'utf8');
205
+ const seeded = await seedTagMap(seedPath, targetPath);
206
+ expect(seeded).toBe(false);
207
+ const content = await fs.readFile(targetPath, 'utf8');
208
+ expect(content).toBe('{"existing": "123"}');
209
+ });
210
+ });
211
+ describe('ensureStatusMessage', () => {
212
+ function makeRecord(overrides) {
213
+ return {
214
+ cronId: 'cron-test1',
215
+ threadId: 'thread-1',
216
+ runCount: 3,
217
+ lastRunAt: '2025-01-15T10:00:00Z',
218
+ lastRunStatus: 'success',
219
+ cadence: 'daily',
220
+ purposeTags: ['monitoring'],
221
+ disabled: false,
222
+ model: 'haiku',
223
+ ...overrides,
224
+ };
225
+ }
226
+ function makeStats() {
227
+ return {
228
+ upsertRecord: vi.fn(async () => makeRecord()),
229
+ getRecord: vi.fn(() => makeRecord()),
230
+ };
231
+ }
232
+ it('creates a new status message when none exists', async () => {
233
+ const sentMsg = { id: 'new-msg-1', pin: vi.fn() };
234
+ const thread = {
235
+ isThread: () => true,
236
+ send: vi.fn(async () => sentMsg),
237
+ messages: { fetch: vi.fn() },
238
+ };
239
+ const client = {
240
+ channels: {
241
+ cache: { get: () => thread },
242
+ fetch: vi.fn(async () => thread),
243
+ },
244
+ };
245
+ const stats = makeStats();
246
+ const result = await ensureStatusMessage(client, 'thread-1', 'cron-test1', makeRecord(), stats);
247
+ expect(result).toBe('new-msg-1');
248
+ expect(thread.send).toHaveBeenCalled();
249
+ expect(sentMsg.pin).toHaveBeenCalled();
250
+ expect(stats.upsertRecord).toHaveBeenCalledWith('cron-test1', 'thread-1', { statusMessageId: 'new-msg-1' });
251
+ });
252
+ it('edits existing status message', async () => {
253
+ const existingMsg = { id: 'existing-msg', edit: vi.fn() };
254
+ const thread = {
255
+ isThread: () => true,
256
+ send: vi.fn(),
257
+ messages: { fetch: vi.fn(async () => existingMsg) },
258
+ };
259
+ const client = {
260
+ channels: {
261
+ cache: { get: () => thread },
262
+ fetch: vi.fn(async () => thread),
263
+ },
264
+ };
265
+ const record = makeRecord({ statusMessageId: 'existing-msg' });
266
+ const result = await ensureStatusMessage(client, 'thread-1', 'cron-test1', record, makeStats());
267
+ expect(result).toBe('existing-msg');
268
+ expect(existingMsg.edit).toHaveBeenCalled();
269
+ expect(thread.send).not.toHaveBeenCalled();
270
+ });
271
+ it('returns undefined when thread not found', async () => {
272
+ const client = {
273
+ channels: {
274
+ cache: { get: () => undefined },
275
+ fetch: vi.fn(async () => null),
276
+ },
277
+ };
278
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
279
+ const result = await ensureStatusMessage(client, 'missing', 'cron-test1', makeRecord(), makeStats(), { log });
280
+ expect(result).toBeUndefined();
281
+ expect(log.warn).toHaveBeenCalled();
282
+ });
283
+ it('passes running flag through to formatted content', async () => {
284
+ const sentMsg = { id: 'run-msg-1', pin: vi.fn() };
285
+ const thread = {
286
+ isThread: () => true,
287
+ send: vi.fn(async () => sentMsg),
288
+ messages: { fetch: vi.fn() },
289
+ };
290
+ const client = {
291
+ channels: {
292
+ cache: { get: () => thread },
293
+ fetch: vi.fn(async () => thread),
294
+ },
295
+ };
296
+ const stats = makeStats();
297
+ await ensureStatusMessage(client, 'thread-1', 'cron-test1', makeRecord(), stats, { running: true });
298
+ expect(thread.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('Currently running') }));
299
+ });
300
+ it('creates new message when existing statusMessageId is stale', async () => {
301
+ const sentMsg = { id: 'new-msg-2', pin: vi.fn() };
302
+ const thread = {
303
+ isThread: () => true,
304
+ send: vi.fn(async () => sentMsg),
305
+ messages: { fetch: vi.fn(async () => { throw new Error('Unknown Message'); }) },
306
+ };
307
+ const client = {
308
+ channels: {
309
+ cache: { get: () => thread },
310
+ fetch: vi.fn(async () => thread),
311
+ },
312
+ };
313
+ const record = makeRecord({ statusMessageId: 'deleted-msg' });
314
+ const stats = makeStats();
315
+ const result = await ensureStatusMessage(client, 'thread-1', 'cron-test1', record, stats);
316
+ expect(result).toBe('new-msg-2');
317
+ expect(thread.send).toHaveBeenCalled();
318
+ });
319
+ });
320
+ describe('resolveForumChannel', () => {
321
+ it('returns forum from cache', async () => {
322
+ const forum = { id: 'forum-1', type: 15 };
323
+ const client = {
324
+ channels: {
325
+ cache: { get: (id) => id === 'forum-1' ? forum : undefined },
326
+ fetch: vi.fn(),
327
+ },
328
+ };
329
+ const result = await resolveForumChannel(client, 'forum-1');
330
+ expect(result).toBe(forum);
331
+ });
332
+ it('returns null for non-forum channel', async () => {
333
+ const textChannel = { id: 'text-1', type: 0 };
334
+ const client = {
335
+ channels: {
336
+ cache: { get: () => textChannel },
337
+ fetch: vi.fn(async () => textChannel),
338
+ },
339
+ };
340
+ const result = await resolveForumChannel(client, 'text-1');
341
+ expect(result).toBeNull();
342
+ });
343
+ it('returns null when channel not found', async () => {
344
+ const client = {
345
+ channels: {
346
+ cache: { get: () => undefined },
347
+ fetch: vi.fn(async () => null),
348
+ },
349
+ };
350
+ const result = await resolveForumChannel(client, 'missing');
351
+ expect(result).toBeNull();
352
+ });
353
+ });