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,191 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { findPlanFile, parsePlanFileHeader, } from './plan-commands.js';
4
+ import { appendAuditRound, buildAuditorPrompt } from './forge-commands.js';
5
+ import { parseAuditVerdict } from './forge-audit-verdict.js';
6
+ import { collectRuntimeText } from './runtime-utils.js';
7
+ import { getSection, parsePlan } from './plan-parser.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Structural audit checks (fast pre-flight gate)
10
+ // ---------------------------------------------------------------------------
11
+ const REQUIRED_SECTIONS = ['Objective', 'Scope', 'Changes', 'Risks', 'Testing'];
12
+ export function auditPlanStructure(content) {
13
+ const concerns = [];
14
+ const parsed = parsePlan(content);
15
+ // Check for required sections
16
+ for (const section of REQUIRED_SECTIONS) {
17
+ const hasSection = parsed.sections.has(section);
18
+ if (!hasSection) {
19
+ concerns.push({
20
+ title: `Missing section: ${section}`,
21
+ description: `The plan is missing the required "## ${section}" section.`,
22
+ severity: 'high',
23
+ });
24
+ continue;
25
+ }
26
+ const body = getSection(parsed, section).trim();
27
+ // Check if the section has meaningful content (not just placeholder text)
28
+ if (!body || /^_.*_$/.test(body) || body.startsWith('(') || body.length < 10) {
29
+ concerns.push({
30
+ title: `Empty or placeholder: ${section}`,
31
+ description: `The "${section}" section appears to contain only placeholder text.`,
32
+ severity: 'medium',
33
+ });
34
+ }
35
+ }
36
+ // Check for a Changes section with file paths
37
+ const changesBody = getSection(parsed, 'Changes').trim();
38
+ if (changesBody) {
39
+ const hasFilePaths = /`[^`]+\.[a-z]+`/.test(changesBody);
40
+ if (changesBody.length > 10 && !hasFilePaths) {
41
+ concerns.push({
42
+ title: 'Changes section lacks file paths',
43
+ description: 'The Changes section does not reference specific file paths. Plans should list concrete file-by-file changes.',
44
+ severity: 'medium',
45
+ });
46
+ }
47
+ }
48
+ // Check plan status
49
+ const header = parsePlanFileHeader(content);
50
+ if (header && header.status === 'CLOSED') {
51
+ concerns.push({
52
+ title: 'Plan is closed',
53
+ description: 'This plan has been closed. Auditing a closed plan is unusual.',
54
+ severity: 'low',
55
+ });
56
+ }
57
+ return concerns;
58
+ }
59
+ export function deriveVerdict(concerns) {
60
+ const hasHigh = concerns.some((c) => c.severity === 'high');
61
+ const hasMedium = concerns.some((c) => c.severity === 'medium');
62
+ if (hasHigh)
63
+ return { maxSeverity: 'blocking', shouldLoop: true };
64
+ if (hasMedium)
65
+ return { maxSeverity: 'medium', shouldLoop: true };
66
+ if (concerns.length > 0)
67
+ return { maxSeverity: 'minor', shouldLoop: false };
68
+ return { maxSeverity: 'none', shouldLoop: false };
69
+ }
70
+ function formatStructuralNotes(concerns) {
71
+ if (concerns.length === 0)
72
+ return '';
73
+ const lines = ['## Structural Pre-flight', ''];
74
+ for (let i = 0; i < concerns.length; i++) {
75
+ const c = concerns[i];
76
+ lines.push(`**Concern ${i + 1}: ${c.title}**`);
77
+ lines.push(c.description);
78
+ lines.push(`**Severity: ${c.severity}**`);
79
+ lines.push('');
80
+ }
81
+ return lines.join('\n');
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Max review number extraction (avoids duplicate round numbers)
85
+ // ---------------------------------------------------------------------------
86
+ export function maxReviewNumber(content) {
87
+ const matches = content.matchAll(/### Review (\d+)/g);
88
+ let max = 0;
89
+ for (const m of matches) {
90
+ const n = parseInt(m[1], 10);
91
+ if (n > max)
92
+ max = n;
93
+ }
94
+ return max;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Project context loader (inlined from ForgeOrchestrator pattern)
98
+ // ---------------------------------------------------------------------------
99
+ async function loadProjectContext(workspaceCwd) {
100
+ try {
101
+ const content = await fs.readFile(path.join(workspaceCwd, '.context', 'project.md'), 'utf-8');
102
+ return content.trim() || undefined;
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // Exported handler
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Run a standalone audit against an existing plan: structural pre-flight
113
+ * followed by an AI-powered deep review. Appends a new review entry to
114
+ * the plan's Audit Log section.
115
+ *
116
+ * The writer lock is only held during the final write phase (not during
117
+ * the AI agent call) to avoid blocking other plan operations.
118
+ */
119
+ export async function handlePlanAudit(opts) {
120
+ // 1. Find the plan file
121
+ const found = await findPlanFile(opts.plansDir, opts.planId);
122
+ if (!found)
123
+ return { ok: false, error: `Plan not found: ${opts.planId}` };
124
+ const planContent = await fs.readFile(found.filePath, 'utf-8');
125
+ // 2. Validate Audit Log section exists
126
+ if (!planContent.includes('## Audit Log')) {
127
+ return { ok: false, error: 'Plan file is missing an Audit Log section — cannot append audit.' };
128
+ }
129
+ // 3. Structural pre-flight (instant)
130
+ const structuralConcerns = auditPlanStructure(planContent);
131
+ const structuralVerdict = deriveVerdict(structuralConcerns);
132
+ // If structural audit finds high/medium issues, stop — no point burning tokens
133
+ if (structuralVerdict.shouldLoop) {
134
+ const structuralNotes = formatStructuralNotes(structuralConcerns);
135
+ const verdictLine = `**Verdict:** Needs revision.`;
136
+ const fullNotes = structuralNotes + verdictLine;
137
+ // Write under lock
138
+ const releaseLock = await opts.acquireWriterLock();
139
+ try {
140
+ const freshContent = await fs.readFile(found.filePath, 'utf-8');
141
+ const round = maxReviewNumber(freshContent) + 1;
142
+ const updated = appendAuditRound(freshContent, round, fullNotes, structuralVerdict);
143
+ const tmpPath = found.filePath + '.tmp';
144
+ await fs.writeFile(tmpPath, updated, 'utf-8');
145
+ await fs.rename(tmpPath, found.filePath);
146
+ return { ok: true, planId: found.header.planId, round, verdict: structuralVerdict };
147
+ }
148
+ finally {
149
+ releaseLock();
150
+ }
151
+ }
152
+ // 4. Load project context for the auditor
153
+ const projectContext = await loadProjectContext(opts.cwd);
154
+ // 5. Determine preliminary round number (for the auditor prompt)
155
+ const preliminaryRound = maxReviewNumber(planContent) + 1;
156
+ // 6. Invoke AI auditor agent (outside the lock)
157
+ let auditOutput;
158
+ try {
159
+ const rt = opts.auditorRuntime ?? opts.runtime;
160
+ const auditorHasFileTools = rt.capabilities.has('tools_fs');
161
+ const readOnlyTools = ['Read', 'Glob', 'Grep'];
162
+ const auditorPrompt = buildAuditorPrompt(planContent, preliminaryRound, projectContext, { hasTools: auditorHasFileTools });
163
+ auditOutput = await collectRuntimeText(rt, auditorPrompt, opts.auditorModel, opts.cwd, auditorHasFileTools ? readOnlyTools : [], auditorHasFileTools ? [opts.cwd] : [], opts.timeoutMs);
164
+ }
165
+ catch (err) {
166
+ return { ok: false, error: `Auditor agent failed: ${String(err instanceof Error ? err.message : err)}` };
167
+ }
168
+ // 7. Parse the AI verdict
169
+ const aiVerdict = parseAuditVerdict(auditOutput);
170
+ // 8. Combine structural notes (low-severity only, since we passed the gate) with AI output
171
+ const structuralPrefix = formatStructuralNotes(structuralConcerns);
172
+ const combinedNotes = structuralPrefix
173
+ ? structuralPrefix + '## AI Audit\n\n' + auditOutput.trim()
174
+ : auditOutput.trim();
175
+ // The AI verdict is the one that matters (structural passed the gate)
176
+ const finalVerdict = aiVerdict;
177
+ // 9. Acquire lock, re-read, and write atomically
178
+ const releaseLock = await opts.acquireWriterLock();
179
+ try {
180
+ const freshContent = await fs.readFile(found.filePath, 'utf-8');
181
+ const round = maxReviewNumber(freshContent) + 1;
182
+ const updated = appendAuditRound(freshContent, round, combinedNotes, finalVerdict);
183
+ const tmpPath = found.filePath + '.tmp';
184
+ await fs.writeFile(tmpPath, updated, 'utf-8');
185
+ await fs.rename(tmpPath, found.filePath);
186
+ return { ok: true, planId: found.header.planId, round, verdict: finalVerdict };
187
+ }
188
+ finally {
189
+ releaseLock();
190
+ }
191
+ }
@@ -0,0 +1,361 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { handlePlanAudit, auditPlanStructure, maxReviewNumber } from './audit-handler.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ async function makeTmpDir() {
10
+ return fs.mkdtemp(path.join(os.tmpdir(), 'audit-handler-test-'));
11
+ }
12
+ function makeMockRuntime(response) {
13
+ return {
14
+ id: 'claude_code',
15
+ capabilities: new Set(['streaming_text']),
16
+ invoke(_params) {
17
+ return (async function* () {
18
+ yield { type: 'text_final', text: response };
19
+ })();
20
+ },
21
+ };
22
+ }
23
+ function makeMockRuntimeError(message) {
24
+ return {
25
+ id: 'claude_code',
26
+ capabilities: new Set(['streaming_text']),
27
+ invoke(_params) {
28
+ return (async function* () {
29
+ yield { type: 'error', message };
30
+ })();
31
+ },
32
+ };
33
+ }
34
+ const MINIMAL_PLAN = `# Plan: Test plan
35
+
36
+ **ID:** plan-099
37
+ **Task:** workspace-test
38
+ **Created:** 2026-02-13
39
+ **Status:** APPROVED
40
+ **Project:** discoclaw
41
+
42
+ ---
43
+
44
+ ## Objective
45
+
46
+ Add a widget to the thing.
47
+
48
+ ## Scope
49
+
50
+ **In:**
51
+ - Add the widget module
52
+ - Write tests
53
+
54
+ **Out:**
55
+ - Changing existing widgets
56
+
57
+ ## Changes
58
+
59
+ ### File-by-file breakdown
60
+
61
+ - \`src/widget.ts\` — New file containing the widget implementation.
62
+ - \`src/widget.test.ts\` — Tests for the widget.
63
+
64
+ ### New files
65
+ - \`src/widget.ts\`
66
+ - \`src/widget.test.ts\`
67
+
68
+ ### Deleted files
69
+ _(none)_
70
+
71
+ ## Risks
72
+
73
+ - Widget might conflict with existing gizmo module.
74
+
75
+ ## Testing
76
+
77
+ - Unit tests for widget creation, deletion, and update.
78
+ - Integration test with the gizmo module.
79
+
80
+ ## Dependencies
81
+
82
+ _(none)_
83
+
84
+ ---
85
+
86
+ ## Audit Log
87
+
88
+ ---
89
+
90
+ ## Implementation Notes
91
+
92
+ _Filled in during/after implementation._
93
+ `;
94
+ function makeLockFn() {
95
+ const state = { released: false };
96
+ const fn = async () => {
97
+ return () => { state.released = true; };
98
+ };
99
+ return { fn, get released() { return state.released; } };
100
+ }
101
+ async function writeTestPlan(plansDir, content = MINIMAL_PLAN) {
102
+ await fs.mkdir(plansDir, { recursive: true });
103
+ const filePath = path.join(plansDir, 'plan-099-test-plan.md');
104
+ await fs.writeFile(filePath, content, 'utf-8');
105
+ return filePath;
106
+ }
107
+ function baseOpts(plansDir, runtime, lock, workspaceCwd, cwd) {
108
+ return {
109
+ planId: 'plan-099',
110
+ plansDir,
111
+ cwd: cwd ?? workspaceCwd,
112
+ workspaceCwd,
113
+ runtime,
114
+ auditorModel: 'test-model',
115
+ timeoutMs: 30_000,
116
+ acquireWriterLock: lock.fn,
117
+ };
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Unit tests: auditPlanStructure
121
+ // ---------------------------------------------------------------------------
122
+ describe('auditPlanStructure', () => {
123
+ it('returns no concerns for a complete plan', () => {
124
+ const concerns = auditPlanStructure(MINIMAL_PLAN);
125
+ expect(concerns).toEqual([]);
126
+ });
127
+ it('flags missing required sections', () => {
128
+ const content = `# Plan: Test\n\n## Objective\n\nDo the thing.\n\n## Scope\n\n**In:** stuff\n\n---\n\n## Audit Log\n`;
129
+ const concerns = auditPlanStructure(content);
130
+ const titles = concerns.map((c) => c.title);
131
+ expect(titles).toContain('Missing section: Changes');
132
+ expect(titles).toContain('Missing section: Risks');
133
+ expect(titles).toContain('Missing section: Testing');
134
+ });
135
+ it('flags placeholder sections', () => {
136
+ const content = MINIMAL_PLAN.replace('## Risks\n\n- Widget might conflict with existing gizmo module.', '## Risks\n\n_(TBD)_');
137
+ const concerns = auditPlanStructure(content);
138
+ expect(concerns.some((c) => c.title === 'Empty or placeholder: Risks')).toBe(true);
139
+ });
140
+ it('treats present-but-empty required sections as placeholder, not missing', () => {
141
+ const content = MINIMAL_PLAN.replace('## Risks\n\n- Widget might conflict with existing gizmo module.', '## Risks\n');
142
+ const concerns = auditPlanStructure(content);
143
+ expect(concerns.some((c) => c.title === 'Empty or placeholder: Risks')).toBe(true);
144
+ expect(concerns.some((c) => c.title === 'Missing section: Risks')).toBe(false);
145
+ });
146
+ it('flags Changes section without file paths', () => {
147
+ const content = MINIMAL_PLAN.replace(/## Changes[\s\S]*?## Risks/m, '## Changes\n\nWe will make some changes to the codebase to improve things significantly.\n\n## Risks');
148
+ const concerns = auditPlanStructure(content);
149
+ expect(concerns.some((c) => c.title === 'Changes section lacks file paths')).toBe(true);
150
+ });
151
+ });
152
+ // ---------------------------------------------------------------------------
153
+ // Unit tests: maxReviewNumber
154
+ // ---------------------------------------------------------------------------
155
+ describe('maxReviewNumber', () => {
156
+ it('returns 0 when no reviews exist', () => {
157
+ expect(maxReviewNumber(MINIMAL_PLAN)).toBe(0);
158
+ });
159
+ it('returns the max review number', () => {
160
+ const content = MINIMAL_PLAN.replace('## Audit Log\n', '## Audit Log\n\n### Review 1 — 2026-02-13\nStuff\n\n### Review 3 — 2026-02-13\nMore stuff\n');
161
+ expect(maxReviewNumber(content)).toBe(3);
162
+ });
163
+ });
164
+ // ---------------------------------------------------------------------------
165
+ // Integration tests: handlePlanAudit
166
+ // ---------------------------------------------------------------------------
167
+ describe('handlePlanAudit', () => {
168
+ let tmpDir;
169
+ let plansDir;
170
+ beforeEach(async () => {
171
+ tmpDir = await makeTmpDir();
172
+ plansDir = path.join(tmpDir, 'plans');
173
+ });
174
+ it('happy path: structural passes, AI audit appends review', async () => {
175
+ await writeTestPlan(plansDir);
176
+ const runtime = makeMockRuntime('No concerns found.\n\n**Verdict:** Ready to approve.');
177
+ const lock = makeLockFn();
178
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
179
+ expect(result.ok).toBe(true);
180
+ if (result.ok) {
181
+ expect(result.round).toBe(1);
182
+ // parseAuditVerdict returns 'minor' for "ready to approve" with no severity markers
183
+ expect(result.verdict.maxSeverity).toBe('minor');
184
+ expect(result.verdict.shouldLoop).toBe(false);
185
+ }
186
+ // Verify the plan file was updated
187
+ const updated = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
188
+ expect(updated).toContain('### Review 1');
189
+ expect(lock.released).toBe(true);
190
+ });
191
+ it('plan not found', async () => {
192
+ await fs.mkdir(plansDir, { recursive: true });
193
+ const runtime = makeMockRuntime('unused');
194
+ const lock = makeLockFn();
195
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
196
+ expect(result.ok).toBe(false);
197
+ if (!result.ok) {
198
+ expect(result.error).toContain('Plan not found');
199
+ }
200
+ });
201
+ it('missing Audit Log section', async () => {
202
+ const noAuditLog = MINIMAL_PLAN.replace('## Audit Log\n', '');
203
+ await writeTestPlan(plansDir, noAuditLog);
204
+ const runtime = makeMockRuntime('unused');
205
+ const lock = makeLockFn();
206
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
207
+ expect(result.ok).toBe(false);
208
+ if (!result.ok) {
209
+ expect(result.error).toContain('Audit Log');
210
+ }
211
+ });
212
+ it('structural gate stops on high severity — no AI call', async () => {
213
+ // Plan missing Changes, Risks, Testing sections
214
+ const badPlan = `# Plan: Bad plan
215
+
216
+ **ID:** plan-099
217
+ **Task:** workspace-test
218
+ **Created:** 2026-02-13
219
+ **Status:** DRAFT
220
+ **Project:** discoclaw
221
+
222
+ ---
223
+
224
+ ## Objective
225
+
226
+ Do something.
227
+
228
+ ## Scope
229
+
230
+ **In:** stuff
231
+
232
+ ---
233
+
234
+ ## Audit Log
235
+
236
+ ---
237
+
238
+ ## Implementation Notes
239
+
240
+ _Filled in during/after implementation._
241
+ `;
242
+ await writeTestPlan(plansDir, badPlan);
243
+ const invokespy = vi.fn();
244
+ const runtime = {
245
+ id: 'claude_code',
246
+ capabilities: new Set(['streaming_text']),
247
+ invoke: invokespy,
248
+ };
249
+ const lock = makeLockFn();
250
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
251
+ expect(result.ok).toBe(true);
252
+ if (result.ok) {
253
+ expect(result.verdict.maxSeverity).toBe('blocking');
254
+ expect(result.verdict.shouldLoop).toBe(true);
255
+ }
256
+ // AI agent should NOT have been called
257
+ expect(invokespy).not.toHaveBeenCalled();
258
+ expect(lock.released).toBe(true);
259
+ });
260
+ it('AI agent failure returns error without writing', async () => {
261
+ await writeTestPlan(plansDir);
262
+ const runtime = makeMockRuntimeError('Model overloaded');
263
+ const lock = makeLockFn();
264
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
265
+ expect(result.ok).toBe(false);
266
+ if (!result.ok) {
267
+ expect(result.error).toContain('Auditor agent failed');
268
+ }
269
+ // Plan file should be untouched
270
+ const content = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
271
+ expect(content).not.toContain('### Review');
272
+ });
273
+ it('round numbering with existing reviews', async () => {
274
+ const withReviews = MINIMAL_PLAN.replace('## Audit Log\n', '## Audit Log\n\n### Review 1 — 2026-02-13\n**Status:** COMPLETE\nStuff\n\n### Review 2 — 2026-02-13\n**Status:** COMPLETE\nMore stuff\n');
275
+ await writeTestPlan(plansDir, withReviews);
276
+ const runtime = makeMockRuntime('No concerns.\n\n**Verdict:** Ready to approve.');
277
+ const lock = makeLockFn();
278
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
279
+ expect(result.ok).toBe(true);
280
+ if (result.ok) {
281
+ expect(result.round).toBe(3);
282
+ }
283
+ });
284
+ it('lock released even on write failure', async () => {
285
+ await writeTestPlan(plansDir);
286
+ const runtime = makeMockRuntime('No concerns.\n\n**Verdict:** Ready to approve.');
287
+ const lock = makeLockFn();
288
+ // Make the plans directory read-only so the .tmp file creation fails.
289
+ // chmod on the file alone won't work — rename(2) only needs directory
290
+ // write permission, not file write permission.
291
+ await fs.chmod(plansDir, 0o555);
292
+ // handlePlanAudit will throw from the write phase (EACCES on .tmp creation),
293
+ // but the try/finally must still release the lock.
294
+ await expect(handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir))).rejects.toThrow();
295
+ expect(lock.released).toBe(true);
296
+ // Plan file should be unchanged (write never completed)
297
+ await fs.chmod(plansDir, 0o755);
298
+ const content = await fs.readFile(path.join(plansDir, 'plan-099-test-plan.md'), 'utf-8');
299
+ expect(content).not.toContain('### Review');
300
+ });
301
+ it('tools_fs runtime receives tools and addDirs pointing to project cwd', async () => {
302
+ await writeTestPlan(plansDir);
303
+ const invokeSpy = vi.fn();
304
+ const runtime = {
305
+ id: 'codex',
306
+ capabilities: new Set(['streaming_text', 'tools_fs']),
307
+ invoke(params) {
308
+ invokeSpy(params);
309
+ return (async function* () {
310
+ yield { type: 'text_final', text: 'No concerns.\n\n**Verdict:** Ready to approve.' };
311
+ })();
312
+ },
313
+ };
314
+ const lock = makeLockFn();
315
+ const projectCwd = path.join(tmpDir, 'project-root');
316
+ await fs.mkdir(projectCwd, { recursive: true });
317
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir, projectCwd));
318
+ expect(result.ok).toBe(true);
319
+ expect(invokeSpy).toHaveBeenCalledTimes(1);
320
+ const params = invokeSpy.mock.calls[0][0];
321
+ // Should receive the read-only tool list
322
+ expect(params.tools).toEqual(['Read', 'Glob', 'Grep']);
323
+ // addDirs should point to the project cwd, not workspaceCwd
324
+ expect(params.addDirs).toEqual([projectCwd]);
325
+ });
326
+ it('non-tools_fs runtime receives no tools or addDirs', async () => {
327
+ await writeTestPlan(plansDir);
328
+ const invokeSpy = vi.fn();
329
+ const runtime = {
330
+ id: 'codex',
331
+ capabilities: new Set(['streaming_text']),
332
+ invoke(params) {
333
+ invokeSpy(params);
334
+ return (async function* () {
335
+ yield { type: 'text_final', text: 'No concerns.\n\n**Verdict:** Ready to approve.' };
336
+ })();
337
+ },
338
+ };
339
+ const lock = makeLockFn();
340
+ const result = await handlePlanAudit(baseOpts(plansDir, runtime, lock, tmpDir));
341
+ expect(result.ok).toBe(true);
342
+ expect(invokeSpy).toHaveBeenCalledTimes(1);
343
+ const params = invokeSpy.mock.calls[0][0];
344
+ // Should receive empty tools
345
+ expect(params.tools).toEqual([]);
346
+ // addDirs should be undefined (collectRuntimeText converts [] to undefined)
347
+ expect(params.addDirs).toBeUndefined();
348
+ });
349
+ it('empty plan ID', async () => {
350
+ await fs.mkdir(plansDir, { recursive: true });
351
+ const runtime = makeMockRuntime('unused');
352
+ const lock = makeLockFn();
353
+ const opts = baseOpts(plansDir, runtime, lock, tmpDir);
354
+ opts.planId = '';
355
+ const result = await handlePlanAudit(opts);
356
+ expect(result.ok).toBe(false);
357
+ if (!result.ok) {
358
+ expect(result.error).toContain('not found');
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,141 @@
1
+ import { Client, GatewayIntentBits, Partials } from 'discord.js';
2
+ import { isAllowlisted } from './allowlist.js';
3
+ import { KeyedQueue } from './keyed-queue.js';
4
+ function discordSessionKey(msg) {
5
+ if (msg.isDm)
6
+ return `discord:dm:${msg.authorId}`;
7
+ return `discord:channel:${msg.channelId}`;
8
+ }
9
+ function splitDiscord(text, limit = 2000) {
10
+ // Minimal fence-safe markdown chunking.
11
+ const normalized = text.replace(/\r\n?/g, '\n');
12
+ if (normalized.length <= limit)
13
+ return [normalized];
14
+ const rawLines = normalized.split('\n');
15
+ const chunks = [];
16
+ let cur = '';
17
+ let inFence = false;
18
+ let fenceHeader = '```';
19
+ const ensureFenceOpen = () => {
20
+ if (cur)
21
+ return;
22
+ if (inFence)
23
+ cur = `${fenceHeader}\n`;
24
+ };
25
+ const flush = () => {
26
+ if (!cur)
27
+ return;
28
+ if (inFence && !cur.trimEnd().endsWith('```')) {
29
+ const close = '\n```';
30
+ if (cur.length + close.length <= limit) {
31
+ cur += close;
32
+ }
33
+ }
34
+ chunks.push(cur);
35
+ cur = '';
36
+ };
37
+ const appendLine = (line) => {
38
+ ensureFenceOpen();
39
+ const sep = cur.length > 0 ? '\n' : '';
40
+ cur += sep + line;
41
+ };
42
+ for (const line of rawLines) {
43
+ const nextLen = (cur.length ? cur.length + 1 : 0) + line.length;
44
+ if (nextLen > limit) {
45
+ flush();
46
+ // Reopen fence if we flushed mid-fence.
47
+ ensureFenceOpen();
48
+ }
49
+ // If the line itself is too long, hard split.
50
+ if (line.length > limit) {
51
+ let rest = line;
52
+ while (rest.length > 0) {
53
+ const room = Math.max(1, limit - (cur.length ? cur.length + 1 : 0));
54
+ const take = rest.slice(0, room);
55
+ appendLine(take);
56
+ rest = rest.slice(room);
57
+ if (rest.length > 0) {
58
+ flush();
59
+ ensureFenceOpen();
60
+ }
61
+ }
62
+ }
63
+ else {
64
+ appendLine(line);
65
+ }
66
+ const trimmed = line.trimStart();
67
+ if (trimmed.startsWith('```')) {
68
+ if (!inFence) {
69
+ inFence = true;
70
+ fenceHeader = trimmed.trimEnd();
71
+ }
72
+ else {
73
+ inFence = false;
74
+ fenceHeader = '```';
75
+ }
76
+ }
77
+ // If we are in a fence and we're close to the limit, proactively flush
78
+ // to reduce the chance of an un-closable fence close.
79
+ if (inFence && cur.length >= limit - 8) {
80
+ flush();
81
+ // Next line will reopen.
82
+ }
83
+ }
84
+ flush();
85
+ return chunks.filter((c) => c.trim().length > 0);
86
+ }
87
+ export async function startDiscordBot(params) {
88
+ const client = new Client({
89
+ intents: [
90
+ GatewayIntentBits.Guilds,
91
+ GatewayIntentBits.GuildMessages,
92
+ GatewayIntentBits.MessageContent,
93
+ GatewayIntentBits.DirectMessages,
94
+ ],
95
+ partials: [Partials.Channel],
96
+ });
97
+ const queue = new KeyedQueue();
98
+ client.on('messageCreate', async (msg) => {
99
+ if (!msg.author || msg.author.bot)
100
+ return;
101
+ if (!isAllowlisted(params.allowUserIds, msg.author.id))
102
+ return;
103
+ const isDm = msg.guildId == null;
104
+ const sessionKey = discordSessionKey({
105
+ channelId: msg.channelId,
106
+ authorId: msg.author.id,
107
+ isDm,
108
+ });
109
+ await queue.run(sessionKey, async () => {
110
+ const sessionId = await params.sessionManager.getOrCreate(sessionKey);
111
+ const reply = await msg.reply('...');
112
+ let finalText = '';
113
+ for await (const evt of params.runtime.invoke({
114
+ prompt: msg.content,
115
+ model: 'opus',
116
+ cwd: params.workspaceCwd,
117
+ sessionId,
118
+ tools: ['Bash', 'Read', 'Edit', 'WebSearch', 'WebFetch'],
119
+ timeoutMs: 10 * 60_000,
120
+ })) {
121
+ if (evt.type === 'text_final') {
122
+ finalText = evt.text;
123
+ }
124
+ else if (evt.type === 'error') {
125
+ finalText = `Error: ${evt.message}`;
126
+ }
127
+ else if (evt.type === 'text_delta' && !finalText) {
128
+ // Only use deltas when we don't get a final text payload.
129
+ finalText += evt.text;
130
+ }
131
+ }
132
+ const chunks = splitDiscord(finalText || '(no output)');
133
+ await reply.edit(chunks[0] ?? '(no output)');
134
+ for (const extra of chunks.slice(1)) {
135
+ await msg.channel.send(extra);
136
+ }
137
+ });
138
+ });
139
+ await client.login(params.token);
140
+ return client;
141
+ }