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,522 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildDisplayResultLines, buildAllResultLines } from './actions.js';
4
+ import { TaskStore } from '../tasks/store.js';
5
+ import { _resetDestructiveConfirmationForTest } from './destructive-confirmation.js';
6
+ const ALL_FLAGS = {
7
+ channels: true,
8
+ messaging: false,
9
+ guild: false,
10
+ moderation: false,
11
+ polls: false,
12
+ tasks: false,
13
+ crons: false,
14
+ botProfile: false,
15
+ forge: false,
16
+ plan: false,
17
+ memory: false,
18
+ config: false,
19
+ defer: true,
20
+ };
21
+ // ---------------------------------------------------------------------------
22
+ // parseDiscordActions
23
+ // ---------------------------------------------------------------------------
24
+ describe('parseDiscordActions', () => {
25
+ beforeEach(() => {
26
+ _resetDestructiveConfirmationForTest();
27
+ });
28
+ it('extracts a single action and strips it from text', () => {
29
+ const input = 'Here is the list:\n<discord-action>{"type":"channelList"}</discord-action>\nDone.';
30
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
31
+ expect(actions).toEqual([{ type: 'channelList' }]);
32
+ expect(cleanText).toBe('Here is the list:\n\nDone.');
33
+ });
34
+ it('extracts multiple actions', () => {
35
+ const input = '<discord-action>{"type":"channelCreate","name":"status","parent":"Dev"}</discord-action>' +
36
+ '<discord-action>{"type":"channelList"}</discord-action>';
37
+ const { actions } = parseDiscordActions(input, ALL_FLAGS);
38
+ expect(actions).toHaveLength(2);
39
+ expect(actions[0]).toEqual({ type: 'channelCreate', name: 'status', parent: 'Dev' });
40
+ expect(actions[1]).toEqual({ type: 'channelList' });
41
+ });
42
+ it('extracts defer actions when defer flag enabled', () => {
43
+ const input = '<discord-action>{"type":"defer","channel":"general","delaySeconds":300,"prompt":"check the forge"}</discord-action>';
44
+ const { actions } = parseDiscordActions(input, ALL_FLAGS);
45
+ expect(actions).toEqual([{ type: 'defer', channel: 'general', delaySeconds: 300, prompt: 'check the forge' }]);
46
+ });
47
+ it('skips malformed JSON gracefully', () => {
48
+ const input = '<discord-action>{bad json}</discord-action>Some text';
49
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
50
+ expect(actions).toHaveLength(0);
51
+ expect(cleanText).toBe('Some text');
52
+ });
53
+ it('skips unknown action types', () => {
54
+ const input = '<discord-action>{"type":"somethingWeird","id":"123"}</discord-action>';
55
+ const { actions } = parseDiscordActions(input, ALL_FLAGS);
56
+ expect(actions).toHaveLength(0);
57
+ });
58
+ it('skips disabled category action types', () => {
59
+ const input = '<discord-action>{"type":"channelCreate","name":"test"}</discord-action>';
60
+ const { actions } = parseDiscordActions(input, { ...ALL_FLAGS, channels: false });
61
+ expect(actions).toHaveLength(0);
62
+ });
63
+ it('skips defer actions when defer flag disabled', () => {
64
+ const input = '<discord-action>{"type":"defer","channel":"general","delaySeconds":60}</discord-action>';
65
+ const { actions } = parseDiscordActions(input, { ...ALL_FLAGS, defer: false });
66
+ expect(actions).toHaveLength(0);
67
+ });
68
+ it('returns original text when no actions present', () => {
69
+ const input = 'Just a normal message.';
70
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
71
+ expect(actions).toHaveLength(0);
72
+ expect(cleanText).toBe(input);
73
+ });
74
+ it('collapses blank lines left by multiple stripped action blocks', () => {
75
+ const block = '<discord-action>{"type":"channelList"}</discord-action>';
76
+ const input = `Here is the list:\n${block}\n${block}\n${block}\n${block}\n${block}\nDone.`;
77
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
78
+ expect(actions).toHaveLength(5);
79
+ expect(cleanText).not.toMatch(/\n{3,}/);
80
+ expect(cleanText).toBe('Here is the list:\n\nDone.');
81
+ });
82
+ it('strips malformed action blocks with wrong closing tags', () => {
83
+ const input = 'Here is the bead:\n<discord-action>{"type":"channelList"}</parameter>\n</invoke>';
84
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
85
+ expect(actions).toEqual([{ type: 'channelList' }]);
86
+ expect(cleanText).toBe('Here is the bead:');
87
+ });
88
+ it('strips malformed action blocks with no closing tag', () => {
89
+ const input = 'Done.\n<discord-action>{"type":"channelList"}';
90
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
91
+ expect(actions).toEqual([{ type: 'channelList' }]);
92
+ expect(cleanText).toBe('Done.');
93
+ });
94
+ it('strips malformed action blocks with complex JSON payloads', () => {
95
+ const json = '{"type":"channelCreate","name":"test","parent":"Dev","topic":"A topic"}';
96
+ const input = `Creating channel.\n<discord-action>${json}</parameter>\n</invoke>\nExtra text.`;
97
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
98
+ expect(actions).toEqual([{ type: 'channelCreate', name: 'test', parent: 'Dev', topic: 'A topic' }]);
99
+ expect(cleanText).toBe('Creating channel.\n\nExtra text.');
100
+ });
101
+ it('handles nested braces in JSON string values', () => {
102
+ const json = '{"type":"channelCreate","name":"test","topic":"Fix {braces} in output"}';
103
+ const input = `Text.\n<discord-action>${json}</parameter>\n</invoke>`;
104
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
105
+ expect(actions).toEqual([{ type: 'channelCreate', name: 'test', topic: 'Fix {braces} in output' }]);
106
+ expect(cleanText).toBe('Text.');
107
+ });
108
+ it('handles two malformed action blocks in one response', () => {
109
+ const input = 'First.\n<discord-action>{"type":"channelList"}</parameter>\n</invoke>\n' +
110
+ 'Second.\n<discord-action>{"type":"channelCreate","name":"x"}</parameter>';
111
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
112
+ expect(actions).toHaveLength(2);
113
+ expect(actions[0]).toEqual({ type: 'channelList' });
114
+ expect(actions[1]).toEqual({ type: 'channelCreate', name: 'x' });
115
+ expect(cleanText).toBe('First.\n\nSecond.');
116
+ });
117
+ it('preserves text after an unterminated malformed action block', () => {
118
+ const input = 'Before\n<discord-action>{"type":"channelList","x":"oops\nAfter text';
119
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
120
+ expect(actions).toHaveLength(0);
121
+ expect(cleanText).toBe('Before\n\nAfter text');
122
+ });
123
+ it('strips trailing XML tags after an unterminated malformed action block', () => {
124
+ const input = 'Before\n<discord-action>{"type":"channelList","x":"oops\n</parameter>\n</invoke>\nAfter';
125
+ const { cleanText, actions } = parseDiscordActions(input, ALL_FLAGS);
126
+ expect(actions).toHaveLength(0);
127
+ expect(cleanText).toBe('Before\n\nAfter');
128
+ });
129
+ it('returns empty strippedUnrecognizedTypes when all action types are recognized', () => {
130
+ const input = '<discord-action>{"type":"channelList"}</discord-action>';
131
+ const { strippedUnrecognizedTypes } = parseDiscordActions(input, ALL_FLAGS);
132
+ expect(strippedUnrecognizedTypes).toEqual([]);
133
+ });
134
+ it('collects unrecognized type names in strippedUnrecognizedTypes (first pass)', () => {
135
+ const input = '<discord-action>{"type":"somethingWeird","id":"123"}</discord-action>';
136
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, ALL_FLAGS);
137
+ expect(actions).toHaveLength(0);
138
+ expect(strippedUnrecognizedTypes).toEqual(['somethingWeird']);
139
+ });
140
+ it('collects flag-disabled type names in strippedUnrecognizedTypes (first pass)', () => {
141
+ const input = '<discord-action>{"type":"channelCreate","name":"test"}</discord-action>';
142
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, { ...ALL_FLAGS, channels: false });
143
+ expect(actions).toHaveLength(0);
144
+ expect(strippedUnrecognizedTypes).toEqual(['channelCreate']);
145
+ });
146
+ it('collects unrecognized type names in strippedUnrecognizedTypes (second pass / malformed block)', () => {
147
+ const input = '<discord-action>{"type":"unknownType","foo":"bar"}</parameter>\n</invoke>';
148
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, ALL_FLAGS);
149
+ expect(actions).toHaveLength(0);
150
+ expect(strippedUnrecognizedTypes).toEqual(['unknownType']);
151
+ });
152
+ it('collects multiple unrecognized types across both passes', () => {
153
+ const input = '<discord-action>{"type":"typeA"}</discord-action>' +
154
+ '<discord-action>{"type":"channelList"}</discord-action>' +
155
+ '<discord-action>{"type":"typeB"}</parameter>';
156
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, ALL_FLAGS);
157
+ expect(actions).toEqual([{ type: 'channelList' }]);
158
+ expect(strippedUnrecognizedTypes).toEqual(['typeA', 'typeB']);
159
+ });
160
+ it('accepts task action types when tasks flag is enabled', () => {
161
+ const input = '<discord-action>{"type":"taskList"}</discord-action>';
162
+ const { actions } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true });
163
+ expect(actions).toEqual([{ type: 'taskList' }]);
164
+ });
165
+ it('rewrites planClose to taskClose when plan actions are disabled and tasks are enabled', () => {
166
+ const input = '<discord-action>{"type":"planClose","planId":"dev-uqy"}</discord-action>';
167
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true, plan: false });
168
+ expect(actions).toEqual([{ type: 'taskClose', taskId: 'dev-uqy' }]);
169
+ expect(strippedUnrecognizedTypes).toEqual([]);
170
+ });
171
+ it('does not rewrite planClose when planId looks like a plan identifier', () => {
172
+ const input = '<discord-action>{"type":"planClose","planId":"plan-042"}</discord-action>';
173
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true, plan: false });
174
+ expect(actions).toEqual([]);
175
+ expect(strippedUnrecognizedTypes).toEqual(['planClose']);
176
+ });
177
+ it('does not rewrite planClose when plan actions are enabled', () => {
178
+ const input = '<discord-action>{"type":"planClose","planId":"dev-uqy"}</discord-action>';
179
+ const { actions } = parseDiscordActions(input, { ...ALL_FLAGS, tasks: true, plan: true });
180
+ expect(actions).toEqual([{ type: 'planClose', planId: 'dev-uqy' }]);
181
+ });
182
+ it('ignores action tags inside fenced code blocks', () => {
183
+ const input = 'Example only:\n```json\n<discord-action>{"type":"channelDelete","channelId":"ch1"}</discord-action>\n```\nNo action.';
184
+ const { actions, cleanText } = parseDiscordActions(input, ALL_FLAGS);
185
+ expect(actions).toEqual([]);
186
+ expect(cleanText).toContain('<discord-action>');
187
+ });
188
+ it('ignores action tags inside inline code spans', () => {
189
+ const input = 'Do not run `<discord-action>{"type":"channelDelete","channelId":"ch1"}</discord-action>` please.';
190
+ const { actions, cleanText } = parseDiscordActions(input, ALL_FLAGS);
191
+ expect(actions).toEqual([]);
192
+ expect(cleanText).toContain('<discord-action>');
193
+ });
194
+ it('ignores action tags inside indented code blocks', () => {
195
+ const input = 'Example only:\n' +
196
+ ' <discord-action>{"type":"channelDelete","channelId":"ch1"}</discord-action>\n' +
197
+ 'No action.';
198
+ const { actions, cleanText } = parseDiscordActions(input, ALL_FLAGS);
199
+ expect(actions).toEqual([]);
200
+ expect(cleanText).toContain('<discord-action>');
201
+ });
202
+ it('parses forgeStatus when forge flag is enabled', () => {
203
+ const input = '<discord-action>{"type":"forgeStatus","forgeId":"forge-abc"}</discord-action>';
204
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, { ...ALL_FLAGS, forge: true });
205
+ expect(actions).toEqual([{ type: 'forgeStatus', forgeId: 'forge-abc' }]);
206
+ expect(strippedUnrecognizedTypes).toEqual([]);
207
+ });
208
+ it('strips forgeStatus when forge flag is disabled', () => {
209
+ const input = '<discord-action>{"type":"forgeStatus","forgeId":"forge-abc"}</discord-action>';
210
+ const { actions, strippedUnrecognizedTypes } = parseDiscordActions(input, { ...ALL_FLAGS, forge: false });
211
+ expect(actions).toHaveLength(0);
212
+ expect(strippedUnrecognizedTypes).toEqual(['forgeStatus']);
213
+ });
214
+ });
215
+ // ---------------------------------------------------------------------------
216
+ // executeDiscordActions — mocked guild
217
+ // ---------------------------------------------------------------------------
218
+ function makeMockGuild(channels) {
219
+ const cache = new Map();
220
+ for (const ch of channels) {
221
+ cache.set(ch.id, {
222
+ id: ch.id,
223
+ name: ch.name,
224
+ type: ch.type,
225
+ parent: ch.parentName ? { name: ch.parentName } : null,
226
+ });
227
+ }
228
+ return {
229
+ channels: {
230
+ cache: {
231
+ find: (fn) => {
232
+ for (const ch of cache.values()) {
233
+ if (fn(ch))
234
+ return ch;
235
+ }
236
+ return undefined;
237
+ },
238
+ values: () => cache.values(),
239
+ get size() { return cache.size; },
240
+ },
241
+ create: vi.fn(async (opts) => ({
242
+ name: opts.name,
243
+ id: 'new-id',
244
+ })),
245
+ },
246
+ };
247
+ }
248
+ function makeCtx(guild) {
249
+ return {
250
+ guild,
251
+ client: {},
252
+ channelId: 'test-channel',
253
+ messageId: 'test-message',
254
+ };
255
+ }
256
+ describe('executeDiscordActions', () => {
257
+ beforeEach(() => {
258
+ _resetDestructiveConfirmationForTest();
259
+ });
260
+ it('channelCreate succeeds with parent category', async () => {
261
+ const guild = makeMockGuild([
262
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
263
+ ]);
264
+ const results = await executeDiscordActions([{ type: 'channelCreate', name: 'status', parent: 'Dev', topic: 'Status updates' }], makeCtx(guild));
265
+ expect(results).toHaveLength(1);
266
+ expect(results[0]).toEqual({ ok: true, summary: 'Created #status under Dev' });
267
+ expect(guild.channels.create).toHaveBeenCalledWith({
268
+ name: 'status',
269
+ type: ChannelType.GuildText,
270
+ parent: 'cat1',
271
+ topic: 'Status updates',
272
+ });
273
+ });
274
+ it('channelCreate fails when parent category not found', async () => {
275
+ const guild = makeMockGuild([]);
276
+ const results = await executeDiscordActions([{ type: 'channelCreate', name: 'status', parent: 'NonExistent' }], makeCtx(guild));
277
+ expect(results).toHaveLength(1);
278
+ expect(results[0]).toEqual({ ok: false, error: 'Category "NonExistent" not found' });
279
+ });
280
+ it('channelCreate without parent', async () => {
281
+ const guild = makeMockGuild([]);
282
+ const results = await executeDiscordActions([{ type: 'channelCreate', name: 'general' }], makeCtx(guild));
283
+ expect(results).toHaveLength(1);
284
+ expect(results[0]).toEqual({ ok: true, summary: 'Created #general' });
285
+ expect(guild.channels.create).toHaveBeenCalledWith({
286
+ name: 'general',
287
+ type: ChannelType.GuildText,
288
+ parent: undefined,
289
+ topic: undefined,
290
+ });
291
+ });
292
+ it('channelList groups by category', async () => {
293
+ const guild = makeMockGuild([
294
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
295
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
296
+ { id: 'ch2', name: 'random', type: ChannelType.GuildText },
297
+ ]);
298
+ const results = await executeDiscordActions([{ type: 'channelList' }], makeCtx(guild));
299
+ expect(results).toHaveLength(1);
300
+ expect(results[0].ok).toBe(true);
301
+ const summary = results[0].summary;
302
+ expect(summary).toContain('#random (id:ch2)');
303
+ expect(summary).toContain('Dev: #general (id:ch1)');
304
+ });
305
+ it('handles API errors gracefully', async () => {
306
+ const guild = makeMockGuild([]);
307
+ guild.channels.create = vi.fn(async () => {
308
+ throw new Error('Missing Permissions');
309
+ });
310
+ const results = await executeDiscordActions([{ type: 'channelCreate', name: 'test' }], makeCtx(guild));
311
+ expect(results).toHaveLength(1);
312
+ expect(results[0]).toEqual({ ok: false, error: 'Missing Permissions' });
313
+ });
314
+ it('one failure does not block subsequent actions', async () => {
315
+ const guild = makeMockGuild([
316
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
317
+ ]);
318
+ guild.channels.create = vi.fn(async () => {
319
+ throw new Error('Missing Permissions');
320
+ });
321
+ const results = await executeDiscordActions([
322
+ { type: 'channelCreate', name: 'test' },
323
+ { type: 'channelList' },
324
+ ], makeCtx(guild));
325
+ expect(results).toHaveLength(2);
326
+ expect(results[0].ok).toBe(false);
327
+ expect(results[1].ok).toBe(true);
328
+ });
329
+ it('executes task actions when taskCtx is provided', async () => {
330
+ const store = new TaskStore({ prefix: 'ws' });
331
+ store.create({ title: 'test task', priority: 2 });
332
+ const results = await executeDiscordActions([{ type: 'taskList', limit: 10 }], makeCtx(makeMockGuild([])), undefined, {
333
+ taskCtx: {
334
+ tasksCwd: '/tmp',
335
+ forumId: 'forum-1',
336
+ tagMap: {},
337
+ store,
338
+ runtime: {},
339
+ autoTag: false,
340
+ autoTagModel: 'fast',
341
+ },
342
+ });
343
+ expect(results).toHaveLength(1);
344
+ expect(results[0].ok).toBe(true);
345
+ });
346
+ it('blocks destructive actions without explicit confirmation bypass', async () => {
347
+ const guild = makeMockGuild([]);
348
+ const results = await executeDiscordActions([{ type: 'channelDelete', channelId: 'ch1' }], {
349
+ ...makeCtx(guild),
350
+ confirmation: {
351
+ mode: 'interactive',
352
+ sessionKey: 'discord:channel:chan',
353
+ userId: '123',
354
+ },
355
+ });
356
+ expect(results).toHaveLength(1);
357
+ expect(results[0].ok).toBe(false);
358
+ if (results[0].ok)
359
+ throw new Error('unexpected ok result');
360
+ expect(results[0].error).toContain('requires confirmation');
361
+ expect(results[0].error).toContain('!confirm');
362
+ });
363
+ it('allows destructive actions with bypassDestructive confirmation context', async () => {
364
+ const ban = vi.fn(async () => { });
365
+ const guild = {
366
+ members: {
367
+ fetch: vi.fn(async () => ({ displayName: 'User42', ban })),
368
+ },
369
+ };
370
+ const results = await executeDiscordActions([{ type: 'ban', userId: '42' }], {
371
+ guild,
372
+ client: {},
373
+ channelId: 'chan',
374
+ messageId: 'msg',
375
+ confirmation: {
376
+ mode: 'interactive',
377
+ sessionKey: 'discord:channel:chan',
378
+ userId: '123',
379
+ bypassDestructive: true,
380
+ },
381
+ });
382
+ expect(results).toEqual([{ ok: true, summary: 'Banned User42' }]);
383
+ expect(guild.members.fetch).toHaveBeenCalledWith('42');
384
+ expect(ban).toHaveBeenCalledOnce();
385
+ });
386
+ });
387
+ // ---------------------------------------------------------------------------
388
+ // buildDisplayResultLines / buildAllResultLines
389
+ // ---------------------------------------------------------------------------
390
+ describe('buildDisplayResultLines', () => {
391
+ it('filters successful sendMessage results', () => {
392
+ const actions = [{ type: 'sendMessage' }, { type: 'channelCreate' }];
393
+ const results = [
394
+ { ok: true, summary: 'Sent message to #general' },
395
+ { ok: true, summary: 'Created #status' },
396
+ ];
397
+ const lines = buildDisplayResultLines(actions, results);
398
+ expect(lines).toEqual(['Done: Created #status']);
399
+ });
400
+ it('keeps failed sendMessage results', () => {
401
+ const actions = [{ type: 'sendMessage' }];
402
+ const results = [
403
+ { ok: false, error: 'Missing Permissions' },
404
+ ];
405
+ const lines = buildDisplayResultLines(actions, results);
406
+ expect(lines).toEqual(['Failed: Missing Permissions']);
407
+ });
408
+ it('keeps non-sendMessage successes', () => {
409
+ const actions = [{ type: 'channelCreate' }, { type: 'react' }, { type: 'channelList' }];
410
+ const results = [
411
+ { ok: true, summary: 'Created #foo' },
412
+ { ok: true, summary: 'Reacted with ✅' },
413
+ { ok: true, summary: 'Listed 3 channels' },
414
+ ];
415
+ const lines = buildDisplayResultLines(actions, results);
416
+ expect(lines).toEqual([
417
+ 'Done: Created #foo',
418
+ 'Done: Reacted with ✅',
419
+ 'Done: Listed 3 channels',
420
+ ]);
421
+ });
422
+ it('returns empty array when all actions are successful sendMessage', () => {
423
+ const actions = [{ type: 'sendMessage' }, { type: 'sendMessage' }];
424
+ const results = [
425
+ { ok: true, summary: 'Sent message to #general' },
426
+ { ok: true, summary: 'Sent message to #random' },
427
+ ];
428
+ const lines = buildDisplayResultLines(actions, results);
429
+ expect(lines).toEqual([]);
430
+ });
431
+ it('handles mixed actions correctly', () => {
432
+ const actions = [{ type: 'sendMessage' }, { type: 'channelCreate' }, { type: 'sendMessage' }];
433
+ const results = [
434
+ { ok: true, summary: 'Sent message to #general' },
435
+ { ok: true, summary: 'Created #status' },
436
+ { ok: false, error: 'Channel not found' },
437
+ ];
438
+ const lines = buildDisplayResultLines(actions, results);
439
+ expect(lines).toEqual([
440
+ 'Done: Created #status',
441
+ 'Failed: Channel not found',
442
+ ]);
443
+ });
444
+ it('filters successful sendFile results', () => {
445
+ const actions = [{ type: 'sendFile' }, { type: 'react' }];
446
+ const results = [
447
+ { ok: true, summary: 'Sent file "screenshot.png" to #general' },
448
+ { ok: true, summary: 'Reacted with 👍' },
449
+ ];
450
+ const lines = buildDisplayResultLines(actions, results);
451
+ expect(lines).toEqual(['Done: Reacted with 👍']);
452
+ });
453
+ it('keeps failed sendFile results', () => {
454
+ const actions = [{ type: 'sendFile' }];
455
+ const results = [
456
+ { ok: false, error: 'File not found: /tmp/missing.png' },
457
+ ];
458
+ const lines = buildDisplayResultLines(actions, results);
459
+ expect(lines).toEqual(['Failed: File not found: /tmp/missing.png']);
460
+ });
461
+ });
462
+ describe('buildAllResultLines', () => {
463
+ it('returns all result lines without filtering', () => {
464
+ const results = [
465
+ { ok: true, summary: 'Sent message to #general' },
466
+ { ok: true, summary: 'Created #status' },
467
+ { ok: false, error: 'Missing Permissions' },
468
+ ];
469
+ const lines = buildAllResultLines(results);
470
+ expect(lines).toEqual([
471
+ 'Done: Sent message to #general',
472
+ 'Done: Created #status',
473
+ 'Failed: Missing Permissions',
474
+ ]);
475
+ });
476
+ });
477
+ describe('discordActionsPromptSection', () => {
478
+ it('always includes the standard guidance when actions are enabled', () => {
479
+ const flags = {
480
+ channels: false,
481
+ messaging: false,
482
+ guild: false,
483
+ moderation: false,
484
+ polls: false,
485
+ tasks: false,
486
+ crons: false,
487
+ botProfile: false,
488
+ forge: false,
489
+ plan: false,
490
+ memory: false,
491
+ config: false,
492
+ defer: false,
493
+ };
494
+ const prompt = discordActionsPromptSection(flags, 'ClawBot');
495
+ expect(prompt).toContain('Setting DISCOCLAW_DISCORD_ACTIONS=1 publishes this standard guidance');
496
+ expect(prompt).toContain('### Rules');
497
+ });
498
+ it('documents deferred self-invocation when defer actions are enabled', () => {
499
+ const flags = {
500
+ channels: false,
501
+ messaging: false,
502
+ guild: false,
503
+ moderation: false,
504
+ polls: false,
505
+ tasks: false,
506
+ crons: false,
507
+ botProfile: false,
508
+ forge: false,
509
+ plan: false,
510
+ memory: false,
511
+ config: false,
512
+ defer: true,
513
+ };
514
+ const prompt = discordActionsPromptSection(flags);
515
+ expect(prompt).toContain('### Deferred self-invocation');
516
+ expect(prompt).toContain('{"type":"defer","channel":"general","delaySeconds":600,"prompt":"Check on the forge run"}');
517
+ expect(prompt).toContain('without another user prompt');
518
+ expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS');
519
+ expect(prompt).toContain('DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT');
520
+ expect(prompt).toContain('forces `defer` off');
521
+ });
522
+ });
@@ -0,0 +1,3 @@
1
+ // Centralize mention policy: Discoclaw should not ping users/roles/@everyone from model output.
2
+ // If you need targeted mentions later, plumb an explicit allowlist into the send call.
3
+ export const NO_MENTIONS = { parse: [] };
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { NO_MENTIONS } from './allowed-mentions.js';
3
+ describe('NO_MENTIONS', () => {
4
+ it('has an empty parse array', () => {
5
+ expect(NO_MENTIONS.parse).toEqual([]);
6
+ });
7
+ it('is assignable to Discord.js MessageMentionOptions', () => {
8
+ // Compile-time check: if NO_MENTIONS breaks compat with discord.js, this fails to build.
9
+ const opts = NO_MENTIONS;
10
+ expect(opts.parse).toEqual([]);
11
+ });
12
+ it('is assignable to SendTarget allowedMentions', () => {
13
+ // Compile-time check: if NO_MENTIONS breaks compat with SendTarget, this fails to build.
14
+ const opts = NO_MENTIONS;
15
+ expect(opts.parse).toEqual([]);
16
+ });
17
+ });
@@ -0,0 +1,29 @@
1
+ export function parseAllowUserIds(raw) {
2
+ const out = new Set();
3
+ for (const part of String(raw ?? '').split(/[,\s]+/g)) {
4
+ const v = part.trim();
5
+ if (!v)
6
+ continue;
7
+ if (/^\d+$/.test(v))
8
+ out.add(v);
9
+ }
10
+ return out;
11
+ }
12
+ export function parseAllowChannelIds(raw) {
13
+ // Same format as user IDs: comma/space-separated Discord snowflakes.
14
+ const out = new Set();
15
+ for (const part of String(raw ?? '').split(/[,\s]+/g)) {
16
+ const v = part.trim();
17
+ if (!v)
18
+ continue;
19
+ if (/^\d+$/.test(v))
20
+ out.add(v);
21
+ }
22
+ return out;
23
+ }
24
+ export function isAllowlisted(allow, userId) {
25
+ // Fail closed: if allowlist is empty, respond to nobody.
26
+ if (allow.size === 0)
27
+ return false;
28
+ return allow.has(userId);
29
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isAllowlisted, parseAllowChannelIds, parseAllowUserIds } from './allowlist.js';
3
+ describe('parseAllowUserIds', () => {
4
+ it('parses comma/space separated IDs', () => {
5
+ expect(parseAllowUserIds(' 123, 456 789 ')).toEqual(new Set(['123', '456', '789']));
6
+ });
7
+ it('drops non-numeric tokens', () => {
8
+ expect(parseAllowUserIds('abc 123 def')).toEqual(new Set(['123']));
9
+ });
10
+ });
11
+ describe('parseAllowChannelIds', () => {
12
+ it('parses comma/space separated IDs', () => {
13
+ expect(parseAllowChannelIds(' 1,2 3 ')).toEqual(new Set(['1', '2', '3']));
14
+ });
15
+ });
16
+ describe('isAllowlisted', () => {
17
+ it('fails closed when the allowlist is empty', () => {
18
+ expect(isAllowlisted(new Set(), '123')).toBe(false);
19
+ });
20
+ it('allows when userId is present', () => {
21
+ expect(isAllowlisted(new Set(['123']), '123')).toBe(true);
22
+ expect(isAllowlisted(new Set(['123']), '456')).toBe(false);
23
+ });
24
+ });