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,610 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { PLAN_ACTION_TYPES, executePlanAction, planActionsPromptSection } from './actions-plan.js';
3
+ import { TaskStore } from '../tasks/store.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks
6
+ // ---------------------------------------------------------------------------
7
+ vi.mock('./plan-commands.js', () => ({
8
+ resolvePlanHeaderTaskId: vi.fn((header) => header.taskId?.trim() || ''),
9
+ findPlanFile: vi.fn(async (_dir, id) => {
10
+ if (id === 'plan-notfound')
11
+ return null;
12
+ if (id === 'plan-implementing') {
13
+ return {
14
+ filePath: `/tmp/plans/${id}-test.md`,
15
+ header: { planId: id, taskId: 'ws-010', status: 'IMPLEMENTING', title: 'Active Plan', project: 'discoclaw', created: '2026-01-01' },
16
+ };
17
+ }
18
+ return {
19
+ filePath: `/tmp/plans/${id}-test.md`,
20
+ header: { planId: id, taskId: 'ws-001', status: 'REVIEW', title: 'Test Plan', project: 'discoclaw', created: '2026-01-01' },
21
+ };
22
+ }),
23
+ listPlanFiles: vi.fn(async () => [
24
+ {
25
+ filePath: '/tmp/plans/plan-001-test.md',
26
+ header: { planId: 'plan-001', taskId: 'ws-001', status: 'DRAFT', title: 'First Plan', project: 'discoclaw', created: '2026-01-01' },
27
+ },
28
+ {
29
+ filePath: '/tmp/plans/plan-002-test.md',
30
+ header: { planId: 'plan-002', taskId: 'ws-002', status: 'APPROVED', title: 'Second Plan', project: 'discoclaw', created: '2026-01-02' },
31
+ },
32
+ ]),
33
+ updatePlanFileStatus: vi.fn(async () => { }),
34
+ handlePlanCommand: vi.fn(async (_cmd, _opts) => {
35
+ return 'Plan created: **plan-003** (task: `ws-003`)\nFile: `workspace/plans/plan-003-test.md`\nDescription: New feature';
36
+ }),
37
+ preparePlanRun: vi.fn(async (_id, _opts) => ({
38
+ phasesFilePath: '/tmp/plans/plan-042-phases.md',
39
+ planFilePath: '/tmp/plans/plan-042-test.md',
40
+ planContent: '---\nproject: discoclaw\n---\n# Plan',
41
+ nextPhase: { id: 'phase-1', title: 'First phase', kind: 'implement', status: 'pending', deps: [], contextFiles: [] },
42
+ })),
43
+ NO_PHASES_SENTINEL: 'NO_PHASES',
44
+ closePlanIfComplete: vi.fn(async () => ({ closed: false, reason: 'not_all_complete' })),
45
+ }));
46
+ vi.mock('./plan-manager.js', () => ({
47
+ runNextPhase: vi.fn(async () => ({ result: 'nothing_to_run' })),
48
+ resolveProjectCwd: vi.fn((_content, workspaceCwd) => workspaceCwd),
49
+ readPhasesFile: vi.fn(() => ({ phases: [] })),
50
+ buildPostRunSummary: vi.fn(() => ''),
51
+ }));
52
+ vi.mock('./forge-plan-registry.js', () => ({
53
+ acquireWriterLock: vi.fn(async () => vi.fn()),
54
+ addRunningPlan: vi.fn(),
55
+ removeRunningPlan: vi.fn(),
56
+ isPlanRunning: vi.fn(() => false),
57
+ }));
58
+ vi.mock('./allowed-mentions.js', () => ({
59
+ NO_MENTIONS: { parse: [] },
60
+ }));
61
+ // ---------------------------------------------------------------------------
62
+ // Helpers
63
+ // ---------------------------------------------------------------------------
64
+ function makeStatusMessage() {
65
+ return { edit: vi.fn(async (_opts) => { }) };
66
+ }
67
+ function makeSendFn(statusMsg) {
68
+ const msg = statusMsg ?? makeStatusMessage();
69
+ return { fn: vi.fn(async (_payload) => msg), msg };
70
+ }
71
+ function makeCtx(sendSetup) {
72
+ const setup = sendSetup ?? makeSendFn();
73
+ return {
74
+ guild: {},
75
+ client: {
76
+ channels: {
77
+ fetch: vi.fn(async () => ({ send: setup.fn })),
78
+ },
79
+ },
80
+ channelId: 'test-channel',
81
+ messageId: 'test-message',
82
+ statusMsg: setup.msg,
83
+ };
84
+ }
85
+ function makePlanCtx(overrides) {
86
+ return {
87
+ plansDir: '/tmp/plans',
88
+ workspaceCwd: '/tmp/workspace',
89
+ taskStore: new TaskStore(),
90
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
91
+ ...overrides,
92
+ };
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Tests
96
+ // ---------------------------------------------------------------------------
97
+ describe('PLAN_ACTION_TYPES', () => {
98
+ it('contains all plan action types', () => {
99
+ expect(PLAN_ACTION_TYPES.has('planList')).toBe(true);
100
+ expect(PLAN_ACTION_TYPES.has('planShow')).toBe(true);
101
+ expect(PLAN_ACTION_TYPES.has('planApprove')).toBe(true);
102
+ expect(PLAN_ACTION_TYPES.has('planClose')).toBe(true);
103
+ expect(PLAN_ACTION_TYPES.has('planCreate')).toBe(true);
104
+ expect(PLAN_ACTION_TYPES.has('planRun')).toBe(true);
105
+ });
106
+ it('does not contain non-plan types', () => {
107
+ expect(PLAN_ACTION_TYPES.has('forgeCreate')).toBe(false);
108
+ expect(PLAN_ACTION_TYPES.has('beadCreate')).toBe(false);
109
+ });
110
+ });
111
+ describe('executePlanAction', () => {
112
+ beforeEach(() => {
113
+ vi.clearAllMocks();
114
+ });
115
+ describe('planList', () => {
116
+ it('lists all plans', async () => {
117
+ const result = await executePlanAction({ type: 'planList' }, makeCtx(), makePlanCtx());
118
+ expect(result.ok).toBe(true);
119
+ if (result.ok) {
120
+ expect(result.summary).toContain('plan-001');
121
+ expect(result.summary).toContain('plan-002');
122
+ expect(result.summary).toContain('First Plan');
123
+ expect(result.summary).toContain('Second Plan');
124
+ }
125
+ });
126
+ it('filters by status', async () => {
127
+ const result = await executePlanAction({ type: 'planList', status: 'APPROVED' }, makeCtx(), makePlanCtx());
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.summary).toContain('plan-002');
131
+ expect(result.summary).not.toContain('plan-001');
132
+ }
133
+ });
134
+ it('returns message when no plans match status filter', async () => {
135
+ const result = await executePlanAction({ type: 'planList', status: 'IMPLEMENTING' }, makeCtx(), makePlanCtx());
136
+ expect(result.ok).toBe(true);
137
+ if (result.ok) {
138
+ expect(result.summary).toContain('No plans with status');
139
+ }
140
+ });
141
+ it('returns message when no plans exist', async () => {
142
+ const { listPlanFiles } = await import('./plan-commands.js');
143
+ listPlanFiles.mockResolvedValueOnce([]);
144
+ const result = await executePlanAction({ type: 'planList' }, makeCtx(), makePlanCtx());
145
+ expect(result.ok).toBe(true);
146
+ if (result.ok) {
147
+ expect(result.summary).toContain('No plans found');
148
+ }
149
+ });
150
+ });
151
+ describe('planShow', () => {
152
+ it('shows plan details', async () => {
153
+ const result = await executePlanAction({ type: 'planShow', planId: 'plan-042' }, makeCtx(), makePlanCtx());
154
+ expect(result.ok).toBe(true);
155
+ if (result.ok) {
156
+ expect(result.summary).toContain('plan-042');
157
+ expect(result.summary).toContain('Test Plan');
158
+ expect(result.summary).toContain('REVIEW');
159
+ }
160
+ });
161
+ it('fails without planId', async () => {
162
+ const result = await executePlanAction({ type: 'planShow', planId: '' }, makeCtx(), makePlanCtx());
163
+ expect(result.ok).toBe(false);
164
+ if (!result.ok)
165
+ expect(result.error).toContain('requires a planId');
166
+ });
167
+ it('fails when plan not found', async () => {
168
+ const result = await executePlanAction({ type: 'planShow', planId: 'plan-notfound' }, makeCtx(), makePlanCtx());
169
+ expect(result.ok).toBe(false);
170
+ if (!result.ok)
171
+ expect(result.error).toContain('Plan not found');
172
+ });
173
+ });
174
+ describe('planApprove', () => {
175
+ it('approves a plan', async () => {
176
+ const { updatePlanFileStatus } = await import('./plan-commands.js');
177
+ const result = await executePlanAction({ type: 'planApprove', planId: 'plan-042' }, makeCtx(), makePlanCtx());
178
+ expect(result.ok).toBe(true);
179
+ if (result.ok) {
180
+ expect(result.summary).toContain('approved');
181
+ expect(result.summary).toContain('plan-042');
182
+ }
183
+ expect(updatePlanFileStatus).toHaveBeenCalledWith('/tmp/plans/plan-042-test.md', 'APPROVED');
184
+ });
185
+ it('fails without planId', async () => {
186
+ const result = await executePlanAction({ type: 'planApprove', planId: '' }, makeCtx(), makePlanCtx());
187
+ expect(result.ok).toBe(false);
188
+ if (!result.ok)
189
+ expect(result.error).toContain('requires a planId');
190
+ });
191
+ it('fails when plan not found', async () => {
192
+ const result = await executePlanAction({ type: 'planApprove', planId: 'plan-notfound' }, makeCtx(), makePlanCtx());
193
+ expect(result.ok).toBe(false);
194
+ if (!result.ok)
195
+ expect(result.error).toContain('Plan not found');
196
+ });
197
+ it('rejects when plan is currently implementing', async () => {
198
+ const result = await executePlanAction({ type: 'planApprove', planId: 'plan-implementing' }, makeCtx(), makePlanCtx());
199
+ expect(result.ok).toBe(false);
200
+ if (!result.ok)
201
+ expect(result.error).toContain('currently being implemented');
202
+ });
203
+ });
204
+ describe('planClose', () => {
205
+ it('closes a plan', async () => {
206
+ const { updatePlanFileStatus } = await import('./plan-commands.js');
207
+ const result = await executePlanAction({ type: 'planClose', planId: 'plan-042' }, makeCtx(), makePlanCtx());
208
+ expect(result.ok).toBe(true);
209
+ if (result.ok) {
210
+ expect(result.summary).toContain('closed');
211
+ expect(result.summary).toContain('plan-042');
212
+ }
213
+ expect(updatePlanFileStatus).toHaveBeenCalledWith('/tmp/plans/plan-042-test.md', 'CLOSED');
214
+ });
215
+ it('fails without planId', async () => {
216
+ const result = await executePlanAction({ type: 'planClose', planId: '' }, makeCtx(), makePlanCtx());
217
+ expect(result.ok).toBe(false);
218
+ if (!result.ok)
219
+ expect(result.error).toContain('requires a planId');
220
+ });
221
+ it('fails when plan not found', async () => {
222
+ const result = await executePlanAction({ type: 'planClose', planId: 'plan-notfound' }, makeCtx(), makePlanCtx());
223
+ expect(result.ok).toBe(false);
224
+ if (!result.ok)
225
+ expect(result.error).toContain('Plan not found');
226
+ });
227
+ it('rejects when plan is currently implementing', async () => {
228
+ const result = await executePlanAction({ type: 'planClose', planId: 'plan-implementing' }, makeCtx(), makePlanCtx());
229
+ expect(result.ok).toBe(false);
230
+ if (!result.ok)
231
+ expect(result.error).toContain('currently being implemented');
232
+ });
233
+ });
234
+ describe('planCreate', () => {
235
+ it('creates a new plan', async () => {
236
+ const result = await executePlanAction({ type: 'planCreate', description: 'New feature' }, makeCtx(), makePlanCtx());
237
+ expect(result.ok).toBe(true);
238
+ if (result.ok) {
239
+ expect(result.summary).toContain('plan-003');
240
+ expect(result.summary).toContain('New feature');
241
+ }
242
+ });
243
+ it('passes context to handlePlanCommand', async () => {
244
+ const { handlePlanCommand } = await import('./plan-commands.js');
245
+ await executePlanAction({ type: 'planCreate', description: 'New feature', context: 'Extra context here' }, makeCtx(), makePlanCtx());
246
+ expect(handlePlanCommand).toHaveBeenCalledWith({ action: 'create', args: 'New feature', context: 'Extra context here' }, expect.objectContaining({ workspaceCwd: '/tmp/workspace' }));
247
+ });
248
+ it('fails without description', async () => {
249
+ const result = await executePlanAction({ type: 'planCreate', description: '' }, makeCtx(), makePlanCtx());
250
+ expect(result.ok).toBe(false);
251
+ if (!result.ok)
252
+ expect(result.error).toContain('requires a description');
253
+ });
254
+ it('returns error when handlePlanCommand fails', async () => {
255
+ const { handlePlanCommand } = await import('./plan-commands.js');
256
+ handlePlanCommand.mockResolvedValueOnce('Failed to create backing bead: ENOENT');
257
+ const result = await executePlanAction({ type: 'planCreate', description: 'Broken plan' }, makeCtx(), makePlanCtx());
258
+ expect(result.ok).toBe(false);
259
+ if (!result.ok)
260
+ expect(result.error).toContain('Failed');
261
+ });
262
+ });
263
+ describe('planRun', () => {
264
+ it('starts a plan run and returns summary', async () => {
265
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
266
+ expect(result.ok).toBe(true);
267
+ if (result.ok) {
268
+ expect(result.summary).toContain('Plan run started');
269
+ expect(result.summary).toContain('plan-042');
270
+ }
271
+ });
272
+ it('fails without planId', async () => {
273
+ const result = await executePlanAction({ type: 'planRun', planId: '' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
274
+ expect(result.ok).toBe(false);
275
+ if (!result.ok)
276
+ expect(result.error).toContain('requires a planId');
277
+ });
278
+ it('fails without runtime', async () => {
279
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx());
280
+ expect(result.ok).toBe(false);
281
+ if (!result.ok)
282
+ expect(result.error).toContain('requires runtime');
283
+ });
284
+ it('blocks at recursion depth >= 1', async () => {
285
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus', depth: 1 }));
286
+ expect(result.ok).toBe(false);
287
+ if (!result.ok)
288
+ expect(result.error).toContain('recursion depth');
289
+ });
290
+ it('rejects when plan is already running', async () => {
291
+ const { isPlanRunning } = await import('./forge-plan-registry.js');
292
+ isPlanRunning.mockReturnValueOnce(true);
293
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
294
+ expect(result.ok).toBe(false);
295
+ if (!result.ok)
296
+ expect(result.error).toContain('already in progress');
297
+ });
298
+ it('rejects plan with DRAFT status via preparePlanRun gate', async () => {
299
+ const { preparePlanRun } = await import('./plan-commands.js');
300
+ preparePlanRun.mockResolvedValueOnce({ error: 'Plan plan-draft has status DRAFT — must be APPROVED or IMPLEMENTING to run.' });
301
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-draft' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
302
+ expect(result.ok).toBe(false);
303
+ if (!result.ok)
304
+ expect(result.error).toContain('DRAFT');
305
+ });
306
+ it('rejects plan with REVIEW status via preparePlanRun gate', async () => {
307
+ const { preparePlanRun } = await import('./plan-commands.js');
308
+ preparePlanRun.mockResolvedValueOnce({ error: 'Plan plan-review has status REVIEW — must be APPROVED or IMPLEMENTING to run.' });
309
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-review' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
310
+ expect(result.ok).toBe(false);
311
+ if (!result.ok)
312
+ expect(result.error).toContain('REVIEW');
313
+ });
314
+ it('calls closePlanIfComplete after phase loop completes', async () => {
315
+ const { closePlanIfComplete } = await import('./plan-commands.js');
316
+ const result = await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
317
+ expect(result.ok).toBe(true);
318
+ // closePlanIfComplete is called in the fire-and-forget async block;
319
+ // yield to let it execute.
320
+ await new Promise(resolve => setTimeout(resolve, 50));
321
+ expect(closePlanIfComplete).toHaveBeenCalledWith('/tmp/plans/plan-042-phases.md', '/tmp/plans/plan-042-test.md', expect.any(TaskStore), expect.any(Function), expect.anything());
322
+ });
323
+ it('calls closePlanIfComplete even when some phases fail', async () => {
324
+ const { runNextPhase } = await import('./plan-manager.js');
325
+ const { closePlanIfComplete } = await import('./plan-commands.js');
326
+ runNextPhase.mockResolvedValueOnce({ result: 'failed', phase: { id: 'phase-1', title: 'Fail' }, error: 'build error' });
327
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus' }));
328
+ await new Promise(resolve => setTimeout(resolve, 50));
329
+ // closePlanIfComplete should still be called — it checks internally
330
+ expect(closePlanIfComplete).toHaveBeenCalled();
331
+ });
332
+ it('sends initial status message and edits it with final outcome after run finishes', async () => {
333
+ const setup = makeSendFn();
334
+ const ctx = makeCtx(setup);
335
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
336
+ await new Promise(resolve => setTimeout(resolve, 50));
337
+ // Two sends: initial status message + standalone completion message
338
+ expect(setup.fn).toHaveBeenCalledTimes(2);
339
+ const firstSendContent = setup.fn.mock.calls[0][0].content;
340
+ expect(firstSendContent).toContain('plan-042');
341
+ expect(firstSendContent).toContain('Plan run started');
342
+ // Second send: standalone completion message posted as a new message
343
+ const completionSendContent = setup.fn.mock.calls[1][0].content;
344
+ expect(completionSendContent).toContain('plan-042');
345
+ expect(completionSendContent).toContain('Plan run complete');
346
+ // Status message also edited in place with final summary (backwards compat)
347
+ expect(setup.msg.edit).toHaveBeenCalled();
348
+ const lastEdit = setup.msg.edit.mock.calls.at(-1)[0];
349
+ expect(lastEdit.content).toContain('plan-042');
350
+ expect(lastEdit.content).toContain('Phases run:');
351
+ expect(lastEdit.allowedMentions).toEqual({ parse: [] });
352
+ });
353
+ it('posts a new message when a phase starts', async () => {
354
+ const { runNextPhase } = await import('./plan-manager.js');
355
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
356
+ await opts.onPlanEvent?.({
357
+ type: 'phase_start',
358
+ planId: 'plan-042',
359
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
360
+ });
361
+ return { result: 'nothing_to_run' };
362
+ });
363
+ const setup = makeSendFn();
364
+ const ctx = makeCtx(setup);
365
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
366
+ await new Promise(resolve => setTimeout(resolve, 50));
367
+ const contents = setup.fn.mock.calls.map((call) => String(call[0].content));
368
+ expect(contents.some((text) => text.includes('**First phase**...'))).toBe(true);
369
+ });
370
+ it('deduplicates phase-start posts for repeated progress lines in the same run', async () => {
371
+ const { runNextPhase } = await import('./plan-manager.js');
372
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
373
+ const event = {
374
+ type: 'phase_start',
375
+ planId: 'plan-042',
376
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
377
+ };
378
+ await opts.onPlanEvent?.(event);
379
+ await opts.onPlanEvent?.(event);
380
+ return { result: 'nothing_to_run' };
381
+ });
382
+ const setup = makeSendFn();
383
+ const ctx = makeCtx(setup);
384
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
385
+ await new Promise(resolve => setTimeout(resolve, 50));
386
+ const phaseStartMessages = setup.fn.mock.calls
387
+ .map((call) => String(call[0].content))
388
+ .filter((content) => content.includes('**First phase**...'));
389
+ expect(phaseStartMessages).toHaveLength(1);
390
+ });
391
+ it('skips completion notification when skipCompletionNotify is true', async () => {
392
+ const setup = makeSendFn();
393
+ const ctx = makeCtx(setup);
394
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus', skipCompletionNotify: true }));
395
+ await new Promise(resolve => setTimeout(resolve, 50));
396
+ expect(setup.fn).not.toHaveBeenCalled();
397
+ expect(setup.msg.edit).not.toHaveBeenCalled();
398
+ });
399
+ it('posts phase-start updates even when skipCompletionNotify is true', async () => {
400
+ const { runNextPhase } = await import('./plan-manager.js');
401
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
402
+ await opts.onPlanEvent?.({
403
+ type: 'phase_start',
404
+ planId: 'plan-042',
405
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
406
+ });
407
+ return { result: 'nothing_to_run' };
408
+ });
409
+ const setup = makeSendFn();
410
+ const ctx = makeCtx(setup);
411
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus', skipCompletionNotify: true }));
412
+ await new Promise(resolve => setTimeout(resolve, 50));
413
+ expect(setup.fn).toHaveBeenCalledOnce();
414
+ const sent = String(setup.fn.mock.calls[0][0].content);
415
+ expect(sent).toContain('**First phase**...');
416
+ expect(setup.msg.edit).not.toHaveBeenCalled();
417
+ });
418
+ it('includes stop reason in completion notification when a phase fails', async () => {
419
+ const { runNextPhase } = await import('./plan-manager.js');
420
+ runNextPhase.mockResolvedValueOnce({ result: 'audit_failed', error: 'lint errors' });
421
+ const setup = makeSendFn();
422
+ const ctx = makeCtx(setup);
423
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
424
+ await new Promise(resolve => setTimeout(resolve, 50));
425
+ expect(setup.fn).toHaveBeenCalledTimes(2);
426
+ const lastEdit = setup.msg.edit.mock.calls.at(-1)[0];
427
+ expect(lastEdit.content).toContain('Stopped:');
428
+ });
429
+ it('calls onRunComplete with final content after run completes', async () => {
430
+ const onRunComplete = vi.fn(async (_content) => { });
431
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, makeCtx(), makePlanCtx({ runtime: {}, model: 'opus', onRunComplete }));
432
+ await new Promise(resolve => setTimeout(resolve, 50));
433
+ expect(onRunComplete).toHaveBeenCalledOnce();
434
+ const content = onRunComplete.mock.calls[0][0];
435
+ expect(content).toContain('Plan run complete');
436
+ expect(content).toContain('plan-042');
437
+ expect(content).toContain('Phases run:');
438
+ });
439
+ it('calls onRunComplete even when skipCompletionNotify is true', async () => {
440
+ const onRunComplete = vi.fn(async (_content) => { });
441
+ const setup = makeSendFn();
442
+ const ctx = makeCtx(setup);
443
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus', skipCompletionNotify: true, onRunComplete }));
444
+ await new Promise(resolve => setTimeout(resolve, 50));
445
+ // No Discord messages should be sent
446
+ expect(setup.fn).not.toHaveBeenCalled();
447
+ expect(setup.msg.edit).not.toHaveBeenCalled();
448
+ // But onRunComplete is still called
449
+ expect(onRunComplete).toHaveBeenCalledOnce();
450
+ const content = onRunComplete.mock.calls[0][0];
451
+ expect(content).toContain('Plan run complete');
452
+ expect(content).toContain('plan-042');
453
+ });
454
+ it('includes auto-close note in completion notification when plan is closed', async () => {
455
+ const { closePlanIfComplete } = await import('./plan-commands.js');
456
+ closePlanIfComplete.mockResolvedValueOnce({ closed: true, reason: 'all_phases_complete' });
457
+ const setup = makeSendFn();
458
+ const ctx = makeCtx(setup);
459
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
460
+ await new Promise(resolve => setTimeout(resolve, 50));
461
+ expect(setup.fn).toHaveBeenCalledTimes(2);
462
+ const lastEdit = setup.msg.edit.mock.calls.at(-1)[0];
463
+ expect(lastEdit.content).toContain('auto-closed');
464
+ });
465
+ it('edits phase-start message to done state on phase completion', async () => {
466
+ const { runNextPhase } = await import('./plan-manager.js');
467
+ // Create distinct message objects per send call to distinguish phase-start from status.
468
+ const sendMsgs = [];
469
+ const sendFn = vi.fn(async () => {
470
+ const msg = { edit: vi.fn(async () => { }) };
471
+ sendMsgs.push(msg);
472
+ return msg;
473
+ });
474
+ const ctx = {
475
+ guild: {},
476
+ client: { channels: { fetch: vi.fn(async () => ({ send: sendFn })) } },
477
+ channelId: 'test-channel',
478
+ messageId: 'test-message',
479
+ };
480
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
481
+ await opts.onPlanEvent?.({
482
+ type: 'phase_start',
483
+ planId: 'plan-042',
484
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
485
+ });
486
+ await opts.onPlanEvent?.({
487
+ type: 'phase_complete',
488
+ planId: 'plan-042',
489
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
490
+ status: 'done',
491
+ });
492
+ return { result: 'done', phase: { id: 'phase-1', title: 'First phase', kind: 'implement' }, output: 'ok' };
493
+ });
494
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
495
+ await new Promise(resolve => setTimeout(resolve, 50));
496
+ // send order: [0] initial status, [1] phase-start, [2] completion
497
+ expect(sendMsgs.length).toBeGreaterThanOrEqual(2);
498
+ const phaseMsg = sendMsgs[1];
499
+ expect(phaseMsg.edit).toHaveBeenCalled();
500
+ const doneEdit = phaseMsg.edit.mock.calls.find((call) => String(call[0].content).includes('[x]'));
501
+ expect(doneEdit).toBeDefined();
502
+ expect(String(doneEdit[0].content)).toContain('First phase');
503
+ });
504
+ it('edits phase-start message to failed state when phase fails', async () => {
505
+ const { runNextPhase } = await import('./plan-manager.js');
506
+ const sendMsgs = [];
507
+ const sendFn = vi.fn(async () => {
508
+ const msg = { edit: vi.fn(async () => { }) };
509
+ sendMsgs.push(msg);
510
+ return msg;
511
+ });
512
+ const ctx = {
513
+ guild: {},
514
+ client: { channels: { fetch: vi.fn(async () => ({ send: sendFn })) } },
515
+ channelId: 'test-channel',
516
+ messageId: 'test-message',
517
+ };
518
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
519
+ await opts.onPlanEvent?.({
520
+ type: 'phase_start',
521
+ planId: 'plan-042',
522
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
523
+ });
524
+ await opts.onPlanEvent?.({
525
+ type: 'phase_complete',
526
+ planId: 'plan-042',
527
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
528
+ status: 'failed',
529
+ });
530
+ return { result: 'failed', phase: { id: 'phase-1', title: 'First phase', kind: 'implement' }, output: '', error: 'build error' };
531
+ });
532
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
533
+ await new Promise(resolve => setTimeout(resolve, 50));
534
+ // send order: [0] initial status, [1] phase-start, [2] completion
535
+ expect(sendMsgs.length).toBeGreaterThanOrEqual(2);
536
+ const phaseMsg = sendMsgs[1];
537
+ expect(phaseMsg.edit).toHaveBeenCalled();
538
+ const failEdit = phaseMsg.edit.mock.calls.find((call) => String(call[0].content).includes('[!]'));
539
+ expect(failEdit).toBeDefined();
540
+ expect(String(failEdit[0].content)).toContain('First phase');
541
+ });
542
+ it('posts a new standalone completion message after run finishes', async () => {
543
+ const setup = makeSendFn();
544
+ const ctx = makeCtx(setup);
545
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
546
+ await new Promise(resolve => setTimeout(resolve, 50));
547
+ // Two sends: initial status + standalone completion message
548
+ expect(setup.fn).toHaveBeenCalledTimes(2);
549
+ const completionContent = setup.fn.mock.calls[1][0].content;
550
+ expect(completionContent).toContain('Plan run complete');
551
+ expect(completionContent).toContain('plan-042');
552
+ expect(completionContent).toContain('Phases run:');
553
+ expect(setup.fn.mock.calls[1][0].allowedMentions).toEqual({ parse: [] });
554
+ });
555
+ it('handles gracefully when phase-start message edit fails', async () => {
556
+ const { runNextPhase } = await import('./plan-manager.js');
557
+ const failingEdit = vi.fn(async () => { throw new Error('edit failed'); });
558
+ let sendCallCount = 0;
559
+ const sendFn = vi.fn(async () => {
560
+ sendCallCount++;
561
+ if (sendCallCount === 2)
562
+ return { edit: failingEdit };
563
+ return { edit: vi.fn(async () => { }) };
564
+ });
565
+ const ctx = {
566
+ guild: {},
567
+ client: { channels: { fetch: vi.fn(async () => ({ send: sendFn })) } },
568
+ channelId: 'test-channel',
569
+ messageId: 'test-message',
570
+ };
571
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
572
+ await opts.onPlanEvent?.({
573
+ type: 'phase_start',
574
+ planId: 'plan-042',
575
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
576
+ });
577
+ await opts.onPlanEvent?.({
578
+ type: 'phase_complete',
579
+ planId: 'plan-042',
580
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
581
+ status: 'done',
582
+ });
583
+ return { result: 'done', phase: { id: 'phase-1', title: 'First phase', kind: 'implement' }, output: 'ok' };
584
+ });
585
+ await executePlanAction({ type: 'planRun', planId: 'plan-042' }, ctx, makePlanCtx({ runtime: {}, model: 'opus' }));
586
+ await new Promise(resolve => setTimeout(resolve, 50));
587
+ // Edit was attempted on the failing message
588
+ expect(failingEdit).toHaveBeenCalled();
589
+ // Run still completed — completion message was sent (initial + phase-start + completion = 3)
590
+ expect(sendCallCount).toBeGreaterThanOrEqual(3);
591
+ });
592
+ });
593
+ });
594
+ describe('planActionsPromptSection', () => {
595
+ it('returns non-empty prompt section', () => {
596
+ const section = planActionsPromptSection();
597
+ expect(section).toContain('planList');
598
+ expect(section).toContain('planShow');
599
+ expect(section).toContain('planApprove');
600
+ expect(section).toContain('planClose');
601
+ expect(section).toContain('planCreate');
602
+ expect(section).toContain('planRun');
603
+ });
604
+ it('includes plan guidelines', () => {
605
+ const section = planActionsPromptSection();
606
+ expect(section).toContain('DRAFT');
607
+ expect(section).toContain('APPROVED');
608
+ expect(section).toContain('forgeCreate');
609
+ });
610
+ });
@@ -0,0 +1,38 @@
1
+ import { resolveChannel } from './action-utils.js';
2
+ const POLL_TYPE_MAP = { poll: true };
3
+ export const POLL_ACTION_TYPES = new Set(Object.keys(POLL_TYPE_MAP));
4
+ // ---------------------------------------------------------------------------
5
+ // Executor
6
+ // ---------------------------------------------------------------------------
7
+ export async function executePollAction(action, ctx) {
8
+ const { guild } = ctx;
9
+ const channel = resolveChannel(guild, action.channel);
10
+ if (!channel)
11
+ return { ok: false, error: `Channel "${action.channel}" not found` };
12
+ const pollAnswers = action.answers.map((text) => ({ text }));
13
+ await channel.send({
14
+ poll: {
15
+ question: { text: action.question },
16
+ answers: pollAnswers,
17
+ allowMultiselect: action.allowMultiselect ?? false,
18
+ duration: action.durationHours ?? 24,
19
+ },
20
+ });
21
+ return { ok: true, summary: `Created poll "${action.question}" in #${channel.name} with ${action.answers.length} options` };
22
+ }
23
+ // ---------------------------------------------------------------------------
24
+ // Prompt section
25
+ // ---------------------------------------------------------------------------
26
+ export function pollActionsPromptSection() {
27
+ return `### Polls
28
+
29
+ **poll** — Create a poll in a channel:
30
+ \`\`\`
31
+ <discord-action>{"type":"poll","channel":"#general","question":"What should we do?","answers":["Option A","Option B","Option C"],"allowMultiselect":false,"durationHours":24}</discord-action>
32
+ \`\`\`
33
+ - \`channel\` (required): Channel name or ID.
34
+ - \`question\` (required): Poll question text.
35
+ - \`answers\` (required): Array of answer strings (2–10 options).
36
+ - \`allowMultiselect\` (optional): Allow multiple selections. Default: false.
37
+ - \`durationHours\` (optional): Poll duration in hours. Default: 24.`;
38
+ }