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,612 @@
1
+ import fsSync from 'node:fs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { decomposePlan, readPhasesFile, getNextPhase, updatePhaseStatus, checkStaleness, writePhasesFile, } from './plan-manager.js';
5
+ import { getLatestAuditVerdictFromSection, getSection, parsePlan } from './plan-parser.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Parsing
8
+ // ---------------------------------------------------------------------------
9
+ const RESERVED_SUBCOMMANDS = new Set(['list', 'show', 'approve', 'close', 'cancel', 'help', 'phases', 'run', 'run-one', 'skip', 'audit']);
10
+ export function parsePlanCommand(content) {
11
+ const trimmed = content.trim();
12
+ if (!trimmed.startsWith('!plan'))
13
+ return null;
14
+ const rest = trimmed.slice('!plan'.length).trim();
15
+ // No args → help
16
+ if (!rest)
17
+ return { action: 'help', args: '' };
18
+ // Check reserved subcommands
19
+ const firstWord = rest.split(/\s+/)[0].toLowerCase();
20
+ if (RESERVED_SUBCOMMANDS.has(firstWord)) {
21
+ const subArgs = rest.slice(firstWord.length).trim();
22
+ return { action: firstWord, args: subArgs };
23
+ }
24
+ // Everything else is a create description
25
+ return { action: 'create', args: rest };
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // Slug generation
29
+ // ---------------------------------------------------------------------------
30
+ export function toSlug(description) {
31
+ return description
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '-')
34
+ .replace(/^-+|-+$/g, '')
35
+ .slice(0, 50)
36
+ .replace(/-+$/, '');
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Plan file header parsing
40
+ // ---------------------------------------------------------------------------
41
+ export function parsePlanFileHeader(content) {
42
+ const parsed = parsePlan(content);
43
+ const planId = parsed.metadata.get('ID')?.trim() ?? '';
44
+ if (!planId)
45
+ return null;
46
+ const taskId = parsed.metadata.get('Task')?.trim() ?? '';
47
+ return {
48
+ planId,
49
+ taskId,
50
+ status: parsed.metadata.get('Status')?.trim() ?? '',
51
+ title: parsed.title.trim(),
52
+ project: parsed.metadata.get('Project')?.trim() ?? '',
53
+ created: parsed.metadata.get('Created')?.trim() ?? '',
54
+ };
55
+ }
56
+ export function resolvePlanHeaderTaskId(header) {
57
+ return header.taskId?.trim() || '';
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Plan file utilities
61
+ // ---------------------------------------------------------------------------
62
+ async function ensurePlansDir(plansDir) {
63
+ await fs.mkdir(plansDir, { recursive: true });
64
+ }
65
+ async function getNextPlanNumber(plansDir) {
66
+ let entries;
67
+ try {
68
+ entries = await fs.readdir(plansDir);
69
+ }
70
+ catch {
71
+ return 1;
72
+ }
73
+ let max = 0;
74
+ for (const entry of entries) {
75
+ const match = entry.match(/^plan-(\d+)/);
76
+ if (match) {
77
+ const num = parseInt(match[1], 10);
78
+ if (num > max)
79
+ max = num;
80
+ }
81
+ }
82
+ return max + 1;
83
+ }
84
+ /**
85
+ * Normalize a bare number or unpadded plan-N string to canonical plan-NNN format.
86
+ * Returns null if the input doesn't look like a plan ID reference.
87
+ */
88
+ export function normalizePlanId(id) {
89
+ // Bare number: "031" or "31" → "plan-031"
90
+ const bareNum = id.match(/^(\d+)$/);
91
+ if (bareNum)
92
+ return `plan-${bareNum[1].padStart(3, '0')}`;
93
+ // Unpadded plan-N: "plan-31" → "plan-031"
94
+ const planNum = id.match(/^plan-(\d+)$/);
95
+ if (planNum)
96
+ return `plan-${planNum[1].padStart(3, '0')}`;
97
+ return null;
98
+ }
99
+ /**
100
+ * Check if a raw string looks like a plan-ID reference (bare number or plan-N pattern).
101
+ * Used to gate plan-ID lookups vs. new plan creation in the forge dispatch path.
102
+ */
103
+ export function looksLikePlanId(id) {
104
+ return /^\d+$/.test(id) || /^plan-\d+$/.test(id);
105
+ }
106
+ export async function findPlanFile(plansDir, id) {
107
+ let entries;
108
+ try {
109
+ entries = await fs.readdir(plansDir);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ const normalizedId = normalizePlanId(id);
115
+ for (const entry of entries) {
116
+ if (!entry.endsWith('.md') || entry.startsWith('.'))
117
+ continue;
118
+ const filePath = path.join(plansDir, entry);
119
+ const content = await fs.readFile(filePath, 'utf-8');
120
+ const header = parsePlanFileHeader(content);
121
+ if (!header)
122
+ continue;
123
+ const taskId = resolvePlanHeaderTaskId(header);
124
+ if (header.planId === id || taskId === id || (normalizedId && header.planId === normalizedId)) {
125
+ return { filePath, header };
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Update the status field in a plan file. Callers must hold the workspace writer lock.
132
+ */
133
+ export async function updatePlanFileStatus(filePath, newStatus) {
134
+ const content = await fs.readFile(filePath, 'utf-8');
135
+ const updated = content.replace(/^\*\*Status:\*\*\s*.+$/m, `**Status:** ${newStatus}`);
136
+ await fs.writeFile(filePath, updated, 'utf-8');
137
+ }
138
+ /**
139
+ * List all plan files in the plans directory, returning parsed headers with file paths.
140
+ * Errors on individual files are caught and skipped.
141
+ */
142
+ export async function listPlanFiles(plansDir) {
143
+ let entries;
144
+ try {
145
+ entries = await fs.readdir(plansDir);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ const results = [];
151
+ for (const entry of entries) {
152
+ if (!entry.endsWith('.md') || entry.startsWith('.'))
153
+ continue;
154
+ try {
155
+ const filePath = path.join(plansDir, entry);
156
+ const content = await fs.readFile(filePath, 'utf-8');
157
+ const header = parsePlanFileHeader(content);
158
+ if (header)
159
+ results.push({ filePath, header });
160
+ }
161
+ catch {
162
+ // skip unreadable files
163
+ }
164
+ }
165
+ return results;
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Inline fallback template (used when .plan-template.md is missing)
169
+ // ---------------------------------------------------------------------------
170
+ const FALLBACK_TEMPLATE = `# Plan: {{TITLE}}
171
+
172
+ **ID:** {{PLAN_ID}}
173
+ **Task:** {{TASK_ID}}
174
+ **Created:** {{DATE}}
175
+ **Status:** DRAFT
176
+ **Project:** {{PROJECT}}
177
+
178
+ ---
179
+
180
+ ## Objective
181
+
182
+ _Describe the objective here._
183
+
184
+ ## Scope
185
+
186
+ _Define what's in and out of scope._
187
+
188
+ ## Changes
189
+
190
+ _List file-by-file changes._
191
+
192
+ ## Risks
193
+
194
+ _Identify risks._
195
+
196
+ ## Testing
197
+
198
+ _How to verify._
199
+
200
+ ---
201
+
202
+ ## Audit Log
203
+
204
+ _Audit notes go here._
205
+
206
+ ---
207
+
208
+ ## Implementation Notes
209
+
210
+ _Filled in during/after implementation._
211
+ `;
212
+ function resolvePlansDir(opts) {
213
+ return opts.plansDir ?? path.join(opts.workspaceCwd, 'plans');
214
+ }
215
+ export async function createPlan(createOpts, opts) {
216
+ const description = createOpts.description.trim();
217
+ if (!description)
218
+ throw new Error('Usage: `!plan <description>`');
219
+ const plansDir = resolvePlansDir(opts);
220
+ await ensurePlansDir(plansDir);
221
+ const num = await getNextPlanNumber(plansDir);
222
+ const planId = `plan-${String(num).padStart(3, '0')}`;
223
+ const slug = toSlug(description);
224
+ const fileName = `${planId}-${slug}.md`;
225
+ const filePath = path.join(plansDir, fileName);
226
+ const date = new Date().toISOString().split('T')[0];
227
+ const trimmedContext = createOpts.context?.trim();
228
+ // Create backing task — or reuse existing one from task thread context.
229
+ let taskId;
230
+ const existingTaskId = createOpts.existingTaskId;
231
+ if (existingTaskId) {
232
+ taskId = existingTaskId;
233
+ // Ensure the reused task has the 'plan' label for label-based filtering.
234
+ try {
235
+ opts.taskStore.addLabel(taskId, 'plan');
236
+ }
237
+ catch {
238
+ // best-effort — label addition failure shouldn't block plan creation.
239
+ }
240
+ }
241
+ else {
242
+ try {
243
+ // Dedup: if an open task with a matching title already exists, reuse it.
244
+ const normalizedTitle = description.toLowerCase();
245
+ const existingTasks = opts.taskStore.list({ label: 'plan' });
246
+ const match = existingTasks.find((task) => task.status !== 'closed' && task.title.trim().toLowerCase() === normalizedTitle);
247
+ if (match) {
248
+ taskId = match.id;
249
+ }
250
+ else {
251
+ const task = opts.taskStore.create({
252
+ title: description,
253
+ labels: ['plan'],
254
+ ...(trimmedContext ? { description: trimmedContext.slice(0, 1800) } : {}),
255
+ });
256
+ taskId = task.id;
257
+ }
258
+ }
259
+ catch (err) {
260
+ throw new Error(`Failed to create backing task: ${String(err)}`);
261
+ }
262
+ }
263
+ // Load template or use fallback
264
+ let template;
265
+ const templatePath = path.join(plansDir, '.plan-template.md');
266
+ try {
267
+ template = await fs.readFile(templatePath, 'utf-8');
268
+ }
269
+ catch {
270
+ template = FALLBACK_TEMPLATE;
271
+ }
272
+ // Fill template
273
+ const content = template
274
+ .replace(/\{\{TITLE\}\}/g, description)
275
+ .replace(/\{\{PLAN_ID\}\}/g, planId)
276
+ .replace(/\{\{TASK_ID\}\}/g, taskId)
277
+ .replace(/\{\{DATE\}\}/g, date)
278
+ .replace(/\{\{PROJECT\}\}/g, 'discoclaw')
279
+ // Set status to DRAFT (remove the options list)
280
+ .replace(/\*\*Status:\*\*\s*DRAFT\s*\|[^\n]*/, '**Status:** DRAFT');
281
+ // Append reply context below the template body (keeps slug/task/title clean).
282
+ const contextSection = trimmedContext ? `\n## Context\n\n${trimmedContext}\n` : '';
283
+ const finalContent = content + contextSection;
284
+ await fs.writeFile(filePath, finalContent, 'utf-8');
285
+ const displayMessage = [
286
+ `Plan created: **${planId}** (task: \`${taskId}\`)`,
287
+ `File: \`workspace/plans/${fileName}\``,
288
+ `Description: ${description}`,
289
+ ].join('\n');
290
+ return { planId, taskId, filePath, fileName, description, displayMessage };
291
+ }
292
+ export async function handlePlanCommand(cmd, opts) {
293
+ const plansDir = resolvePlansDir(opts);
294
+ try {
295
+ if (cmd.action === 'help') {
296
+ return [
297
+ '**!plan commands:**',
298
+ '- `!plan <description>` — create a new plan',
299
+ '- `!plan list` — list active plans',
300
+ '- `!plan show <plan-id|task-id>` — show plan details',
301
+ '- `!plan approve <plan-id|task-id>` — approve for implementation',
302
+ '- `!plan close <plan-id|task-id>` — close/abandon a plan',
303
+ '- `!plan phases <plan-id>` — show/generate phase checklist',
304
+ '- `!plan run <plan-id>` — execute all remaining phases',
305
+ '- `!plan run-one <plan-id>` — execute next pending phase only',
306
+ '- `!plan skip <plan-id>` — skip a failed/in-progress phase',
307
+ '- `!plan audit <plan-id>` — run a standalone audit against a plan',
308
+ ].join('\n');
309
+ }
310
+ if (cmd.action === 'create') {
311
+ try {
312
+ const created = await createPlan({
313
+ description: cmd.args,
314
+ context: cmd.context,
315
+ existingTaskId: cmd.existingTaskId,
316
+ }, opts);
317
+ return created.displayMessage;
318
+ }
319
+ catch (err) {
320
+ return String(err instanceof Error ? err.message : err);
321
+ }
322
+ }
323
+ if (cmd.action === 'list') {
324
+ let entries;
325
+ try {
326
+ entries = await fs.readdir(plansDir);
327
+ }
328
+ catch {
329
+ return 'No plans directory found.';
330
+ }
331
+ const plans = [];
332
+ for (const entry of entries) {
333
+ if (!entry.endsWith('.md') || entry.startsWith('.'))
334
+ continue;
335
+ try {
336
+ const content = await fs.readFile(path.join(plansDir, entry), 'utf-8');
337
+ const header = parsePlanFileHeader(content);
338
+ if (header)
339
+ plans.push(header);
340
+ }
341
+ catch {
342
+ // skip unreadable files
343
+ }
344
+ }
345
+ if (plans.length === 0)
346
+ return 'No plans found.';
347
+ // Sort by planId
348
+ plans.sort((a, b) => a.planId.localeCompare(b.planId));
349
+ const lines = plans.map((p) => {
350
+ const taskId = resolvePlanHeaderTaskId(p);
351
+ return `- \`${p.planId}\` [${p.status}] — ${p.title}${taskId ? ` (task: \`${taskId}\`)` : ''}`;
352
+ });
353
+ return lines.join('\n');
354
+ }
355
+ if (cmd.action === 'show') {
356
+ if (!cmd.args)
357
+ return 'Usage: `!plan show <plan-id|task-id>`';
358
+ const found = await findPlanFile(plansDir, cmd.args);
359
+ if (!found)
360
+ return `Plan not found: ${cmd.args}`;
361
+ const content = await fs.readFile(found.filePath, 'utf-8');
362
+ const parsedPlan = parsePlan(content);
363
+ const objective = getSection(parsedPlan, 'Objective') || '(no objective)';
364
+ const auditSection = getSection(parsedPlan, 'Audit Log');
365
+ const latestVerdict = getLatestAuditVerdictFromSection(auditSection) ?? '(no audit yet)';
366
+ return [
367
+ `**${found.header.planId}** — ${found.header.title}`,
368
+ `Status: ${found.header.status}`,
369
+ `Task: \`${resolvePlanHeaderTaskId(found.header)}\``,
370
+ `Project: ${found.header.project}`,
371
+ `Created: ${found.header.created}`,
372
+ '',
373
+ `**Objective:** ${objective}`,
374
+ '',
375
+ `**Latest audit:** ${latestVerdict}`,
376
+ ].join('\n');
377
+ }
378
+ if (cmd.action === 'approve') {
379
+ if (!cmd.args)
380
+ return 'Usage: `!plan approve <plan-id|task-id>`';
381
+ const found = await findPlanFile(plansDir, cmd.args);
382
+ if (!found)
383
+ return `Plan not found: ${cmd.args}`;
384
+ if (found.header.status === 'IMPLEMENTING')
385
+ return `Plan is currently being implemented. Use \`!plan cancel ${found.header.planId}\` to stop it first.`;
386
+ await updatePlanFileStatus(found.filePath, 'APPROVED');
387
+ // Update backing task to in_progress.
388
+ const taskId = resolvePlanHeaderTaskId(found.header);
389
+ if (taskId) {
390
+ try {
391
+ opts.taskStore.update(taskId, { status: 'in_progress' });
392
+ }
393
+ catch {
394
+ // best-effort — task update failure shouldn't block approval.
395
+ }
396
+ }
397
+ return `Plan **${found.header.planId}** approved for implementation.`;
398
+ }
399
+ if (cmd.action === 'close') {
400
+ if (!cmd.args)
401
+ return 'Usage: `!plan close <plan-id|task-id>`';
402
+ const found = await findPlanFile(plansDir, cmd.args);
403
+ if (!found)
404
+ return `Plan not found: ${cmd.args}`;
405
+ if (found.header.status === 'IMPLEMENTING')
406
+ return `Plan is currently being implemented. Use \`!plan cancel ${found.header.planId}\` to stop it first.`;
407
+ await updatePlanFileStatus(found.filePath, 'CLOSED');
408
+ // Close backing task.
409
+ const taskId = resolvePlanHeaderTaskId(found.header);
410
+ if (taskId) {
411
+ try {
412
+ opts.taskStore.close(taskId, 'Plan closed');
413
+ }
414
+ catch {
415
+ // best-effort
416
+ }
417
+ }
418
+ return `Plan **${found.header.planId}** closed.`;
419
+ }
420
+ if (cmd.action === 'phases') {
421
+ if (!cmd.args)
422
+ return 'Usage: `!plan phases <plan-id>`';
423
+ // Parse --regenerate flag
424
+ const regenerate = cmd.args.includes('--regenerate');
425
+ const planIdArg = cmd.args.replace('--regenerate', '').trim();
426
+ if (!planIdArg)
427
+ return 'Usage: `!plan phases <plan-id>`';
428
+ const found = await findPlanFile(plansDir, planIdArg);
429
+ if (!found)
430
+ return `Plan not found: ${planIdArg}`;
431
+ const phasesFileName = `${found.header.planId}-phases.md`;
432
+ const phasesFilePath = path.join(plansDir, phasesFileName);
433
+ let phases;
434
+ const phasesFileExists = fsSync.existsSync(phasesFilePath);
435
+ if (!phasesFileExists || regenerate) {
436
+ // Generate phases
437
+ const planContent = await fs.readFile(found.filePath, 'utf-8');
438
+ const planRelPath = `workspace/plans/${path.basename(found.filePath)}`;
439
+ phases = decomposePlan(planContent, found.header.planId, planRelPath, opts.maxContextFiles);
440
+ writePhasesFile(phasesFilePath, phases);
441
+ }
442
+ else {
443
+ phases = readPhasesFile(phasesFilePath);
444
+ }
445
+ // Format checklist
446
+ return formatPhasesChecklist(phases);
447
+ }
448
+ // Note: 'run' and 'skip' are intercepted by discord.ts before reaching here.
449
+ return 'Unknown plan command. Try `!plan` for help.';
450
+ }
451
+ catch (err) {
452
+ return `Plan command error: ${String(err)}`;
453
+ }
454
+ }
455
+ // ---------------------------------------------------------------------------
456
+ // Phase status emoji
457
+ // ---------------------------------------------------------------------------
458
+ const STATUS_EMOJI = {
459
+ 'pending': '[ ]',
460
+ 'in-progress': '[~]',
461
+ 'done': '[x]',
462
+ 'failed': '[!]',
463
+ 'skipped': '[-]',
464
+ };
465
+ function formatPhasesChecklist(phases) {
466
+ const lines = [];
467
+ lines.push(`**Phases for ${phases.planId}** (hash: \`${phases.planContentHash}\`)`);
468
+ lines.push('');
469
+ for (const phase of phases.phases) {
470
+ const emoji = STATUS_EMOJI[phase.status] ?? '[ ]';
471
+ const deps = phase.dependsOn.length > 0 ? ` (depends: ${phase.dependsOn.join(', ')})` : '';
472
+ lines.push(`${emoji} **${phase.id}:** ${phase.title} [${phase.kind}]${deps}`);
473
+ if (phase.error)
474
+ lines.push(` Error: ${phase.error}`);
475
+ if (phase.gitCommit)
476
+ lines.push(` Commit: \`${phase.gitCommit}\``);
477
+ }
478
+ return lines.join('\n');
479
+ }
480
+ // ---------------------------------------------------------------------------
481
+ // Exported helpers for discord.ts
482
+ // ---------------------------------------------------------------------------
483
+ export async function handlePlanSkip(planId, opts) {
484
+ const plansDir = resolvePlansDir(opts);
485
+ const found = await findPlanFile(plansDir, planId);
486
+ if (!found)
487
+ return `Plan not found: ${planId}`;
488
+ const phasesFileName = `${found.header.planId}-phases.md`;
489
+ const phasesFilePath = path.join(plansDir, phasesFileName);
490
+ if (!fsSync.existsSync(phasesFilePath)) {
491
+ return `No phases file found for ${planId}. Run \`!plan phases ${planId}\` first.`;
492
+ }
493
+ let phases;
494
+ try {
495
+ phases = readPhasesFile(phasesFilePath);
496
+ }
497
+ catch (err) {
498
+ return `Failed to read phases file: ${String(err)}`;
499
+ }
500
+ // Find the first in-progress or failed phase
501
+ const target = phases.phases.find((p) => p.status === 'in-progress' || p.status === 'failed');
502
+ if (!target)
503
+ return 'Nothing to skip.';
504
+ phases = updatePhaseStatus(phases, target.id, 'skipped');
505
+ writePhasesFile(phasesFilePath, phases);
506
+ return `Skipped **${target.id}**: ${target.title} (was ${target.status})`;
507
+ }
508
+ export const NO_PHASES_SENTINEL = 'No phases to run';
509
+ const RUNNABLE_STATUSES = new Set(['APPROVED', 'IMPLEMENTING']);
510
+ export async function preparePlanRun(planId, opts) {
511
+ const plansDir = resolvePlansDir(opts);
512
+ const found = await findPlanFile(plansDir, planId);
513
+ if (!found)
514
+ return { error: `Plan not found: ${planId}` };
515
+ // Status gate: only run phases on approved or implementing plans
516
+ if (!RUNNABLE_STATUSES.has(found.header.status)) {
517
+ return { error: `Plan ${found.header.planId} has status ${found.header.status} — must be APPROVED or IMPLEMENTING to run.` };
518
+ }
519
+ const phasesFileName = `${found.header.planId}-phases.md`;
520
+ const phasesFilePath = path.join(plansDir, phasesFileName);
521
+ // Generate phases if needed
522
+ if (!fsSync.existsSync(phasesFilePath)) {
523
+ const planContent = await fs.readFile(found.filePath, 'utf-8');
524
+ const planRelPath = `workspace/plans/${path.basename(found.filePath)}`;
525
+ const phases = decomposePlan(planContent, found.header.planId, planRelPath, opts.maxContextFiles);
526
+ writePhasesFile(phasesFilePath, phases);
527
+ }
528
+ // Read and validate
529
+ let phases;
530
+ try {
531
+ phases = readPhasesFile(phasesFilePath);
532
+ }
533
+ catch (err) {
534
+ return { error: `Failed to read phases file: ${String(err)}` };
535
+ }
536
+ const planContent = await fs.readFile(found.filePath, 'utf-8');
537
+ const staleness = checkStaleness(phases, planContent);
538
+ if (staleness.stale)
539
+ return { error: staleness.message };
540
+ const nextPhase = getNextPhase(phases);
541
+ // NOTE: The multi-phase loop in discord.ts depends on NO_PHASES_SENTINEL only here
542
+ // (initial validation before the loop starts). The loop itself uses runNextPhase's
543
+ // `nothing_to_run` discriminated union result — not this sentinel string. If this
544
+ // error message is refactored, only the initial "already all done" detection breaks,
545
+ // and the failure mode is benign (user sees an error instead of "all done").
546
+ if (!nextPhase)
547
+ return { error: `${NO_PHASES_SENTINEL} — all done or dependencies unmet.` };
548
+ return {
549
+ phasesFilePath,
550
+ planFilePath: found.filePath,
551
+ planContent,
552
+ nextPhase,
553
+ };
554
+ }
555
+ // ---------------------------------------------------------------------------
556
+ // Auto-close plan when all phases are terminal
557
+ // ---------------------------------------------------------------------------
558
+ const CLOSEABLE_STATUSES = new Set(['APPROVED', 'IMPLEMENTING']);
559
+ const TERMINAL_PHASE_STATUSES = new Set(['done', 'skipped']);
560
+ export async function closePlanIfComplete(phasesFilePath, planFilePath, taskStore, acquireLock, log) {
561
+ let taskId;
562
+ const releaseLock = await acquireLock();
563
+ try {
564
+ let phases;
565
+ try {
566
+ phases = readPhasesFile(phasesFilePath, { log });
567
+ }
568
+ catch (err) {
569
+ log?.warn({ err, phasesFilePath }, 'closePlanIfComplete: failed to read phases state');
570
+ return { closed: false, reason: 'read_error' };
571
+ }
572
+ // Check whether every phase has a terminal status (done or skipped)
573
+ const allComplete = phases.phases.every((p) => TERMINAL_PHASE_STATUSES.has(p.status));
574
+ if (!allComplete) {
575
+ return { closed: false, reason: 'not_all_complete' };
576
+ }
577
+ // Read plan file header
578
+ let planContent;
579
+ try {
580
+ planContent = await fs.readFile(planFilePath, 'utf-8');
581
+ }
582
+ catch (err) {
583
+ log?.warn({ err, planFilePath }, 'closePlanIfComplete: failed to read plan file');
584
+ return { closed: false, reason: 'read_error' };
585
+ }
586
+ const header = parsePlanFileHeader(planContent);
587
+ if (!header) {
588
+ log?.warn({ planFilePath }, 'closePlanIfComplete: failed to parse plan file header');
589
+ return { closed: false, reason: 'read_error' };
590
+ }
591
+ // Plan-status gate: only auto-close plans that were approved for execution
592
+ if (!CLOSEABLE_STATUSES.has(header.status)) {
593
+ return { closed: false, reason: 'wrong_status' };
594
+ }
595
+ taskId = resolvePlanHeaderTaskId(header) || undefined;
596
+ // Close the plan (under lock, as updatePlanFileStatus requires)
597
+ await updatePlanFileStatus(planFilePath, 'CLOSED');
598
+ }
599
+ finally {
600
+ releaseLock();
601
+ }
602
+ // Best-effort task close (no lock needed).
603
+ if (taskId) {
604
+ try {
605
+ taskStore.close(taskId, 'All phases complete');
606
+ }
607
+ catch (err) {
608
+ log?.warn({ err, taskId }, 'closePlanIfComplete: failed to close task (best-effort)');
609
+ }
610
+ }
611
+ return { closed: true, reason: 'all_phases_complete' };
612
+ }