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,1622 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parsePlanCommand, handlePlanCommand, createPlan, parsePlanFileHeader, toSlug, handlePlanSkip, preparePlanRun, updatePlanFileStatus, listPlanFiles, findPlanFile, normalizePlanId, looksLikePlanId, closePlanIfComplete, NO_PHASES_SENTINEL, } from './plan-commands.js';
6
+ import { TaskStore } from '../tasks/store.js';
7
+ async function makeTmpDir() {
8
+ return fs.mkdtemp(path.join(os.tmpdir(), 'plan-commands-test-'));
9
+ }
10
+ function makeStore(prefix = 'ws') {
11
+ return new TaskStore({ prefix });
12
+ }
13
+ function baseOpts(overrides = {}) {
14
+ return {
15
+ workspaceCwd: '/tmp/test-workspace',
16
+ taskStore: makeStore(),
17
+ ...overrides,
18
+ };
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // parsePlanCommand
22
+ // ---------------------------------------------------------------------------
23
+ describe('parsePlanCommand', () => {
24
+ it('returns null for non-plan messages', () => {
25
+ expect(parsePlanCommand('hello world')).toBeNull();
26
+ expect(parsePlanCommand('!memory show')).toBeNull();
27
+ expect(parsePlanCommand('')).toBeNull();
28
+ // Note: '!planning something' would match because it starts with '!plan'.
29
+ // This is fine — no other !plan* commands exist.
30
+ });
31
+ it('!plan with no args returns help', () => {
32
+ expect(parsePlanCommand('!plan')).toEqual({ action: 'help', args: '' });
33
+ });
34
+ it('!plan with extra whitespace returns help', () => {
35
+ expect(parsePlanCommand(' !plan ')).toEqual({ action: 'help', args: '' });
36
+ });
37
+ it('parses create from description text', () => {
38
+ expect(parsePlanCommand('!plan fix the login bug')).toEqual({
39
+ action: 'create',
40
+ args: 'fix the login bug',
41
+ });
42
+ });
43
+ it('parses list as reserved subcommand', () => {
44
+ expect(parsePlanCommand('!plan list')).toEqual({ action: 'list', args: '' });
45
+ });
46
+ it('"list" is reserved — "!plan list something" is not treated as create', () => {
47
+ expect(parsePlanCommand('!plan list something')).toEqual({
48
+ action: 'list',
49
+ args: 'something',
50
+ });
51
+ });
52
+ it('parses show with plan ID', () => {
53
+ expect(parsePlanCommand('!plan show plan-001')).toEqual({
54
+ action: 'show',
55
+ args: 'plan-001',
56
+ });
57
+ });
58
+ it('parses show with bead ID', () => {
59
+ expect(parsePlanCommand('!plan show ws-abc-123')).toEqual({
60
+ action: 'show',
61
+ args: 'ws-abc-123',
62
+ });
63
+ });
64
+ it('parses approve', () => {
65
+ expect(parsePlanCommand('!plan approve plan-001')).toEqual({
66
+ action: 'approve',
67
+ args: 'plan-001',
68
+ });
69
+ });
70
+ it('parses close', () => {
71
+ expect(parsePlanCommand('!plan close plan-001')).toEqual({
72
+ action: 'close',
73
+ args: 'plan-001',
74
+ });
75
+ });
76
+ it('parses help explicitly', () => {
77
+ expect(parsePlanCommand('!plan help')).toEqual({ action: 'help', args: '' });
78
+ });
79
+ it('parses phases subcommand', () => {
80
+ expect(parsePlanCommand('!plan phases plan-011')).toEqual({
81
+ action: 'phases',
82
+ args: 'plan-011',
83
+ });
84
+ });
85
+ it('parses phases with --regenerate flag', () => {
86
+ expect(parsePlanCommand('!plan phases --regenerate plan-011')).toEqual({
87
+ action: 'phases',
88
+ args: '--regenerate plan-011',
89
+ });
90
+ });
91
+ it('parses run subcommand', () => {
92
+ expect(parsePlanCommand('!plan run plan-011')).toEqual({
93
+ action: 'run',
94
+ args: 'plan-011',
95
+ });
96
+ });
97
+ it('parses run-one subcommand', () => {
98
+ expect(parsePlanCommand('!plan run-one plan-011')).toEqual({
99
+ action: 'run-one',
100
+ args: 'plan-011',
101
+ });
102
+ });
103
+ it('parses skip subcommand', () => {
104
+ expect(parsePlanCommand('!plan skip plan-011')).toEqual({
105
+ action: 'skip',
106
+ args: 'plan-011',
107
+ });
108
+ });
109
+ it('parses cancel subcommand', () => {
110
+ expect(parsePlanCommand('!plan cancel plan-011')).toEqual({
111
+ action: 'cancel',
112
+ args: 'plan-011',
113
+ });
114
+ });
115
+ it('parses audit subcommand', () => {
116
+ expect(parsePlanCommand('!plan audit plan-027')).toEqual({
117
+ action: 'audit',
118
+ args: 'plan-027',
119
+ });
120
+ });
121
+ it('parses audit with no args', () => {
122
+ expect(parsePlanCommand('!plan audit')).toEqual({
123
+ action: 'audit',
124
+ args: '',
125
+ });
126
+ });
127
+ });
128
+ // ---------------------------------------------------------------------------
129
+ // toSlug
130
+ // ---------------------------------------------------------------------------
131
+ describe('toSlug', () => {
132
+ it('converts to lowercase and replaces non-alphanumeric with hyphens', () => {
133
+ expect(toSlug('Fix the Login Bug')).toBe('fix-the-login-bug');
134
+ });
135
+ it('strips leading and trailing hyphens', () => {
136
+ expect(toSlug('---hello---')).toBe('hello');
137
+ });
138
+ it('truncates at 50 chars without trailing hyphen', () => {
139
+ const long = 'a'.repeat(60);
140
+ const slug = toSlug(long);
141
+ expect(slug.length).toBeLessThanOrEqual(50);
142
+ expect(slug.endsWith('-')).toBe(false);
143
+ });
144
+ it('handles special characters and Unicode', () => {
145
+ expect(toSlug('Add café support & résumé handling!')).toBe('add-caf-support-r-sum-handling');
146
+ });
147
+ it('handles empty string', () => {
148
+ expect(toSlug('')).toBe('');
149
+ });
150
+ });
151
+ // ---------------------------------------------------------------------------
152
+ // parsePlanFileHeader
153
+ // ---------------------------------------------------------------------------
154
+ describe('parsePlanFileHeader', () => {
155
+ it('parses a well-formed plan header', () => {
156
+ const content = `# Plan: Add the plan command
157
+
158
+ **ID:** plan-001
159
+ **Task:** ws-test-001
160
+ **Created:** 2026-02-12
161
+ **Status:** DRAFT
162
+ **Project:** discoclaw
163
+ `;
164
+ const header = parsePlanFileHeader(content);
165
+ expect(header).toEqual({
166
+ planId: 'plan-001',
167
+ taskId: 'ws-test-001',
168
+ status: 'DRAFT',
169
+ title: 'Add the plan command',
170
+ project: 'discoclaw',
171
+ created: '2026-02-12',
172
+ });
173
+ });
174
+ it('returns null when no ID field', () => {
175
+ expect(parsePlanFileHeader('# Just some file\n\nNo plan header.')).toBeNull();
176
+ });
177
+ it('handles missing optional fields', () => {
178
+ const content = `**ID:** plan-002\n`;
179
+ const header = parsePlanFileHeader(content);
180
+ expect(header).not.toBeNull();
181
+ expect(header.planId).toBe('plan-002');
182
+ expect(header.taskId).toBe('');
183
+ expect(header.title).toBe('');
184
+ });
185
+ it('parses task header alias as taskId', () => {
186
+ const content = `# Plan: Alias header test
187
+
188
+ **ID:** plan-003
189
+ **Task:** ws-task-003
190
+ **Status:** DRAFT
191
+ `;
192
+ const header = parsePlanFileHeader(content);
193
+ expect(header).not.toBeNull();
194
+ expect(header.planId).toBe('plan-003');
195
+ expect(header.taskId).toBe('ws-task-003');
196
+ });
197
+ });
198
+ // ---------------------------------------------------------------------------
199
+ // handlePlanCommand
200
+ // ---------------------------------------------------------------------------
201
+ describe('handlePlanCommand', () => {
202
+ it('help — returns usage text', async () => {
203
+ const result = await handlePlanCommand({ action: 'help', args: '' }, baseOpts());
204
+ expect(result).toContain('!plan commands');
205
+ expect(result).toContain('!plan list');
206
+ expect(result).toContain('!plan show');
207
+ expect(result).toContain('!plan approve');
208
+ expect(result).toContain('!plan close');
209
+ });
210
+ it('create — writes plan file and creates bead', async () => {
211
+ const tmpDir = await makeTmpDir();
212
+ const store = makeStore();
213
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
214
+ const result = await handlePlanCommand({ action: 'create', args: 'Add user authentication' }, opts);
215
+ expect(result).toContain('plan-001');
216
+ expect(result).toContain('Add user authentication');
217
+ // Verify bead was created with plan label.
218
+ const tasks = store.list({ label: 'plan' });
219
+ expect(tasks).toHaveLength(1);
220
+ expect(tasks[0].title).toBe('Add user authentication');
221
+ expect(tasks[0].labels).toContain('plan');
222
+ const beadId = tasks[0].id;
223
+ expect(result).toContain(beadId);
224
+ // Verify file was written.
225
+ const plansDir = path.join(tmpDir, 'plans');
226
+ const files = await fs.readdir(plansDir);
227
+ const planFile = files.find((f) => f.startsWith('plan-001'));
228
+ expect(planFile).toBeTruthy();
229
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
230
+ expect(content).toContain('**ID:** plan-001');
231
+ expect(content).toContain(`**Task:** ${beadId}`);
232
+ expect(content).toContain('**Status:** DRAFT');
233
+ });
234
+ it('createPlan — returns typed metadata for forge callers', async () => {
235
+ const tmpDir = await makeTmpDir();
236
+ const store = makeStore();
237
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
238
+ const created = await createPlan({ description: 'Create typed result plan', context: 'thread context' }, opts);
239
+ expect(created.planId).toBe('plan-001');
240
+ expect(created.fileName).toContain('create-typed-result-plan');
241
+ expect(created.filePath).toContain(path.join(tmpDir, 'plans', created.fileName));
242
+ expect(created.taskId).toMatch(/^ws-/);
243
+ expect(created.displayMessage).toContain('Plan created: **plan-001**');
244
+ });
245
+ it('create — increments plan number based on existing files', async () => {
246
+ const tmpDir = await makeTmpDir();
247
+ const plansDir = path.join(tmpDir, 'plans');
248
+ await fs.mkdir(plansDir, { recursive: true });
249
+ // Create a pre-existing plan file.
250
+ await fs.writeFile(path.join(plansDir, 'plan-003-existing.md'), '**ID:** plan-003\n**Status:** DONE\n');
251
+ const result = await handlePlanCommand({ action: 'create', args: 'New feature' }, baseOpts({ workspaceCwd: tmpDir }));
252
+ expect(result).toContain('plan-004');
253
+ });
254
+ it('create — sanitizes and truncates slug', async () => {
255
+ const tmpDir = await makeTmpDir();
256
+ await handlePlanCommand({ action: 'create', args: 'This is a very long description that should be truncated to fifty characters maximum for the filename' }, baseOpts({ workspaceCwd: tmpDir }));
257
+ const plansDir = path.join(tmpDir, 'plans');
258
+ const files = await fs.readdir(plansDir);
259
+ const planFile = files.find((f) => f.startsWith('plan-001'));
260
+ expect(planFile).toBeTruthy();
261
+ // Slug portion (after plan-001-) should be <= 50 chars.
262
+ const slug = planFile.replace(/^plan-\d+-/, '').replace(/\.md$/, '');
263
+ expect(slug.length).toBeLessThanOrEqual(50);
264
+ });
265
+ it('create — returns error when no description', async () => {
266
+ const result = await handlePlanCommand({ action: 'create', args: '' }, baseOpts());
267
+ expect(result).toContain('Usage');
268
+ });
269
+ it('create — handles task store create failure gracefully', async () => {
270
+ const tmpDir = await makeTmpDir();
271
+ const store = makeStore();
272
+ vi.spyOn(store, 'create').mockImplementationOnce(() => { throw new Error('store error'); });
273
+ const result = await handlePlanCommand({ action: 'create', args: 'Something' }, baseOpts({ workspaceCwd: tmpDir, taskStore: store }));
274
+ expect(result).toContain('Failed to create backing task');
275
+ });
276
+ it('create — uses fallback template when .plan-template.md is missing', async () => {
277
+ const tmpDir = await makeTmpDir();
278
+ // No template file in plansDir — should use fallback.
279
+ const result = await handlePlanCommand({ action: 'create', args: 'Test fallback' }, baseOpts({ workspaceCwd: tmpDir }));
280
+ expect(result).toContain('plan-001');
281
+ const plansDir = path.join(tmpDir, 'plans');
282
+ const files = await fs.readdir(plansDir);
283
+ const planFile = files.find((f) => f.startsWith('plan-001'));
284
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
285
+ expect(content).toContain('## Objective');
286
+ expect(content).toContain('**Status:** DRAFT');
287
+ });
288
+ it('create — fills {{TASK_ID}} placeholder in custom template', async () => {
289
+ const tmpDir = await makeTmpDir();
290
+ const plansDir = path.join(tmpDir, 'plans');
291
+ await fs.mkdir(plansDir, { recursive: true });
292
+ await fs.writeFile(path.join(plansDir, '.plan-template.md'), '# Plan: {{TITLE}}\n\n**ID:** {{PLAN_ID}}\n**Task:** {{TASK_ID}}\n**Status:** DRAFT | APPROVED\n');
293
+ const store = makeStore();
294
+ const result = await handlePlanCommand({ action: 'create', args: 'Custom task placeholder' }, baseOpts({ workspaceCwd: tmpDir, taskStore: store }));
295
+ expect(result).toContain('plan-001');
296
+ const beadId = store.list({ label: 'plan' })[0].id;
297
+ const files = await fs.readdir(plansDir);
298
+ const planFile = files.find((f) => f.startsWith('plan-001'));
299
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
300
+ expect(content).toContain(`**Task:** ${beadId}`);
301
+ });
302
+ it('create — appends context to plan file body and bead description without polluting slug or bead title', async () => {
303
+ const tmpDir = await makeTmpDir();
304
+ const store = makeStore();
305
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
306
+ const result = await handlePlanCommand({ action: 'create', args: 'fix the login flow', context: 'Context (replied-to message):\n[Weston]: The login handler crashes on empty passwords.' }, opts);
307
+ expect(result).toContain('plan-001');
308
+ expect(result).toContain('fix the login flow');
309
+ // Bead title should be the raw args, not polluted with context
310
+ const tasks = store.list({ label: 'plan' });
311
+ expect(tasks).toHaveLength(1);
312
+ expect(tasks[0].title).toBe('fix the login flow');
313
+ expect(tasks[0].labels).toContain('plan');
314
+ expect(tasks[0].description).toBe('Context (replied-to message):\n[Weston]: The login handler crashes on empty passwords.');
315
+ // Slug should not contain context text
316
+ const plansDir = path.join(tmpDir, 'plans');
317
+ const files = await fs.readdir(plansDir);
318
+ const planFile = files.find((f) => f.startsWith('plan-001'));
319
+ expect(planFile).toBeTruthy();
320
+ expect(planFile).not.toContain('context');
321
+ expect(planFile).not.toContain('replied');
322
+ // But the file body should contain the context section
323
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
324
+ expect(content).toContain('## Context');
325
+ expect(content).toContain('The login handler crashes on empty passwords');
326
+ });
327
+ it('create — trims whitespace around context before writing section and bead description', async () => {
328
+ const tmpDir = await makeTmpDir();
329
+ const store = makeStore();
330
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
331
+ const rawContext = '\n Trimmed context line\n Another line \n';
332
+ const expectedContext = rawContext.trim();
333
+ await handlePlanCommand({ action: 'create', args: 'trim context plan', context: rawContext }, opts);
334
+ const plansDir = path.join(tmpDir, 'plans');
335
+ const files = await fs.readdir(plansDir);
336
+ const planFile = files.find((f) => f.startsWith('plan-001'));
337
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
338
+ expect(content).toContain(`## Context\n\n${expectedContext}\n`);
339
+ const tasks = store.list({ label: 'plan' });
340
+ expect(tasks[0].description).toBe(expectedContext);
341
+ });
342
+ it('create — does not pass description when context is absent', async () => {
343
+ const tmpDir = await makeTmpDir();
344
+ const store = makeStore();
345
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
346
+ await handlePlanCommand({ action: 'create', args: 'simple plan' }, opts);
347
+ const tasks = store.list({ label: 'plan' });
348
+ expect(tasks).toHaveLength(1);
349
+ expect(tasks[0].title).toBe('simple plan');
350
+ expect(tasks[0].description).toBeUndefined();
351
+ });
352
+ it('create — does not pass description when context is whitespace-only', async () => {
353
+ const tmpDir = await makeTmpDir();
354
+ const store = makeStore();
355
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
356
+ await handlePlanCommand({ action: 'create', args: 'plan with blank context', context: ' \n ' }, opts);
357
+ const tasks = store.list({ label: 'plan' });
358
+ expect(tasks).toHaveLength(1);
359
+ expect(tasks[0].title).toBe('plan with blank context');
360
+ expect(tasks[0].description).toBeUndefined();
361
+ });
362
+ it('create — truncates long context in bead description', async () => {
363
+ const tmpDir = await makeTmpDir();
364
+ const store = makeStore();
365
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
366
+ const longContext = 'x'.repeat(5000);
367
+ await handlePlanCommand({ action: 'create', args: 'plan with long context', context: longContext }, opts);
368
+ const tasks = store.list({ label: 'plan' });
369
+ expect(tasks[0].description).toHaveLength(1800);
370
+ expect(tasks[0].description).toBe('x'.repeat(1800));
371
+ });
372
+ it('create — creates plans dir when missing', async () => {
373
+ const tmpDir = await makeTmpDir();
374
+ // Don't create plansDir — handlePlanCommand should create it.
375
+ await handlePlanCommand({ action: 'create', args: 'First plan ever' }, baseOpts({ workspaceCwd: tmpDir }));
376
+ const plansDir = path.join(tmpDir, 'plans');
377
+ const stat = await fs.stat(plansDir);
378
+ expect(stat.isDirectory()).toBe(true);
379
+ });
380
+ it('create — skips task store create when existingTaskId is provided', async () => {
381
+ const tmpDir = await makeTmpDir();
382
+ const store = makeStore();
383
+ const createSpy = vi.spyOn(store, 'create');
384
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
385
+ const result = await handlePlanCommand({ action: 'create', args: 'fix the bug', existingTaskId: 'bead-abc' }, opts);
386
+ expect(result).toContain('plan-001');
387
+ expect(result).toContain('bead-abc');
388
+ expect(createSpy).not.toHaveBeenCalled();
389
+ // Verify the plan file contains the existing bead ID
390
+ const plansDir = path.join(tmpDir, 'plans');
391
+ const files = await fs.readdir(plansDir);
392
+ const planFile = files.find((f) => f.startsWith('plan-001'));
393
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
394
+ expect(content).toContain('**Task:** bead-abc');
395
+ });
396
+ it('create — calls addLabel when reusing existing bead', async () => {
397
+ const tmpDir = await makeTmpDir();
398
+ const store = makeStore();
399
+ const spy = vi.spyOn(store, 'addLabel');
400
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
401
+ await handlePlanCommand({ action: 'create', args: 'test', existingTaskId: 'bead-xyz' }, opts);
402
+ expect(spy).toHaveBeenCalledWith('bead-xyz', 'plan');
403
+ });
404
+ it('create — addLabel failure does not block plan creation', async () => {
405
+ const tmpDir = await makeTmpDir();
406
+ const store = makeStore();
407
+ vi.spyOn(store, 'addLabel').mockImplementationOnce(() => { throw new Error('label fail'); });
408
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
409
+ const result = await handlePlanCommand({ action: 'create', args: 'test', existingTaskId: 'bead-fail' }, opts);
410
+ expect(result).toContain('plan-001');
411
+ expect(result).toContain('bead-fail');
412
+ // Plan file should still be created with the correct bead ID
413
+ const plansDir = path.join(tmpDir, 'plans');
414
+ const files = await fs.readdir(plansDir);
415
+ const planFile = files.find((f) => f.startsWith('plan-001'));
416
+ const content = await fs.readFile(path.join(plansDir, planFile), 'utf-8');
417
+ expect(content).toContain('**Task:** bead-fail');
418
+ });
419
+ it('create — reuses existing open bead with matching title instead of creating duplicate', async () => {
420
+ const tmpDir = await makeTmpDir();
421
+ const store = makeStore();
422
+ const existing = store.create({ title: 'Add user authentication', labels: ['plan'] });
423
+ const createSpy = vi.spyOn(store, 'create');
424
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
425
+ const result = await handlePlanCommand({ action: 'create', args: 'Add user authentication' }, opts);
426
+ expect(result).toContain('plan-001');
427
+ expect(result).toContain(existing.id);
428
+ expect(createSpy).not.toHaveBeenCalled();
429
+ // Store still has exactly the one pre-existing task
430
+ expect(store.list({ label: 'plan' })).toHaveLength(1);
431
+ });
432
+ it('create — dedup is case-insensitive and trims whitespace', async () => {
433
+ const tmpDir = await makeTmpDir();
434
+ const store = makeStore();
435
+ const existing = store.create({ title: ' Fix The Bug ', labels: ['plan'] });
436
+ const createSpy = vi.spyOn(store, 'create');
437
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
438
+ const result = await handlePlanCommand({ action: 'create', args: 'fix the bug' }, opts);
439
+ expect(result).toContain(existing.id);
440
+ expect(createSpy).not.toHaveBeenCalled();
441
+ });
442
+ it('create — does not reuse closed beads with matching title', async () => {
443
+ const tmpDir = await makeTmpDir();
444
+ const store = makeStore();
445
+ const closed = store.create({ title: 'Add user authentication', labels: ['plan'] });
446
+ store.close(closed.id, 'done');
447
+ const createSpy = vi.spyOn(store, 'create');
448
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
449
+ const result = await handlePlanCommand({ action: 'create', args: 'Add user authentication' }, opts);
450
+ expect(createSpy).toHaveBeenCalled();
451
+ // A new task was created (total 2 in store including the closed one)
452
+ expect(store.list({ status: 'all' })).toHaveLength(2);
453
+ // Result contains the new task's ID
454
+ const newTask = store.list({ label: 'plan' })[0];
455
+ expect(result).toContain(newTask.id);
456
+ });
457
+ it('create — creates new bead when no title match exists', async () => {
458
+ const tmpDir = await makeTmpDir();
459
+ const store = makeStore();
460
+ store.create({ title: 'Something else entirely', labels: ['plan'] });
461
+ const createSpy = vi.spyOn(store, 'create');
462
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
463
+ const result = await handlePlanCommand({ action: 'create', args: 'Add user authentication' }, opts);
464
+ expect(createSpy).toHaveBeenCalled();
465
+ expect(store.list({ label: 'plan' })).toHaveLength(2);
466
+ const newTask = store.list({ label: 'plan' }).find((t) => t.title === 'Add user authentication');
467
+ expect(result).toContain(newTask.id);
468
+ });
469
+ it('create — dedup reuses in_progress bead with matching title', async () => {
470
+ const tmpDir = await makeTmpDir();
471
+ const store = makeStore();
472
+ const existing = store.create({ title: 'Add user authentication', labels: ['plan'] });
473
+ store.update(existing.id, { status: 'in_progress' });
474
+ const createSpy = vi.spyOn(store, 'create');
475
+ const opts = baseOpts({ workspaceCwd: tmpDir, taskStore: store });
476
+ const result = await handlePlanCommand({ action: 'create', args: 'Add user authentication' }, opts);
477
+ expect(result).toContain(existing.id);
478
+ expect(createSpy).not.toHaveBeenCalled();
479
+ });
480
+ it('list — shows active plans as bullet list', async () => {
481
+ const tmpDir = await makeTmpDir();
482
+ const plansDir = path.join(tmpDir, 'plans');
483
+ await fs.mkdir(plansDir, { recursive: true });
484
+ await fs.writeFile(path.join(plansDir, 'plan-001-alpha.md'), '# Plan: Alpha\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** DRAFT\n**Project:** test\n**Created:** 2026-01-01\n');
485
+ await fs.writeFile(path.join(plansDir, 'plan-002-beta.md'), '# Plan: Beta\n\n**ID:** plan-002\n**Task:** ws-002\n**Status:** APPROVED\n**Project:** test\n**Created:** 2026-01-02\n');
486
+ const result = await handlePlanCommand({ action: 'list', args: '' }, baseOpts({ workspaceCwd: tmpDir }));
487
+ expect(result).toContain('plan-001');
488
+ expect(result).toContain('DRAFT');
489
+ expect(result).toContain('Alpha');
490
+ expect(result).toContain('plan-002');
491
+ expect(result).toContain('APPROVED');
492
+ expect(result).toContain('Beta');
493
+ });
494
+ it('list — returns message when no plans', async () => {
495
+ const tmpDir = await makeTmpDir();
496
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
497
+ const result = await handlePlanCommand({ action: 'list', args: '' }, baseOpts({ workspaceCwd: tmpDir }));
498
+ expect(result).toBe('No plans found.');
499
+ });
500
+ it('list — returns message when plans dir missing', async () => {
501
+ const tmpDir = await makeTmpDir();
502
+ const result = await handlePlanCommand({ action: 'list', args: '' }, baseOpts({ workspaceCwd: tmpDir }));
503
+ expect(result).toBe('No plans directory found.');
504
+ });
505
+ it('show — finds plan by plan ID', async () => {
506
+ const tmpDir = await makeTmpDir();
507
+ const plansDir = path.join(tmpDir, 'plans');
508
+ await fs.mkdir(plansDir, { recursive: true });
509
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
510
+ '# Plan: Test feature',
511
+ '',
512
+ '**ID:** plan-001',
513
+ '**Task:** ws-001',
514
+ '**Status:** DRAFT',
515
+ '**Project:** discoclaw',
516
+ '**Created:** 2026-02-12',
517
+ '',
518
+ '---',
519
+ '',
520
+ '## Objective',
521
+ '',
522
+ 'Build the test feature for plan commands.',
523
+ '',
524
+ '## Audit Log',
525
+ '',
526
+ '### Review 1',
527
+ '',
528
+ '#### Verdict',
529
+ '',
530
+ '**Ready with minor revisions.**',
531
+ '',
532
+ '---',
533
+ ].join('\n'));
534
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
535
+ expect(result).toContain('plan-001');
536
+ expect(result).toContain('Test feature');
537
+ expect(result).toContain('DRAFT');
538
+ expect(result).toContain('Build the test feature');
539
+ expect(result).toContain('Ready with minor revisions');
540
+ });
541
+ it('show — extracts **Verdict:** inline bold format from audit log', async () => {
542
+ const tmpDir = await makeTmpDir();
543
+ const plansDir = path.join(tmpDir, 'plans');
544
+ await fs.mkdir(plansDir, { recursive: true });
545
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
546
+ '# Plan: Test feature',
547
+ '',
548
+ '**ID:** plan-001',
549
+ '**Task:** ws-001',
550
+ '**Status:** DRAFT',
551
+ '**Project:** discoclaw',
552
+ '**Created:** 2026-02-12',
553
+ '',
554
+ '---',
555
+ '',
556
+ '## Objective',
557
+ '',
558
+ 'Build the test feature.',
559
+ '',
560
+ '## Audit Log',
561
+ '',
562
+ '### Round 1',
563
+ '',
564
+ 'Some audit commentary.',
565
+ '',
566
+ '**Verdict:** Ready to approve. The plan is solid.',
567
+ '',
568
+ '---',
569
+ ].join('\n'));
570
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
571
+ expect(result).toContain('Ready to approve. The plan is solid.');
572
+ expect(result).not.toContain('(no audit yet)');
573
+ });
574
+ it('show — picks latest verdict when multiple audit rounds exist', async () => {
575
+ const tmpDir = await makeTmpDir();
576
+ const plansDir = path.join(tmpDir, 'plans');
577
+ await fs.mkdir(plansDir, { recursive: true });
578
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
579
+ '# Plan: Test feature',
580
+ '',
581
+ '**ID:** plan-001',
582
+ '**Task:** ws-001',
583
+ '**Status:** DRAFT',
584
+ '**Project:** discoclaw',
585
+ '**Created:** 2026-02-12',
586
+ '',
587
+ '---',
588
+ '',
589
+ '## Objective',
590
+ '',
591
+ 'Build the test feature.',
592
+ '',
593
+ '## Audit Log',
594
+ '',
595
+ '### Round 1',
596
+ '',
597
+ '**Verdict:** Needs revision. Missing error handling.',
598
+ '',
599
+ '### Round 2',
600
+ '',
601
+ '**Verdict:** Ready to approve. All issues addressed.',
602
+ '',
603
+ '---',
604
+ ].join('\n'));
605
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
606
+ expect(result).toContain('Ready to approve. All issues addressed.');
607
+ expect(result).not.toContain('Needs revision');
608
+ });
609
+ it('show — handles mixed legacy #### Verdict and **Verdict:** formats', async () => {
610
+ const tmpDir = await makeTmpDir();
611
+ const plansDir = path.join(tmpDir, 'plans');
612
+ await fs.mkdir(plansDir, { recursive: true });
613
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
614
+ '# Plan: Test feature',
615
+ '',
616
+ '**ID:** plan-001',
617
+ '**Task:** ws-001',
618
+ '**Status:** DRAFT',
619
+ '**Project:** discoclaw',
620
+ '**Created:** 2026-02-12',
621
+ '',
622
+ '---',
623
+ '',
624
+ '## Objective',
625
+ '',
626
+ 'Build the test feature.',
627
+ '',
628
+ '## Audit Log',
629
+ '',
630
+ '### Round 1 (legacy)',
631
+ '',
632
+ '#### Verdict',
633
+ '',
634
+ '**Old format verdict.**',
635
+ '',
636
+ '### Round 2 (new)',
637
+ '',
638
+ '**Verdict:** Ready to approve. Updated format.',
639
+ '',
640
+ '---',
641
+ ].join('\n'));
642
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
643
+ expect(result).toContain('Ready to approve. Updated format.');
644
+ expect(result).not.toContain('Old format verdict');
645
+ });
646
+ it('show — returns "(no audit yet)" when audit log section has no verdicts', async () => {
647
+ const tmpDir = await makeTmpDir();
648
+ const plansDir = path.join(tmpDir, 'plans');
649
+ await fs.mkdir(plansDir, { recursive: true });
650
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
651
+ '# Plan: Test feature',
652
+ '',
653
+ '**ID:** plan-001',
654
+ '**Task:** ws-001',
655
+ '**Status:** DRAFT',
656
+ '**Project:** discoclaw',
657
+ '**Created:** 2026-02-12',
658
+ '',
659
+ '---',
660
+ '',
661
+ '## Objective',
662
+ '',
663
+ 'Build the test feature.',
664
+ '',
665
+ '## Audit Log',
666
+ '',
667
+ '_Audit notes go here._',
668
+ '',
669
+ '---',
670
+ ].join('\n'));
671
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
672
+ expect(result).toContain('(no audit yet)');
673
+ });
674
+ it('show — ignores **Verdict:** inside fenced code blocks', async () => {
675
+ const tmpDir = await makeTmpDir();
676
+ const plansDir = path.join(tmpDir, 'plans');
677
+ await fs.mkdir(plansDir, { recursive: true });
678
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
679
+ '# Plan: Test feature',
680
+ '',
681
+ '**ID:** plan-001',
682
+ '**Task:** ws-001',
683
+ '**Status:** DRAFT',
684
+ '**Project:** discoclaw',
685
+ '**Created:** 2026-02-12',
686
+ '',
687
+ '---',
688
+ '',
689
+ '## Objective',
690
+ '',
691
+ 'Build the test feature.',
692
+ '',
693
+ '## Audit Log',
694
+ '',
695
+ '### Round 1',
696
+ '',
697
+ 'Here is an example of what the auditor writes:',
698
+ '',
699
+ '```',
700
+ '**Verdict:** This is inside a code block and should be ignored.',
701
+ '```',
702
+ '',
703
+ '**Verdict:** Ready to approve. Real verdict outside the fence.',
704
+ '',
705
+ '---',
706
+ ].join('\n'));
707
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
708
+ expect(result).toContain('Ready to approve. Real verdict outside the fence.');
709
+ expect(result).not.toContain('inside a code block');
710
+ });
711
+ it('show — ignores mid-line **Verdict:** in prose after the real verdict', async () => {
712
+ const tmpDir = await makeTmpDir();
713
+ const plansDir = path.join(tmpDir, 'plans');
714
+ await fs.mkdir(plansDir, { recursive: true });
715
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
716
+ '# Plan: Test feature',
717
+ '',
718
+ '**ID:** plan-001',
719
+ '**Task:** ws-001',
720
+ '**Status:** DRAFT',
721
+ '**Project:** discoclaw',
722
+ '**Created:** 2026-02-12',
723
+ '',
724
+ '---',
725
+ '',
726
+ '## Objective',
727
+ '',
728
+ 'Build the test feature.',
729
+ '',
730
+ '## Audit Log',
731
+ '',
732
+ '### Round 1',
733
+ '',
734
+ '**Verdict:** Ready to approve. The plan is solid.',
735
+ '',
736
+ 'Note: Use **Verdict:** [text] format for future audits. This mid-line mention should not override the real verdict above.',
737
+ '',
738
+ '---',
739
+ ].join('\n'));
740
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
741
+ expect(result).toContain('Ready to approve. The plan is solid.');
742
+ expect(result).not.toContain('future audits');
743
+ });
744
+ it('show — captures multi-line legacy #### Verdict blocks', async () => {
745
+ const tmpDir = await makeTmpDir();
746
+ const plansDir = path.join(tmpDir, 'plans');
747
+ await fs.mkdir(plansDir, { recursive: true });
748
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), [
749
+ '# Plan: Test feature',
750
+ '',
751
+ '**ID:** plan-001',
752
+ '**Task:** ws-001',
753
+ '**Status:** DRAFT',
754
+ '**Project:** discoclaw',
755
+ '**Created:** 2026-02-12',
756
+ '',
757
+ '---',
758
+ '',
759
+ '## Objective',
760
+ '',
761
+ 'Build the test feature.',
762
+ '',
763
+ '## Audit Log',
764
+ '',
765
+ '#### Verdict',
766
+ '',
767
+ '**Ready with minor revisions.**',
768
+ 'Some additional detail about the verdict.',
769
+ '',
770
+ '---',
771
+ ].join('\n'));
772
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
773
+ expect(result).toContain('Ready with minor revisions.');
774
+ expect(result).toContain('additional detail');
775
+ });
776
+ it('show — finds plan by bead ID', async () => {
777
+ const tmpDir = await makeTmpDir();
778
+ const plansDir = path.join(tmpDir, 'plans');
779
+ await fs.mkdir(plansDir, { recursive: true });
780
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-abc-123\n**Status:** DRAFT\n**Project:** test\n**Created:** 2026-01-01\n\n---\n\n## Objective\n\nSome objective.\n\n## Risks\n');
781
+ const result = await handlePlanCommand({ action: 'show', args: 'ws-abc-123' }, baseOpts({ workspaceCwd: tmpDir }));
782
+ expect(result).toContain('plan-001');
783
+ expect(result).toContain('ws-abc-123');
784
+ });
785
+ it('show — returns not found for unknown ID', async () => {
786
+ const tmpDir = await makeTmpDir();
787
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
788
+ const result = await handlePlanCommand({ action: 'show', args: 'plan-999' }, baseOpts({ workspaceCwd: tmpDir }));
789
+ expect(result).toContain('Plan not found');
790
+ });
791
+ it('show — returns usage when no args', async () => {
792
+ const result = await handlePlanCommand({ action: 'show', args: '' }, baseOpts());
793
+ expect(result).toContain('Usage');
794
+ });
795
+ it('approve — updates status to APPROVED and bead to in_progress', async () => {
796
+ const tmpDir = await makeTmpDir();
797
+ const plansDir = path.join(tmpDir, 'plans');
798
+ await fs.mkdir(plansDir, { recursive: true });
799
+ const filePath = path.join(plansDir, 'plan-001-test.md');
800
+ await fs.writeFile(filePath, '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** DRAFT\n**Project:** test\n**Created:** 2026-01-01\n');
801
+ const store = makeStore();
802
+ const updateSpy = vi.spyOn(store, 'update');
803
+ const result = await handlePlanCommand({ action: 'approve', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir, taskStore: store }));
804
+ expect(result).toContain('approved');
805
+ // Verify file was updated.
806
+ const content = await fs.readFile(filePath, 'utf-8');
807
+ expect(content).toContain('**Status:** APPROVED');
808
+ expect(content).not.toContain('**Status:** DRAFT');
809
+ // Verify task store update was attempted with in_progress.
810
+ expect(updateSpy).toHaveBeenCalledWith('ws-001', { status: 'in_progress' });
811
+ });
812
+ it('approve — returns usage when no args', async () => {
813
+ const result = await handlePlanCommand({ action: 'approve', args: '' }, baseOpts());
814
+ expect(result).toContain('Usage');
815
+ });
816
+ it('close — updates status to CLOSED and closes bead', async () => {
817
+ const tmpDir = await makeTmpDir();
818
+ const plansDir = path.join(tmpDir, 'plans');
819
+ await fs.mkdir(plansDir, { recursive: true });
820
+ const filePath = path.join(plansDir, 'plan-001-test.md');
821
+ await fs.writeFile(filePath, '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** APPROVED\n**Project:** test\n**Created:** 2026-01-01\n');
822
+ const store = makeStore();
823
+ const closeSpy = vi.spyOn(store, 'close');
824
+ const result = await handlePlanCommand({ action: 'close', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir, taskStore: store }));
825
+ expect(result).toContain('closed');
826
+ // Verify file was updated.
827
+ const content = await fs.readFile(filePath, 'utf-8');
828
+ expect(content).toContain('**Status:** CLOSED');
829
+ // Verify task store close was attempted.
830
+ expect(closeSpy).toHaveBeenCalledWith('ws-001', 'Plan closed');
831
+ });
832
+ it('close — returns usage when no args', async () => {
833
+ const result = await handlePlanCommand({ action: 'close', args: '' }, baseOpts());
834
+ expect(result).toContain('Usage');
835
+ });
836
+ it('close — returns not found for unknown ID', async () => {
837
+ const tmpDir = await makeTmpDir();
838
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
839
+ const result = await handlePlanCommand({ action: 'close', args: 'plan-999' }, baseOpts({ workspaceCwd: tmpDir }));
840
+ expect(result).toContain('Plan not found');
841
+ });
842
+ it('phases — returns usage when no args', async () => {
843
+ const result = await handlePlanCommand({ action: 'phases', args: '' }, baseOpts());
844
+ expect(result).toContain('Usage');
845
+ });
846
+ it('phases — returns not found for unknown plan', async () => {
847
+ const tmpDir = await makeTmpDir();
848
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
849
+ const result = await handlePlanCommand({ action: 'phases', args: 'plan-999' }, baseOpts({ workspaceCwd: tmpDir }));
850
+ expect(result).toContain('Plan not found');
851
+ });
852
+ it('phases — generates and returns phases checklist', async () => {
853
+ const tmpDir = await makeTmpDir();
854
+ const plansDir = path.join(tmpDir, 'plans');
855
+ await fs.mkdir(plansDir, { recursive: true });
856
+ const planContent = [
857
+ '# Plan: Test phases',
858
+ '',
859
+ '**ID:** plan-001',
860
+ '**Task:** ws-001',
861
+ '**Status:** APPROVED',
862
+ '**Project:** discoclaw',
863
+ '**Created:** 2026-02-12',
864
+ '',
865
+ '## Changes',
866
+ '',
867
+ '- `src/foo.ts` — add the foo module',
868
+ '- `src/foo.test.ts` — add tests',
869
+ '',
870
+ ].join('\n');
871
+ await fs.writeFile(path.join(plansDir, 'plan-001-test-phases.md'), planContent);
872
+ const result = await handlePlanCommand({ action: 'phases', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
873
+ expect(result).toContain('Phases for plan-001');
874
+ expect(result).toContain('phase-');
875
+ // Phases file should have been created
876
+ const phasesFile = path.join(plansDir, 'plan-001-phases.md');
877
+ const exists = await fs.access(phasesFile).then(() => true, () => false);
878
+ expect(exists).toBe(true);
879
+ });
880
+ it('phases — reads existing phases file without regenerating', async () => {
881
+ const tmpDir = await makeTmpDir();
882
+ const plansDir = path.join(tmpDir, 'plans');
883
+ await fs.mkdir(plansDir, { recursive: true });
884
+ const planContent = [
885
+ '# Plan: Test',
886
+ '',
887
+ '**ID:** plan-001',
888
+ '**Task:** ws-001',
889
+ '**Status:** APPROVED',
890
+ '**Project:** discoclaw',
891
+ '**Created:** 2026-02-12',
892
+ '',
893
+ '## Changes',
894
+ '',
895
+ '- `src/foo.ts` — add the foo module',
896
+ '',
897
+ ].join('\n');
898
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
899
+ // First call generates phases
900
+ await handlePlanCommand({ action: 'phases', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
901
+ // Read the generated file to confirm it exists
902
+ const phasesPath = path.join(plansDir, 'plan-001-phases.md');
903
+ const content1 = await fs.readFile(phasesPath, 'utf-8');
904
+ // Second call should read existing, not regenerate
905
+ const result2 = await handlePlanCommand({ action: 'phases', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
906
+ const content2 = await fs.readFile(phasesPath, 'utf-8');
907
+ expect(content1).toBe(content2);
908
+ expect(result2).toContain('Phases for plan-001');
909
+ });
910
+ it('help — includes phases, run, skip, audit commands', async () => {
911
+ const result = await handlePlanCommand({ action: 'help', args: '' }, baseOpts());
912
+ expect(result).toContain('!plan phases');
913
+ expect(result).toContain('!plan run');
914
+ expect(result).toContain('!plan skip');
915
+ expect(result).toContain('!plan audit');
916
+ });
917
+ it('approve — blocks when plan is IMPLEMENTING', async () => {
918
+ const tmpDir = await makeTmpDir();
919
+ const plansDir = path.join(tmpDir, 'plans');
920
+ await fs.mkdir(plansDir, { recursive: true });
921
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** IMPLEMENTING\n**Project:** test\n**Created:** 2026-01-01\n');
922
+ const result = await handlePlanCommand({ action: 'approve', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
923
+ expect(result).toContain('currently being implemented');
924
+ expect(result).toContain('!plan cancel plan-001');
925
+ });
926
+ it('close — blocks when plan is IMPLEMENTING', async () => {
927
+ const tmpDir = await makeTmpDir();
928
+ const plansDir = path.join(tmpDir, 'plans');
929
+ await fs.mkdir(plansDir, { recursive: true });
930
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** IMPLEMENTING\n**Project:** test\n**Created:** 2026-01-01\n');
931
+ const result = await handlePlanCommand({ action: 'close', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
932
+ expect(result).toContain('currently being implemented');
933
+ expect(result).toContain('!plan cancel plan-001');
934
+ });
935
+ });
936
+ // ---------------------------------------------------------------------------
937
+ // handlePlanSkip
938
+ // ---------------------------------------------------------------------------
939
+ describe('handlePlanSkip', () => {
940
+ it('returns not found for unknown plan', async () => {
941
+ const tmpDir = await makeTmpDir();
942
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
943
+ const result = await handlePlanSkip('plan-999', baseOpts({ workspaceCwd: tmpDir }));
944
+ expect(result).toContain('Plan not found');
945
+ });
946
+ it('returns error when no phases file exists', async () => {
947
+ const tmpDir = await makeTmpDir();
948
+ const plansDir = path.join(tmpDir, 'plans');
949
+ await fs.mkdir(plansDir, { recursive: true });
950
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** APPROVED\n**Project:** discoclaw\n**Created:** 2026-02-12\n');
951
+ const result = await handlePlanSkip('plan-001', baseOpts({ workspaceCwd: tmpDir }));
952
+ expect(result).toContain('No phases file found');
953
+ });
954
+ it('returns nothing to skip when no failed/in-progress phases', async () => {
955
+ const tmpDir = await makeTmpDir();
956
+ const plansDir = path.join(tmpDir, 'plans');
957
+ await fs.mkdir(plansDir, { recursive: true });
958
+ const planContent = [
959
+ '# Plan: Test',
960
+ '',
961
+ '**ID:** plan-001',
962
+ '**Task:** ws-001',
963
+ '**Status:** APPROVED',
964
+ '**Project:** discoclaw',
965
+ '**Created:** 2026-02-12',
966
+ '',
967
+ '## Changes',
968
+ '',
969
+ '- `src/foo.ts` — add foo',
970
+ '',
971
+ ].join('\n');
972
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
973
+ // Generate phases (all will be pending)
974
+ await handlePlanCommand({ action: 'phases', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
975
+ const result = await handlePlanSkip('plan-001', baseOpts({ workspaceCwd: tmpDir }));
976
+ expect(result).toBe('Nothing to skip.');
977
+ });
978
+ it('skips a failed phase and writes to disk', async () => {
979
+ const tmpDir = await makeTmpDir();
980
+ const plansDir = path.join(tmpDir, 'plans');
981
+ await fs.mkdir(plansDir, { recursive: true });
982
+ const planContent = [
983
+ '# Plan: Test',
984
+ '',
985
+ '**ID:** plan-001',
986
+ '**Task:** ws-001',
987
+ '**Status:** APPROVED',
988
+ '**Project:** discoclaw',
989
+ '**Created:** 2026-02-12',
990
+ '',
991
+ '## Changes',
992
+ '',
993
+ '- `src/foo.ts` — add foo',
994
+ '- `src/bar.ts` — add bar',
995
+ '',
996
+ ].join('\n');
997
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
998
+ // Generate phases
999
+ await handlePlanCommand({ action: 'phases', args: 'plan-001' }, baseOpts({ workspaceCwd: tmpDir }));
1000
+ // Manually edit phases file to mark phase-1 as failed
1001
+ const phasesPath = path.join(plansDir, 'plan-001-phases.md');
1002
+ const phasesJsonPath = path.join(plansDir, 'plan-001-phases.json');
1003
+ let phasesContent = await fs.readFile(phasesPath, 'utf-8');
1004
+ phasesContent = phasesContent.replace('**Status:** pending', '**Status:** failed');
1005
+ await fs.writeFile(phasesPath, phasesContent);
1006
+ const phasesJson = JSON.parse(await fs.readFile(phasesJsonPath, 'utf-8'));
1007
+ const firstPending = phasesJson.phases.find((p) => p.status === 'pending');
1008
+ if (firstPending)
1009
+ firstPending.status = 'failed';
1010
+ await fs.writeFile(phasesJsonPath, JSON.stringify(phasesJson, null, 2) + '\n', 'utf-8');
1011
+ const result = await handlePlanSkip('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1012
+ expect(result).toContain('Skipped');
1013
+ expect(result).toContain('was failed');
1014
+ // Verify the file was updated
1015
+ const updatedContent = await fs.readFile(phasesPath, 'utf-8');
1016
+ expect(updatedContent).toContain('**Status:** skipped');
1017
+ });
1018
+ });
1019
+ // ---------------------------------------------------------------------------
1020
+ // preparePlanRun
1021
+ // ---------------------------------------------------------------------------
1022
+ describe('preparePlanRun', () => {
1023
+ it('returns error for unknown plan', async () => {
1024
+ const tmpDir = await makeTmpDir();
1025
+ await fs.mkdir(path.join(tmpDir, 'plans'), { recursive: true });
1026
+ const result = await preparePlanRun('plan-999', baseOpts({ workspaceCwd: tmpDir }));
1027
+ expect('error' in result).toBe(true);
1028
+ if ('error' in result) {
1029
+ expect(result.error).toContain('Plan not found');
1030
+ }
1031
+ });
1032
+ it('rejects DRAFT plans with status gate error', async () => {
1033
+ const tmpDir = await makeTmpDir();
1034
+ const plansDir = path.join(tmpDir, 'plans');
1035
+ await fs.mkdir(plansDir, { recursive: true });
1036
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** DRAFT\n**Project:** discoclaw\n**Created:** 2026-02-12\n');
1037
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1038
+ expect('error' in result).toBe(true);
1039
+ if ('error' in result) {
1040
+ expect(result.error).toContain('DRAFT');
1041
+ expect(result.error).toContain('APPROVED or IMPLEMENTING');
1042
+ }
1043
+ });
1044
+ it('rejects REVIEW plans with status gate error', async () => {
1045
+ const tmpDir = await makeTmpDir();
1046
+ const plansDir = path.join(tmpDir, 'plans');
1047
+ await fs.mkdir(plansDir, { recursive: true });
1048
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** discoclaw\n**Created:** 2026-02-12\n');
1049
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1050
+ expect('error' in result).toBe(true);
1051
+ if ('error' in result) {
1052
+ expect(result.error).toContain('REVIEW');
1053
+ }
1054
+ });
1055
+ it('rejects CLOSED plans with status gate error', async () => {
1056
+ const tmpDir = await makeTmpDir();
1057
+ const plansDir = path.join(tmpDir, 'plans');
1058
+ await fs.mkdir(plansDir, { recursive: true });
1059
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** CLOSED\n**Project:** discoclaw\n**Created:** 2026-02-12\n');
1060
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1061
+ expect('error' in result).toBe(true);
1062
+ if ('error' in result) {
1063
+ expect(result.error).toContain('CLOSED');
1064
+ }
1065
+ });
1066
+ it('allows IMPLEMENTING plans through status gate', async () => {
1067
+ const tmpDir = await makeTmpDir();
1068
+ const plansDir = path.join(tmpDir, 'plans');
1069
+ await fs.mkdir(plansDir, { recursive: true });
1070
+ const planContent = [
1071
+ '# Plan: Test',
1072
+ '',
1073
+ '**ID:** plan-001',
1074
+ '**Task:** ws-001',
1075
+ '**Status:** IMPLEMENTING',
1076
+ '**Project:** discoclaw',
1077
+ '**Created:** 2026-02-12',
1078
+ '',
1079
+ '## Changes',
1080
+ '',
1081
+ '- `src/foo.ts` — add foo',
1082
+ '',
1083
+ ].join('\n');
1084
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
1085
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1086
+ expect('error' in result).toBe(false);
1087
+ });
1088
+ it('generates phases file if missing', async () => {
1089
+ const tmpDir = await makeTmpDir();
1090
+ const plansDir = path.join(tmpDir, 'plans');
1091
+ await fs.mkdir(plansDir, { recursive: true });
1092
+ const planContent = [
1093
+ '# Plan: Test',
1094
+ '',
1095
+ '**ID:** plan-001',
1096
+ '**Task:** ws-001',
1097
+ '**Status:** APPROVED',
1098
+ '**Project:** discoclaw',
1099
+ '**Created:** 2026-02-12',
1100
+ '',
1101
+ '## Changes',
1102
+ '',
1103
+ '- `src/foo.ts` — add foo',
1104
+ '',
1105
+ ].join('\n');
1106
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
1107
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1108
+ expect('error' in result).toBe(false);
1109
+ if (!('error' in result)) {
1110
+ expect(result.nextPhase).toBeDefined();
1111
+ expect(result.planContent).toBe(planContent);
1112
+ expect(result.phasesFilePath).toContain('plan-001-phases.md');
1113
+ }
1114
+ // Verify phases file was created
1115
+ const phasesExists = await fs.access(path.join(plansDir, 'plan-001-phases.md')).then(() => true, () => false);
1116
+ expect(phasesExists).toBe(true);
1117
+ });
1118
+ it('detects stale phases and returns error', async () => {
1119
+ const tmpDir = await makeTmpDir();
1120
+ const plansDir = path.join(tmpDir, 'plans');
1121
+ await fs.mkdir(plansDir, { recursive: true });
1122
+ const planContent = [
1123
+ '# Plan: Test',
1124
+ '',
1125
+ '**ID:** plan-001',
1126
+ '**Task:** ws-001',
1127
+ '**Status:** APPROVED',
1128
+ '**Project:** discoclaw',
1129
+ '**Created:** 2026-02-12',
1130
+ '',
1131
+ '## Changes',
1132
+ '',
1133
+ '- `src/foo.ts` — add foo',
1134
+ '',
1135
+ ].join('\n');
1136
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
1137
+ // Generate phases
1138
+ await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1139
+ // Now modify the plan content (make it stale)
1140
+ const modifiedContent = planContent + '\n\n## Extra section\n';
1141
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), modifiedContent);
1142
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1143
+ expect('error' in result).toBe(true);
1144
+ if ('error' in result) {
1145
+ expect(result.error).toContain('changed since phases');
1146
+ }
1147
+ });
1148
+ it('returns next phase info when ready', async () => {
1149
+ const tmpDir = await makeTmpDir();
1150
+ const plansDir = path.join(tmpDir, 'plans');
1151
+ await fs.mkdir(plansDir, { recursive: true });
1152
+ const planContent = [
1153
+ '# Plan: Test',
1154
+ '',
1155
+ '**ID:** plan-001',
1156
+ '**Task:** ws-001',
1157
+ '**Status:** APPROVED',
1158
+ '**Project:** discoclaw',
1159
+ '**Created:** 2026-02-12',
1160
+ '',
1161
+ '## Changes',
1162
+ '',
1163
+ '- `src/foo.ts` — add foo',
1164
+ '- `src/bar.ts` — add bar',
1165
+ '',
1166
+ ].join('\n');
1167
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
1168
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1169
+ expect('error' in result).toBe(false);
1170
+ if (!('error' in result)) {
1171
+ expect(result.nextPhase.status).toBe('pending');
1172
+ expect(result.nextPhase.kind).toBeDefined();
1173
+ expect(result.planFilePath).toContain('plan-001-test.md');
1174
+ }
1175
+ });
1176
+ it('returns error with NO_PHASES_SENTINEL when all phases are done', async () => {
1177
+ const tmpDir = await makeTmpDir();
1178
+ const plansDir = path.join(tmpDir, 'plans');
1179
+ await fs.mkdir(plansDir, { recursive: true });
1180
+ const planContent = [
1181
+ '# Plan: Test',
1182
+ '',
1183
+ '**ID:** plan-001',
1184
+ '**Task:** ws-001',
1185
+ '**Status:** APPROVED',
1186
+ '**Project:** discoclaw',
1187
+ '**Created:** 2026-02-12',
1188
+ '',
1189
+ '## Changes',
1190
+ '',
1191
+ '- `src/foo.ts` — add foo',
1192
+ '',
1193
+ ].join('\n');
1194
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), planContent);
1195
+ // Generate phases then mark them all done
1196
+ await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1197
+ const phasesPath = path.join(plansDir, 'plan-001-phases.md');
1198
+ const phasesJsonPath = path.join(plansDir, 'plan-001-phases.json');
1199
+ let content = await fs.readFile(phasesPath, 'utf-8');
1200
+ content = content.replace(/\*\*Status:\*\* pending/g, '**Status:** done');
1201
+ await fs.writeFile(phasesPath, content);
1202
+ const phasesJson = JSON.parse(await fs.readFile(phasesJsonPath, 'utf-8'));
1203
+ for (const phase of phasesJson.phases) {
1204
+ phase.status = 'done';
1205
+ }
1206
+ await fs.writeFile(phasesJsonPath, JSON.stringify(phasesJson, null, 2) + '\n', 'utf-8');
1207
+ const result = await preparePlanRun('plan-001', baseOpts({ workspaceCwd: tmpDir }));
1208
+ expect('error' in result).toBe(true);
1209
+ if ('error' in result) {
1210
+ expect(result.error).toContain(NO_PHASES_SENTINEL);
1211
+ expect(result.error.startsWith(NO_PHASES_SENTINEL)).toBe(true);
1212
+ }
1213
+ });
1214
+ });
1215
+ // ---------------------------------------------------------------------------
1216
+ // updatePlanFileStatus
1217
+ // ---------------------------------------------------------------------------
1218
+ describe('updatePlanFileStatus', () => {
1219
+ it('updates the status field in a plan file', async () => {
1220
+ const tmpDir = await makeTmpDir();
1221
+ const filePath = path.join(tmpDir, 'plan-001-test.md');
1222
+ await fs.writeFile(filePath, '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** DRAFT\n**Project:** test\n**Created:** 2026-01-01\n');
1223
+ await updatePlanFileStatus(filePath, 'APPROVED');
1224
+ const content = await fs.readFile(filePath, 'utf-8');
1225
+ expect(content).toContain('**Status:** APPROVED');
1226
+ expect(content).not.toContain('**Status:** DRAFT');
1227
+ });
1228
+ });
1229
+ // ---------------------------------------------------------------------------
1230
+ // listPlanFiles
1231
+ // ---------------------------------------------------------------------------
1232
+ describe('listPlanFiles', () => {
1233
+ it('returns parsed headers for all plan files', async () => {
1234
+ const tmpDir = await makeTmpDir();
1235
+ const plansDir = path.join(tmpDir, 'plans');
1236
+ await fs.mkdir(plansDir, { recursive: true });
1237
+ await fs.writeFile(path.join(plansDir, 'plan-001-alpha.md'), '# Plan: Alpha\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** DRAFT\n**Project:** test\n**Created:** 2026-01-01\n');
1238
+ await fs.writeFile(path.join(plansDir, 'plan-002-beta.md'), '# Plan: Beta\n\n**ID:** plan-002\n**Task:** ws-002\n**Status:** IMPLEMENTING\n**Project:** test\n**Created:** 2026-01-02\n');
1239
+ const results = await listPlanFiles(plansDir);
1240
+ expect(results).toHaveLength(2);
1241
+ expect(results.map((r) => r.header.planId).sort()).toEqual(['plan-001', 'plan-002']);
1242
+ expect(results[0].filePath).toContain('plans/');
1243
+ });
1244
+ it('skips dot-prefixed files and non-.md files', async () => {
1245
+ const tmpDir = await makeTmpDir();
1246
+ const plansDir = path.join(tmpDir, 'plans');
1247
+ await fs.mkdir(plansDir, { recursive: true });
1248
+ await fs.writeFile(path.join(plansDir, '.plan-template.md'), '**ID:** template\n');
1249
+ await fs.writeFile(path.join(plansDir, 'notes.txt'), 'not a plan');
1250
+ await fs.writeFile(path.join(plansDir, 'plan-001-real.md'), '# Plan: Real\n\n**ID:** plan-001\n**Status:** DRAFT\n');
1251
+ const results = await listPlanFiles(plansDir);
1252
+ expect(results).toHaveLength(1);
1253
+ expect(results[0].header.planId).toBe('plan-001');
1254
+ });
1255
+ it('returns empty array when directory does not exist', async () => {
1256
+ const results = await listPlanFiles('/tmp/nonexistent-plans-dir-12345');
1257
+ expect(results).toEqual([]);
1258
+ });
1259
+ it('skips files that fail to parse', async () => {
1260
+ const tmpDir = await makeTmpDir();
1261
+ const plansDir = path.join(tmpDir, 'plans');
1262
+ await fs.mkdir(plansDir, { recursive: true });
1263
+ await fs.writeFile(path.join(plansDir, 'bad-plan.md'), 'No valid header here');
1264
+ await fs.writeFile(path.join(plansDir, 'plan-001-good.md'), '# Plan: Good\n\n**ID:** plan-001\n**Status:** DRAFT\n');
1265
+ const results = await listPlanFiles(plansDir);
1266
+ expect(results).toHaveLength(1);
1267
+ expect(results[0].header.planId).toBe('plan-001');
1268
+ });
1269
+ });
1270
+ // ---------------------------------------------------------------------------
1271
+ // normalizePlanId
1272
+ // ---------------------------------------------------------------------------
1273
+ describe('normalizePlanId', () => {
1274
+ it('normalizes bare "031" to "plan-031"', () => {
1275
+ expect(normalizePlanId('031')).toBe('plan-031');
1276
+ });
1277
+ it('normalizes bare "31" to "plan-031"', () => {
1278
+ expect(normalizePlanId('31')).toBe('plan-031');
1279
+ });
1280
+ it('normalizes bare "1" to "plan-001"', () => {
1281
+ expect(normalizePlanId('1')).toBe('plan-001');
1282
+ });
1283
+ it('normalizes "plan-31" to "plan-031"', () => {
1284
+ expect(normalizePlanId('plan-31')).toBe('plan-031');
1285
+ });
1286
+ it('normalizes "plan-1" to "plan-001"', () => {
1287
+ expect(normalizePlanId('plan-1')).toBe('plan-001');
1288
+ });
1289
+ it('passes through "plan-031" as "plan-031"', () => {
1290
+ expect(normalizePlanId('plan-031')).toBe('plan-031');
1291
+ });
1292
+ it('returns null for bead IDs', () => {
1293
+ expect(normalizePlanId('workspace-abc')).toBeNull();
1294
+ });
1295
+ it('returns null for descriptions', () => {
1296
+ expect(normalizePlanId('add rate limiting')).toBeNull();
1297
+ });
1298
+ });
1299
+ // ---------------------------------------------------------------------------
1300
+ // looksLikePlanId
1301
+ // ---------------------------------------------------------------------------
1302
+ describe('looksLikePlanId', () => {
1303
+ it('returns true for bare numbers', () => {
1304
+ expect(looksLikePlanId('031')).toBe(true);
1305
+ expect(looksLikePlanId('31')).toBe(true);
1306
+ expect(looksLikePlanId('1')).toBe(true);
1307
+ });
1308
+ it('returns true for plan-N patterns', () => {
1309
+ expect(looksLikePlanId('plan-031')).toBe(true);
1310
+ expect(looksLikePlanId('plan-31')).toBe(true);
1311
+ });
1312
+ it('returns false for descriptions', () => {
1313
+ expect(looksLikePlanId('add rate limiting')).toBe(false);
1314
+ expect(looksLikePlanId('fix the bug')).toBe(false);
1315
+ expect(looksLikePlanId('31 flavors of ice cream')).toBe(false);
1316
+ });
1317
+ it('returns false for bead IDs', () => {
1318
+ expect(looksLikePlanId('workspace-abc')).toBe(false);
1319
+ expect(looksLikePlanId('ws-test-001')).toBe(false);
1320
+ });
1321
+ });
1322
+ // ---------------------------------------------------------------------------
1323
+ // findPlanFile — bare-number resolution
1324
+ // ---------------------------------------------------------------------------
1325
+ describe('findPlanFile — bare-number resolution', () => {
1326
+ it('resolves bare number "031" to "plan-031"', async () => {
1327
+ const tmpDir = await makeTmpDir();
1328
+ const plansDir = path.join(tmpDir, 'plans');
1329
+ await fs.mkdir(plansDir, { recursive: true });
1330
+ await fs.writeFile(path.join(plansDir, 'plan-031-test.md'), '# Plan: Test\n\n**ID:** plan-031\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1331
+ const result = await findPlanFile(plansDir, '031');
1332
+ expect(result).not.toBeNull();
1333
+ expect(result.header.planId).toBe('plan-031');
1334
+ });
1335
+ it('resolves unpadded number "31" to "plan-031"', async () => {
1336
+ const tmpDir = await makeTmpDir();
1337
+ const plansDir = path.join(tmpDir, 'plans');
1338
+ await fs.mkdir(plansDir, { recursive: true });
1339
+ await fs.writeFile(path.join(plansDir, 'plan-031-test.md'), '# Plan: Test\n\n**ID:** plan-031\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1340
+ const result = await findPlanFile(plansDir, '31');
1341
+ expect(result).not.toBeNull();
1342
+ expect(result.header.planId).toBe('plan-031');
1343
+ });
1344
+ it('resolves "plan-31" to "plan-031"', async () => {
1345
+ const tmpDir = await makeTmpDir();
1346
+ const plansDir = path.join(tmpDir, 'plans');
1347
+ await fs.mkdir(plansDir, { recursive: true });
1348
+ await fs.writeFile(path.join(plansDir, 'plan-031-test.md'), '# Plan: Test\n\n**ID:** plan-031\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1349
+ const result = await findPlanFile(plansDir, 'plan-31');
1350
+ expect(result).not.toBeNull();
1351
+ expect(result.header.planId).toBe('plan-031');
1352
+ });
1353
+ it('still resolves full "plan-031" format', async () => {
1354
+ const tmpDir = await makeTmpDir();
1355
+ const plansDir = path.join(tmpDir, 'plans');
1356
+ await fs.mkdir(plansDir, { recursive: true });
1357
+ await fs.writeFile(path.join(plansDir, 'plan-031-test.md'), '# Plan: Test\n\n**ID:** plan-031\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1358
+ const result = await findPlanFile(plansDir, 'plan-031');
1359
+ expect(result).not.toBeNull();
1360
+ expect(result.header.planId).toBe('plan-031');
1361
+ });
1362
+ it('still resolves bead ID', async () => {
1363
+ const tmpDir = await makeTmpDir();
1364
+ const plansDir = path.join(tmpDir, 'plans');
1365
+ await fs.mkdir(plansDir, { recursive: true });
1366
+ await fs.writeFile(path.join(plansDir, 'plan-031-test.md'), '# Plan: Test\n\n**ID:** plan-031\n**Task:** ws-special-bead\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1367
+ const result = await findPlanFile(plansDir, 'ws-special-bead');
1368
+ expect(result).not.toBeNull();
1369
+ expect(result.header.planId).toBe('plan-031');
1370
+ });
1371
+ it('returns null for number that does not match any plan', async () => {
1372
+ const tmpDir = await makeTmpDir();
1373
+ const plansDir = path.join(tmpDir, 'plans');
1374
+ await fs.mkdir(plansDir, { recursive: true });
1375
+ await fs.writeFile(path.join(plansDir, 'plan-001-test.md'), '# Plan: Test\n\n**ID:** plan-001\n**Task:** ws-001\n**Status:** REVIEW\n**Project:** test\n**Created:** 2026-01-01\n');
1376
+ const result = await findPlanFile(plansDir, '999');
1377
+ expect(result).toBeNull();
1378
+ });
1379
+ });
1380
+ // ---------------------------------------------------------------------------
1381
+ // closePlanIfComplete
1382
+ // ---------------------------------------------------------------------------
1383
+ function makePhasesFile(statuses) {
1384
+ const lines = [];
1385
+ lines.push('# Phases: plan-001 — workspace/plans/plan-001-test.md');
1386
+ lines.push('Created: 2026-02-16T00:00:00.000Z');
1387
+ lines.push('Updated: 2026-02-16T00:00:00.000Z');
1388
+ lines.push('Plan hash: abc123');
1389
+ lines.push('');
1390
+ for (let i = 0; i < statuses.length; i++) {
1391
+ lines.push(`## phase-${i + 1}: Phase ${i + 1}`);
1392
+ lines.push(`**Kind:** implement`);
1393
+ lines.push(`**Status:** ${statuses[i]}`);
1394
+ lines.push(`**Context:** (none)`);
1395
+ lines.push(`**Depends on:** (none)`);
1396
+ lines.push('');
1397
+ lines.push('Do the thing.');
1398
+ lines.push('');
1399
+ lines.push('---');
1400
+ lines.push('');
1401
+ }
1402
+ return lines.join('\n');
1403
+ }
1404
+ function makePlanFile(opts) {
1405
+ const lines = [
1406
+ '# Plan: Test',
1407
+ '',
1408
+ '**ID:** plan-001',
1409
+ ];
1410
+ // Only include Bead line if beadId is provided (non-empty).
1411
+ // parsePlanFileHeader's regex misbehaves on empty **Task:** lines.
1412
+ const beadId = opts.beadId ?? 'ws-001';
1413
+ if (beadId)
1414
+ lines.push(`**Task:** ${beadId}`);
1415
+ lines.push(`**Status:** ${opts.status}`);
1416
+ lines.push('**Project:** discoclaw');
1417
+ lines.push('**Created:** 2026-02-12');
1418
+ return lines.join('\n');
1419
+ }
1420
+ function makeAcquireLock() {
1421
+ const state = { lockCalls: 0, unlockCalls: 0 };
1422
+ const acquireLock = async () => {
1423
+ state.lockCalls++;
1424
+ return () => { state.unlockCalls++; };
1425
+ };
1426
+ return { acquireLock, ...state, get lockCalls() { return state.lockCalls; }, get unlockCalls() { return state.unlockCalls; } };
1427
+ }
1428
+ describe('closePlanIfComplete', () => {
1429
+ it('closes plan and bead when all phases are done', async () => {
1430
+ const tmpDir = await makeTmpDir();
1431
+ const phasesPath = path.join(tmpDir, 'phases.md');
1432
+ const planPath = path.join(tmpDir, 'plan.md');
1433
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'done']));
1434
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1435
+ const store = makeStore();
1436
+ const closeSpy = vi.spyOn(store, 'close');
1437
+ const lock = makeAcquireLock();
1438
+ const result = await closePlanIfComplete(phasesPath, planPath, store, lock.acquireLock);
1439
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1440
+ // Plan file should now be CLOSED
1441
+ const content = await fs.readFile(planPath, 'utf-8');
1442
+ expect(content).toContain('**Status:** CLOSED');
1443
+ // Bead close attempted on the task store
1444
+ expect(closeSpy).toHaveBeenCalledWith('ws-001', 'All phases complete');
1445
+ // Lock acquired and released exactly once
1446
+ expect(lock.lockCalls).toBe(1);
1447
+ expect(lock.unlockCalls).toBe(1);
1448
+ });
1449
+ it('closes plan when all phases are skipped', async () => {
1450
+ const tmpDir = await makeTmpDir();
1451
+ const phasesPath = path.join(tmpDir, 'phases.md');
1452
+ const planPath = path.join(tmpDir, 'plan.md');
1453
+ await fs.writeFile(phasesPath, makePhasesFile(['skipped', 'skipped']));
1454
+ await fs.writeFile(planPath, makePlanFile({ status: 'IMPLEMENTING' }));
1455
+ const lock = makeAcquireLock();
1456
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1457
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1458
+ });
1459
+ it('closes plan with mix of done and skipped phases', async () => {
1460
+ const tmpDir = await makeTmpDir();
1461
+ const phasesPath = path.join(tmpDir, 'phases.md');
1462
+ const planPath = path.join(tmpDir, 'plan.md');
1463
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'skipped', 'done']));
1464
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1465
+ const lock = makeAcquireLock();
1466
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1467
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1468
+ });
1469
+ it('returns not_all_complete when some phases are pending', async () => {
1470
+ const tmpDir = await makeTmpDir();
1471
+ const phasesPath = path.join(tmpDir, 'phases.md');
1472
+ const planPath = path.join(tmpDir, 'plan.md');
1473
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'pending']));
1474
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1475
+ const lock = makeAcquireLock();
1476
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1477
+ expect(result).toEqual({ closed: false, reason: 'not_all_complete' });
1478
+ // Plan status should be unchanged
1479
+ const content = await fs.readFile(planPath, 'utf-8');
1480
+ expect(content).toContain('**Status:** APPROVED');
1481
+ // Lock should still be released
1482
+ expect(lock.unlockCalls).toBe(1);
1483
+ });
1484
+ it('returns not_all_complete when a phase is in-progress', async () => {
1485
+ const tmpDir = await makeTmpDir();
1486
+ const phasesPath = path.join(tmpDir, 'phases.md');
1487
+ const planPath = path.join(tmpDir, 'plan.md');
1488
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'in-progress']));
1489
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1490
+ const lock = makeAcquireLock();
1491
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1492
+ expect(result).toEqual({ closed: false, reason: 'not_all_complete' });
1493
+ });
1494
+ it('returns not_all_complete when a phase is failed', async () => {
1495
+ const tmpDir = await makeTmpDir();
1496
+ const phasesPath = path.join(tmpDir, 'phases.md');
1497
+ const planPath = path.join(tmpDir, 'plan.md');
1498
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'failed']));
1499
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1500
+ const lock = makeAcquireLock();
1501
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1502
+ expect(result).toEqual({ closed: false, reason: 'not_all_complete' });
1503
+ });
1504
+ it('returns wrong_status for DRAFT plans', async () => {
1505
+ const tmpDir = await makeTmpDir();
1506
+ const phasesPath = path.join(tmpDir, 'phases.md');
1507
+ const planPath = path.join(tmpDir, 'plan.md');
1508
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'done']));
1509
+ await fs.writeFile(planPath, makePlanFile({ status: 'DRAFT' }));
1510
+ const lock = makeAcquireLock();
1511
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1512
+ expect(result).toEqual({ closed: false, reason: 'wrong_status' });
1513
+ // Plan should remain DRAFT
1514
+ const content = await fs.readFile(planPath, 'utf-8');
1515
+ expect(content).toContain('**Status:** DRAFT');
1516
+ });
1517
+ it('returns wrong_status for CLOSED plans', async () => {
1518
+ const tmpDir = await makeTmpDir();
1519
+ const phasesPath = path.join(tmpDir, 'phases.md');
1520
+ const planPath = path.join(tmpDir, 'plan.md');
1521
+ await fs.writeFile(phasesPath, makePhasesFile(['done']));
1522
+ await fs.writeFile(planPath, makePlanFile({ status: 'CLOSED' }));
1523
+ const lock = makeAcquireLock();
1524
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1525
+ expect(result).toEqual({ closed: false, reason: 'wrong_status' });
1526
+ });
1527
+ it('returns read_error when phases file does not exist', async () => {
1528
+ const tmpDir = await makeTmpDir();
1529
+ const planPath = path.join(tmpDir, 'plan.md');
1530
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1531
+ const lock = makeAcquireLock();
1532
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
1533
+ const result = await closePlanIfComplete(path.join(tmpDir, 'nonexistent-phases.md'), planPath, makeStore(), lock.acquireLock, log);
1534
+ expect(result).toEqual({ closed: false, reason: 'read_error' });
1535
+ expect(log.warn).toHaveBeenCalled();
1536
+ expect(lock.unlockCalls).toBe(1);
1537
+ });
1538
+ it('returns read_error when phases file is malformed', async () => {
1539
+ const tmpDir = await makeTmpDir();
1540
+ const phasesPath = path.join(tmpDir, 'phases.md');
1541
+ const planPath = path.join(tmpDir, 'plan.md');
1542
+ await fs.writeFile(phasesPath, 'this is not a valid phases file');
1543
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1544
+ const lock = makeAcquireLock();
1545
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
1546
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock, log);
1547
+ expect(result).toEqual({ closed: false, reason: 'read_error' });
1548
+ expect(log.warn).toHaveBeenCalled();
1549
+ expect(lock.unlockCalls).toBe(1);
1550
+ });
1551
+ it('returns read_error when plan file does not exist', async () => {
1552
+ const tmpDir = await makeTmpDir();
1553
+ const phasesPath = path.join(tmpDir, 'phases.md');
1554
+ await fs.writeFile(phasesPath, makePhasesFile(['done']));
1555
+ const lock = makeAcquireLock();
1556
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
1557
+ const result = await closePlanIfComplete(phasesPath, path.join(tmpDir, 'nonexistent-plan.md'), makeStore(), lock.acquireLock, log);
1558
+ expect(result).toEqual({ closed: false, reason: 'read_error' });
1559
+ expect(lock.unlockCalls).toBe(1);
1560
+ });
1561
+ it('skips bead close when beadId is empty', async () => {
1562
+ const tmpDir = await makeTmpDir();
1563
+ const phasesPath = path.join(tmpDir, 'phases.md');
1564
+ const planPath = path.join(tmpDir, 'plan.md');
1565
+ await fs.writeFile(phasesPath, makePhasesFile(['done']));
1566
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED', beadId: '' }));
1567
+ const store = makeStore();
1568
+ const closeSpy = vi.spyOn(store, 'close');
1569
+ const lock = makeAcquireLock();
1570
+ const result = await closePlanIfComplete(phasesPath, planPath, store, lock.acquireLock);
1571
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1572
+ expect(closeSpy).not.toHaveBeenCalled();
1573
+ });
1574
+ it('still closes plan when task store close fails (best-effort)', async () => {
1575
+ const tmpDir = await makeTmpDir();
1576
+ const phasesPath = path.join(tmpDir, 'phases.md');
1577
+ const planPath = path.join(tmpDir, 'plan.md');
1578
+ await fs.writeFile(phasesPath, makePhasesFile(['done']));
1579
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1580
+ const store = makeStore();
1581
+ vi.spyOn(store, 'close').mockImplementationOnce(() => { throw new Error('bead not found'); });
1582
+ const lock = makeAcquireLock();
1583
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
1584
+ const result = await closePlanIfComplete(phasesPath, planPath, store, lock.acquireLock, log);
1585
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1586
+ // Plan should still be CLOSED
1587
+ const content = await fs.readFile(planPath, 'utf-8');
1588
+ expect(content).toContain('**Status:** CLOSED');
1589
+ // Warning should have been logged
1590
+ expect(log.warn).toHaveBeenCalled();
1591
+ });
1592
+ it('releases lock even when updatePlanFileStatus throws', async () => {
1593
+ const tmpDir = await makeTmpDir();
1594
+ const phasesPath = path.join(tmpDir, 'phases.md');
1595
+ // Plan file path that exists for header parsing but will fail on write
1596
+ // (updatePlanFileStatus reads then writes — make the path a directory to force write failure)
1597
+ const planPath = path.join(tmpDir, 'plan.md');
1598
+ await fs.writeFile(phasesPath, makePhasesFile(['done']));
1599
+ await fs.writeFile(planPath, makePlanFile({ status: 'APPROVED' }));
1600
+ // Make plan file read-only to cause updatePlanFileStatus to fail on write
1601
+ await fs.chmod(planPath, 0o444);
1602
+ const lock = makeAcquireLock();
1603
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
1604
+ await expect(closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock, log)).rejects.toThrow();
1605
+ // Lock must still be released
1606
+ expect(lock.unlockCalls).toBe(1);
1607
+ // Restore permissions for cleanup
1608
+ await fs.chmod(planPath, 0o644);
1609
+ });
1610
+ it('accepts IMPLEMENTING status for auto-close', async () => {
1611
+ const tmpDir = await makeTmpDir();
1612
+ const phasesPath = path.join(tmpDir, 'phases.md');
1613
+ const planPath = path.join(tmpDir, 'plan.md');
1614
+ await fs.writeFile(phasesPath, makePhasesFile(['done', 'done']));
1615
+ await fs.writeFile(planPath, makePlanFile({ status: 'IMPLEMENTING' }));
1616
+ const lock = makeAcquireLock();
1617
+ const result = await closePlanIfComplete(phasesPath, planPath, makeStore(), lock.acquireLock);
1618
+ expect(result).toEqual({ closed: true, reason: 'all_phases_complete' });
1619
+ const content = await fs.readFile(planPath, 'utf-8');
1620
+ expect(content).toContain('**Status:** CLOSED');
1621
+ });
1622
+ });