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,68 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared forge/plan lifecycle state
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Centralizes the orchestrator reference, running plan IDs, and workspace
6
+ // writer lock that were previously scattered across discord.ts module-level
7
+ // variables. Both human `!` commands and action-initiated forge/plan/memory
8
+ // operations coordinate through this registry.
9
+ // ---------------------------------------------------------------------------
10
+ // ---------------------------------------------------------------------------
11
+ // Workspace writer lock — serializes forge creates, plan phase runs, and
12
+ // memory mutations that touch the workspace filesystem.
13
+ // ---------------------------------------------------------------------------
14
+ let writerLockChain = Promise.resolve();
15
+ /**
16
+ * Acquire the workspace writer lock. Returns a release function.
17
+ * Callers must call `release()` when done, ideally in a finally block.
18
+ */
19
+ export function acquireWriterLock() {
20
+ let release;
21
+ const prev = writerLockChain;
22
+ writerLockChain = new Promise((resolve) => { release = resolve; });
23
+ return prev.then(() => release);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Active forge orchestrator
27
+ // ---------------------------------------------------------------------------
28
+ let _activeOrchestrator = null;
29
+ /** Set the active forge orchestrator (or null to clear). */
30
+ export function setActiveOrchestrator(orch) {
31
+ _activeOrchestrator = orch;
32
+ }
33
+ /** Get the active forge orchestrator, if any. */
34
+ export function getActiveOrchestrator() {
35
+ return _activeOrchestrator;
36
+ }
37
+ /** Returns the active forge plan ID if a forge is running, undefined otherwise. */
38
+ export function getActiveForgeId() {
39
+ return _activeOrchestrator?.activePlanId;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Running plan IDs — tracks which plans have active phase runs.
43
+ // ---------------------------------------------------------------------------
44
+ const _runningPlanIds = new Set();
45
+ /** Mark a plan as having an active phase run. */
46
+ export function addRunningPlan(planId) {
47
+ _runningPlanIds.add(planId);
48
+ }
49
+ /** Remove a plan from the active runs set. */
50
+ export function removeRunningPlan(planId) {
51
+ _runningPlanIds.delete(planId);
52
+ }
53
+ /** Check if a plan has an active phase run. */
54
+ export function isPlanRunning(planId) {
55
+ return _runningPlanIds.has(planId);
56
+ }
57
+ /** Get all currently running plan IDs (snapshot). */
58
+ export function getRunningPlanIds() {
59
+ return _runningPlanIds;
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Test helper — reset all state (for test isolation)
63
+ // ---------------------------------------------------------------------------
64
+ export function _resetForTest() {
65
+ writerLockChain = Promise.resolve();
66
+ _activeOrchestrator = null;
67
+ _runningPlanIds.clear();
68
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, beforeEach } from 'vitest';
2
+ import { acquireWriterLock, setActiveOrchestrator, getActiveOrchestrator, getActiveForgeId, addRunningPlan, removeRunningPlan, isPlanRunning, getRunningPlanIds, _resetForTest, } from './forge-plan-registry.js';
3
+ beforeEach(() => {
4
+ _resetForTest();
5
+ });
6
+ // ---------------------------------------------------------------------------
7
+ // Writer lock
8
+ // ---------------------------------------------------------------------------
9
+ describe('acquireWriterLock', () => {
10
+ it('serializes concurrent acquires', async () => {
11
+ const order = [];
12
+ const r1 = await acquireWriterLock();
13
+ const p2 = acquireWriterLock().then((r2) => {
14
+ order.push(2);
15
+ r2();
16
+ });
17
+ // r1 is held, so p2 should not have resolved yet
18
+ await Promise.resolve(); // flush microtasks
19
+ expect(order).toEqual([]);
20
+ order.push(1);
21
+ r1(); // release first lock
22
+ await p2;
23
+ expect(order).toEqual([1, 2]);
24
+ });
25
+ it('works for sequential acquire-release cycles', async () => {
26
+ const r1 = await acquireWriterLock();
27
+ r1();
28
+ const r2 = await acquireWriterLock();
29
+ r2();
30
+ // No deadlock — if we got here, it works
31
+ expect(true).toBe(true);
32
+ });
33
+ it('chains three acquires in order', async () => {
34
+ const order = [];
35
+ const r1 = await acquireWriterLock();
36
+ const p2 = acquireWriterLock().then((r) => { order.push(2); r(); });
37
+ const p3 = acquireWriterLock().then((r) => { order.push(3); r(); });
38
+ order.push(1);
39
+ r1();
40
+ await Promise.all([p2, p3]);
41
+ expect(order).toEqual([1, 2, 3]);
42
+ });
43
+ });
44
+ // ---------------------------------------------------------------------------
45
+ // Active forge orchestrator
46
+ // ---------------------------------------------------------------------------
47
+ describe('active orchestrator', () => {
48
+ it('starts as null', () => {
49
+ expect(getActiveOrchestrator()).toBeNull();
50
+ expect(getActiveForgeId()).toBeUndefined();
51
+ });
52
+ it('set and get', () => {
53
+ const fake = { activePlanId: 'plan-001' };
54
+ setActiveOrchestrator(fake);
55
+ expect(getActiveOrchestrator()).toBe(fake);
56
+ expect(getActiveForgeId()).toBe('plan-001');
57
+ });
58
+ it('clear', () => {
59
+ const fake = { activePlanId: 'plan-001' };
60
+ setActiveOrchestrator(fake);
61
+ setActiveOrchestrator(null);
62
+ expect(getActiveOrchestrator()).toBeNull();
63
+ expect(getActiveForgeId()).toBeUndefined();
64
+ });
65
+ it('returns undefined when orchestrator has no activePlanId', () => {
66
+ const fake = { activePlanId: undefined };
67
+ setActiveOrchestrator(fake);
68
+ expect(getActiveForgeId()).toBeUndefined();
69
+ });
70
+ });
71
+ // ---------------------------------------------------------------------------
72
+ // Running plan IDs
73
+ // ---------------------------------------------------------------------------
74
+ describe('running plan IDs', () => {
75
+ it('starts empty', () => {
76
+ expect(isPlanRunning('plan-001')).toBe(false);
77
+ expect(getRunningPlanIds().size).toBe(0);
78
+ });
79
+ it('add and check', () => {
80
+ addRunningPlan('plan-001');
81
+ expect(isPlanRunning('plan-001')).toBe(true);
82
+ expect(isPlanRunning('plan-002')).toBe(false);
83
+ expect(getRunningPlanIds().size).toBe(1);
84
+ });
85
+ it('remove', () => {
86
+ addRunningPlan('plan-001');
87
+ removeRunningPlan('plan-001');
88
+ expect(isPlanRunning('plan-001')).toBe(false);
89
+ expect(getRunningPlanIds().size).toBe(0);
90
+ });
91
+ it('remove non-existent is a no-op', () => {
92
+ removeRunningPlan('plan-999');
93
+ expect(getRunningPlanIds().size).toBe(0);
94
+ });
95
+ it('tracks multiple plans', () => {
96
+ addRunningPlan('plan-001');
97
+ addRunningPlan('plan-002');
98
+ expect(isPlanRunning('plan-001')).toBe(true);
99
+ expect(isPlanRunning('plan-002')).toBe(true);
100
+ expect(getRunningPlanIds().size).toBe(2);
101
+ removeRunningPlan('plan-001');
102
+ expect(isPlanRunning('plan-001')).toBe(false);
103
+ expect(isPlanRunning('plan-002')).toBe(true);
104
+ });
105
+ it('duplicate add is idempotent', () => {
106
+ addRunningPlan('plan-001');
107
+ addRunningPlan('plan-001');
108
+ expect(getRunningPlanIds().size).toBe(1);
109
+ });
110
+ });
111
+ // ---------------------------------------------------------------------------
112
+ // Reset
113
+ // ---------------------------------------------------------------------------
114
+ describe('_resetForTest', () => {
115
+ it('clears all state', async () => {
116
+ setActiveOrchestrator({ activePlanId: 'plan-001' });
117
+ addRunningPlan('plan-002');
118
+ // Acquire a lock and don't release — reset should clear the chain
119
+ await acquireWriterLock();
120
+ _resetForTest();
121
+ expect(getActiveOrchestrator()).toBeNull();
122
+ expect(isPlanRunning('plan-002')).toBe(false);
123
+ // Lock should be acquirable after reset (not stuck behind unreleased previous lock)
124
+ const r = await acquireWriterLock();
125
+ r();
126
+ });
127
+ });
@@ -0,0 +1,130 @@
1
+ import { ChannelType } from 'discord.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Helpers
4
+ // ---------------------------------------------------------------------------
5
+ /** Strip a trailing `・N` (or legacy ` ・ N`, `(N)`, `-・-N`) count suffix from a forum name. */
6
+ export function stripCountSuffix(name) {
7
+ let result = name;
8
+ // Loop to handle stacked corruption (e.g. "tasks-6-・-・-6" from multiple
9
+ // rounds of count-sync running on already-slugified names).
10
+ let prev;
11
+ do {
12
+ prev = result;
13
+ // Strip structured suffix: `・N`, ` ・ N`, `-・-N`, or `(N)`.
14
+ result = result.replace(/[\s-]*(?:・[\s-]*\d+|\(\d+\))$/, '');
15
+ // Clean up any trailing separator debris (lone `・` without a count digit).
16
+ result = result.replace(/[\s-]*・[\s-]*$/, '');
17
+ // Strip Discord-slugified numeric suffix (e.g. "tasks-6" from "tasks (6)").
18
+ // Greedy: a forum named "tasks-3" loses the "-3". Acceptable since
19
+ // count sync is the only thing that sets these suffixed names.
20
+ result = result.replace(/-\d+$/, '');
21
+ } while (result !== prev);
22
+ return result;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // ForumCountSync
26
+ // ---------------------------------------------------------------------------
27
+ const DEBOUNCE_MS = 10_000; // 10s debounce after last requestUpdate()
28
+ const MIN_INTERVAL_MS = 5 * 60_000; // 5min minimum between actual setName() calls
29
+ /**
30
+ * Keeps a forum channel name in sync with a dynamic item count.
31
+ * Aggressively debounces to stay within Discord's 2-per-10-min channel rename limit.
32
+ */
33
+ export class ForumCountSync {
34
+ client;
35
+ forumId;
36
+ countFn;
37
+ log;
38
+ debounceTimer = null;
39
+ deferredTimer = null;
40
+ lastUpdateMs = 0;
41
+ stopped = false;
42
+ constructor(client, forumId, countFn, log) {
43
+ this.client = client;
44
+ this.forumId = forumId;
45
+ this.countFn = countFn;
46
+ this.log = log;
47
+ }
48
+ /** Schedule a count update (debounced). */
49
+ requestUpdate() {
50
+ if (this.stopped)
51
+ return;
52
+ if (this.debounceTimer)
53
+ clearTimeout(this.debounceTimer);
54
+ this.debounceTimer = setTimeout(() => {
55
+ this.debounceTimer = null;
56
+ void this.execute();
57
+ }, DEBOUNCE_MS);
58
+ }
59
+ /** Cancel all pending timers. */
60
+ stop() {
61
+ this.stopped = true;
62
+ if (this.debounceTimer) {
63
+ clearTimeout(this.debounceTimer);
64
+ this.debounceTimer = null;
65
+ }
66
+ if (this.deferredTimer) {
67
+ clearTimeout(this.deferredTimer);
68
+ this.deferredTimer = null;
69
+ }
70
+ }
71
+ async execute() {
72
+ if (this.stopped)
73
+ return;
74
+ // Rate-limit: if last setName() was too recent, defer.
75
+ const now = Date.now();
76
+ const elapsed = now - this.lastUpdateMs;
77
+ if (elapsed < MIN_INTERVAL_MS && this.lastUpdateMs > 0) {
78
+ const remaining = MIN_INTERVAL_MS - elapsed;
79
+ this.log?.info({ forumId: this.forumId, deferMs: remaining }, 'forum-count-sync: deferred (rate limit)');
80
+ if (this.deferredTimer)
81
+ clearTimeout(this.deferredTimer);
82
+ this.deferredTimer = setTimeout(() => {
83
+ this.deferredTimer = null;
84
+ void this.execute();
85
+ }, remaining);
86
+ return;
87
+ }
88
+ let count;
89
+ try {
90
+ count = await this.countFn();
91
+ }
92
+ catch (err) {
93
+ this.log?.warn({ err, forumId: this.forumId }, 'forum-count-sync: countFn failed');
94
+ return;
95
+ }
96
+ const channel = this.client.channels.cache.get(this.forumId);
97
+ if (!channel || channel.type !== ChannelType.GuildForum) {
98
+ this.log?.warn({ forumId: this.forumId }, 'forum-count-sync: forum channel not found or not a forum');
99
+ return;
100
+ }
101
+ const currentName = channel.name;
102
+ const baseName = stripCountSuffix(currentName);
103
+ const newName = `${baseName}・${count}`;
104
+ if (newName === currentName) {
105
+ this.log?.info({ forumId: this.forumId, name: currentName }, 'forum-count-sync: name unchanged, skipping');
106
+ return;
107
+ }
108
+ try {
109
+ await channel.setName(newName);
110
+ this.lastUpdateMs = Date.now();
111
+ this.log?.info({ forumId: this.forumId, name: newName }, 'forum-count-sync: updated');
112
+ }
113
+ catch (err) {
114
+ // Handle Discord 429 rate limit.
115
+ const retryAfter = err?.retryAfter ?? err?.retry_after;
116
+ if (retryAfter && typeof retryAfter === 'number') {
117
+ const retryMs = retryAfter * 1000;
118
+ this.log?.warn({ forumId: this.forumId, retryAfter }, 'forum-count-sync: rate limited, rescheduling');
119
+ if (this.deferredTimer)
120
+ clearTimeout(this.deferredTimer);
121
+ this.deferredTimer = setTimeout(() => {
122
+ this.deferredTimer = null;
123
+ void this.execute();
124
+ }, retryMs);
125
+ return;
126
+ }
127
+ this.log?.warn({ err, forumId: this.forumId }, 'forum-count-sync: setName failed');
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ForumCountSync, stripCountSuffix } from './forum-count-sync.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ function mockChannel(name) {
7
+ return {
8
+ name,
9
+ type: 15, // ChannelType.GuildForum
10
+ setName: vi.fn(async () => { }),
11
+ };
12
+ }
13
+ function makeClient(channel) {
14
+ return {
15
+ channels: {
16
+ cache: {
17
+ get: vi.fn(() => channel),
18
+ },
19
+ },
20
+ };
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // stripCountSuffix
24
+ // ---------------------------------------------------------------------------
25
+ describe('stripCountSuffix', () => {
26
+ it('strips count suffix from "beads ・ 12"', () => {
27
+ expect(stripCountSuffix('beads ・ 12')).toBe('beads');
28
+ });
29
+ it('no-ops on name without suffix', () => {
30
+ expect(stripCountSuffix('automations')).toBe('automations');
31
+ });
32
+ it('strips count suffix from "my forum ・ 0"', () => {
33
+ expect(stripCountSuffix('my forum ・ 0')).toBe('my forum');
34
+ });
35
+ it('handles multiple spaces around separator', () => {
36
+ expect(stripCountSuffix('beads ・ 5')).toBe('beads');
37
+ });
38
+ it('strips Discord-slugified suffix "beads-6"', () => {
39
+ expect(stripCountSuffix('beads-6')).toBe('beads');
40
+ });
41
+ it('strips slugified suffix from multi-dash name "my-cool-forum-12"', () => {
42
+ expect(stripCountSuffix('my-cool-forum-12')).toBe('my-cool-forum');
43
+ });
44
+ it('strips mixed corruption "beads-6 ・ 5" back to base name', () => {
45
+ expect(stripCountSuffix('beads-6 ・ 5')).toBe('beads');
46
+ });
47
+ it('strips Discord-slugified separator "automations-・-1"', () => {
48
+ expect(stripCountSuffix('automations-・-1')).toBe('automations');
49
+ });
50
+ it('strips stacked slugified corruption "beads-6-・-・-6"', () => {
51
+ expect(stripCountSuffix('beads-6-・-・-6')).toBe('beads');
52
+ });
53
+ it('strips compact format "automations・1"', () => {
54
+ expect(stripCountSuffix('automations・1')).toBe('automations');
55
+ });
56
+ });
57
+ // ---------------------------------------------------------------------------
58
+ // ForumCountSync
59
+ // ---------------------------------------------------------------------------
60
+ describe('ForumCountSync', () => {
61
+ beforeEach(() => {
62
+ vi.useFakeTimers();
63
+ });
64
+ afterEach(() => {
65
+ vi.useRealTimers();
66
+ });
67
+ it('debounces multiple rapid requestUpdate() calls into single setName()', async () => {
68
+ const channel = mockChannel('beads');
69
+ const client = makeClient(channel);
70
+ const countFn = vi.fn(() => 5);
71
+ const sync = new ForumCountSync(client, 'forum-1', countFn);
72
+ sync.requestUpdate();
73
+ sync.requestUpdate();
74
+ sync.requestUpdate();
75
+ // Advance past debounce (10s).
76
+ await vi.advanceTimersByTimeAsync(10_000);
77
+ expect(countFn).toHaveBeenCalledTimes(1);
78
+ expect(channel.setName).toHaveBeenCalledTimes(1);
79
+ expect(channel.setName).toHaveBeenCalledWith('beads・5');
80
+ sync.stop();
81
+ });
82
+ it('rate-limits: second call within 5min defers to remaining time', async () => {
83
+ const channel = mockChannel('beads');
84
+ const client = makeClient(channel);
85
+ let count = 3;
86
+ const countFn = vi.fn(() => count);
87
+ const sync = new ForumCountSync(client, 'forum-1', countFn);
88
+ // First update.
89
+ sync.requestUpdate();
90
+ await vi.advanceTimersByTimeAsync(10_000);
91
+ expect(channel.setName).toHaveBeenCalledTimes(1);
92
+ expect(channel.setName).toHaveBeenCalledWith('beads・3');
93
+ // Second update shortly after.
94
+ count = 4;
95
+ // Update the channel mock name to reflect last setName.
96
+ channel.name = 'beads・3';
97
+ sync.requestUpdate();
98
+ await vi.advanceTimersByTimeAsync(10_000); // debounce fires, but rate limit defers
99
+ // Should not have made a second setName yet (rate-limited).
100
+ expect(channel.setName).toHaveBeenCalledTimes(1);
101
+ // Advance to remaining time (~5min - 20s = ~280s).
102
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
103
+ expect(channel.setName).toHaveBeenCalledTimes(2);
104
+ expect(channel.setName).toHaveBeenLastCalledWith('beads・4');
105
+ sync.stop();
106
+ });
107
+ it('no-op does not consume rate budget', async () => {
108
+ const channel = mockChannel('beads・5');
109
+ const client = makeClient(channel);
110
+ const countFn = vi.fn(() => 5);
111
+ const sync = new ForumCountSync(client, 'forum-1', countFn);
112
+ // First update: name unchanged, no setName call.
113
+ sync.requestUpdate();
114
+ await vi.advanceTimersByTimeAsync(10_000);
115
+ expect(countFn).toHaveBeenCalledTimes(1);
116
+ expect(channel.setName).not.toHaveBeenCalled();
117
+ // Second update shortly after with different count — should execute immediately
118
+ // (no rate limit since lastUpdateMs was never set).
119
+ countFn.mockReturnValue(6);
120
+ sync.requestUpdate();
121
+ await vi.advanceTimersByTimeAsync(10_000);
122
+ expect(channel.setName).toHaveBeenCalledTimes(1);
123
+ expect(channel.setName).toHaveBeenCalledWith('beads・6');
124
+ sync.stop();
125
+ });
126
+ it('deferred timer fires and executes the update', async () => {
127
+ const channel = mockChannel('beads');
128
+ const client = makeClient(channel);
129
+ let count = 1;
130
+ const countFn = vi.fn(() => count);
131
+ const log = { info: vi.fn(), warn: vi.fn() };
132
+ const sync = new ForumCountSync(client, 'forum-1', countFn, log);
133
+ // First update to set lastUpdateMs.
134
+ sync.requestUpdate();
135
+ await vi.advanceTimersByTimeAsync(10_000);
136
+ expect(channel.setName).toHaveBeenCalledTimes(1);
137
+ // Request another update (will be deferred).
138
+ count = 2;
139
+ channel.name = 'beads・1';
140
+ sync.requestUpdate();
141
+ await vi.advanceTimersByTimeAsync(10_000); // debounce fires
142
+ // Should have logged deferred.
143
+ expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ forumId: 'forum-1' }), expect.stringContaining('deferred'));
144
+ // Advance past the remaining rate limit time.
145
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
146
+ expect(channel.setName).toHaveBeenCalledTimes(2);
147
+ sync.stop();
148
+ });
149
+ it('countFn error is logged and does not throw or call setName', async () => {
150
+ const channel = mockChannel('beads');
151
+ const client = makeClient(channel);
152
+ const countFn = vi.fn(() => { throw new Error('count failed'); });
153
+ const log = { info: vi.fn(), warn: vi.fn() };
154
+ const sync = new ForumCountSync(client, 'forum-1', countFn, log);
155
+ sync.requestUpdate();
156
+ await vi.advanceTimersByTimeAsync(10_000);
157
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ forumId: 'forum-1' }), expect.stringContaining('countFn failed'));
158
+ expect(channel.setName).not.toHaveBeenCalled();
159
+ sync.stop();
160
+ });
161
+ it('429 error reschedules at retry_after duration', async () => {
162
+ const channel = mockChannel('beads');
163
+ const rateLimitError = Object.assign(new Error('rate limited'), { retryAfter: 30 });
164
+ channel.setName = vi.fn(async () => { throw rateLimitError; });
165
+ const client = makeClient(channel);
166
+ const countFn = vi.fn(() => 5);
167
+ const log = { info: vi.fn(), warn: vi.fn() };
168
+ const sync = new ForumCountSync(client, 'forum-1', countFn, log);
169
+ sync.requestUpdate();
170
+ await vi.advanceTimersByTimeAsync(10_000); // debounce fires, setName throws 429
171
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ forumId: 'forum-1', retryAfter: 30 }), expect.stringContaining('rate limited'));
172
+ // Fix setName for retry.
173
+ channel.setName = vi.fn(async () => { });
174
+ await vi.advanceTimersByTimeAsync(30_000); // retry_after fires
175
+ expect(channel.setName).toHaveBeenCalledWith('beads・5');
176
+ sync.stop();
177
+ });
178
+ it('recovers base name from slugified channel name "beads-6"', async () => {
179
+ const channel = mockChannel('beads-6');
180
+ const client = makeClient(channel);
181
+ const countFn = vi.fn(() => 5);
182
+ const sync = new ForumCountSync(client, 'forum-1', countFn);
183
+ sync.requestUpdate();
184
+ await vi.advanceTimersByTimeAsync(10_000);
185
+ expect(channel.setName).toHaveBeenCalledTimes(1);
186
+ expect(channel.setName).toHaveBeenCalledWith('beads・5');
187
+ sync.stop();
188
+ });
189
+ it('stop() cancels all pending timers', async () => {
190
+ const channel = mockChannel('beads');
191
+ const client = makeClient(channel);
192
+ const countFn = vi.fn(() => 5);
193
+ const sync = new ForumCountSync(client, 'forum-1', countFn);
194
+ sync.requestUpdate();
195
+ sync.stop();
196
+ await vi.advanceTimersByTimeAsync(10_000);
197
+ expect(countFn).not.toHaveBeenCalled();
198
+ expect(channel.setName).not.toHaveBeenCalled();
199
+ });
200
+ });
@@ -0,0 +1,98 @@
1
+ import { renderMemoryLine } from '../observability/memory-sampler.js';
2
+ export function parseHealthCommand(content) {
3
+ const normalized = String(content ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
4
+ if (normalized === '!health')
5
+ return 'basic';
6
+ if (normalized === '!health verbose')
7
+ return 'verbose';
8
+ if (normalized === '!health tools')
9
+ return 'tools';
10
+ return null;
11
+ }
12
+ function formatUptime(ms) {
13
+ const seconds = Math.max(0, Math.floor(ms / 1000));
14
+ const h = Math.floor(seconds / 3600);
15
+ const m = Math.floor((seconds % 3600) / 60);
16
+ const s = seconds % 60;
17
+ return `${h}h ${m}m ${s}s`;
18
+ }
19
+ function asCount(counters, name) {
20
+ return Number(counters[name] ?? 0);
21
+ }
22
+ function formatTaskSyncVerboseLines(counters) {
23
+ const started = asCount(counters, 'tasks.sync.started');
24
+ const succeeded = asCount(counters, 'tasks.sync.succeeded');
25
+ const failed = asCount(counters, 'tasks.sync.failed');
26
+ const coalesced = asCount(counters, 'tasks.sync.coalesced');
27
+ const durationTotalMs = asCount(counters, 'tasks.sync.duration_ms.total');
28
+ const durationSamples = asCount(counters, 'tasks.sync.duration_ms.samples');
29
+ const avgMs = durationSamples > 0
30
+ ? Math.round((durationTotalMs / durationSamples) * 100) / 100
31
+ : 0;
32
+ if (started === 0 && coalesced === 0) {
33
+ return ['Task sync: no runs yet'];
34
+ }
35
+ const lines = [];
36
+ lines.push(`Task sync: started=${started} ok=${succeeded} failed=${failed} coalesced=${coalesced} avgMs=${avgMs}`);
37
+ lines.push(`Task sync transitions: created=${asCount(counters, 'tasks.sync.transition.threads_created')} renamed=${asCount(counters, 'tasks.sync.transition.thread_names_updated')} starter=${asCount(counters, 'tasks.sync.transition.starter_messages_updated')} statuses=${asCount(counters, 'tasks.sync.transition.statuses_updated')} tags=${asCount(counters, 'tasks.sync.transition.tags_updated')} archived=${asCount(counters, 'tasks.sync.transition.threads_archived')} reconciled=${asCount(counters, 'tasks.sync.transition.threads_reconciled')} orphans=${asCount(counters, 'tasks.sync.transition.orphan_threads_found')} deferred=${asCount(counters, 'tasks.sync.transition.closes_deferred')} warnings=${asCount(counters, 'tasks.sync.transition.warnings')}`);
38
+ lines.push(`Task sync follow-up/retry: followUp=${asCount(counters, 'tasks.sync.follow_up.scheduled')}/${asCount(counters, 'tasks.sync.follow_up.triggered')}/${asCount(counters, 'tasks.sync.follow_up.succeeded')}/${asCount(counters, 'tasks.sync.follow_up.failed')} retry=${asCount(counters, 'tasks.sync.retry.scheduled')}/${asCount(counters, 'tasks.sync.retry.triggered')}/${asCount(counters, 'tasks.sync.retry.failed')} (coalesced=${asCount(counters, 'tasks.sync.retry.coalesced')} canceled=${asCount(counters, 'tasks.sync.retry.canceled')}) failureRetry=${asCount(counters, 'tasks.sync.failure_retry.scheduled')}/${asCount(counters, 'tasks.sync.failure_retry.triggered')}/${asCount(counters, 'tasks.sync.failure_retry.failed')} (coalesced=${asCount(counters, 'tasks.sync.failure_retry.coalesced')} canceled=${asCount(counters, 'tasks.sync.failure_retry.canceled')} disabled=${asCount(counters, 'tasks.sync.failure_retry.disabled')})`);
39
+ const tagMapReloadAttempts = asCount(counters, 'tasks.sync.tag_map_reload.attempted');
40
+ const tagMapReloadSucceeded = asCount(counters, 'tasks.sync.tag_map_reload.succeeded');
41
+ const tagMapReloadFailed = asCount(counters, 'tasks.sync.tag_map_reload.failed');
42
+ if (tagMapReloadAttempts > 0 || tagMapReloadSucceeded > 0 || tagMapReloadFailed > 0) {
43
+ lines.push(`Task sync tag-map reload: attempts=${tagMapReloadAttempts} ok=${tagMapReloadSucceeded} failed=${tagMapReloadFailed}`);
44
+ }
45
+ return lines;
46
+ }
47
+ export function renderHealthReport(opts) {
48
+ const snap = opts.metrics.snapshot();
49
+ const counters = snap.counters;
50
+ const lines = [];
51
+ lines.push(`${opts.botDisplayName ?? 'Discoclaw'} Health`);
52
+ lines.push(`Uptime: ${formatUptime(Date.now() - snap.startedAt)}`);
53
+ lines.push(`Queue depth: ${opts.queueDepth}`);
54
+ lines.push(`Messages: ${counters['discord.message.received'] ?? 0} | Reactions: ${counters['discord.reaction.received'] ?? 0} add / ${counters['discord.reaction_remove.received'] ?? 0} remove`);
55
+ lines.push(`Invokes: started=${(counters['invoke.message.started'] ?? 0) + (counters['invoke.reaction.started'] ?? 0) + (counters['invoke.cron.started'] ?? 0)} ` +
56
+ `ok=${(counters['invoke.message.succeeded'] ?? 0) + (counters['invoke.reaction.succeeded'] ?? 0) + (counters['invoke.cron.succeeded'] ?? 0)} ` +
57
+ `failed=${(counters['invoke.message.failed'] ?? 0) + (counters['invoke.reaction.failed'] ?? 0) + (counters['invoke.cron.failed'] ?? 0)}`);
58
+ lines.push(`Actions: ok=${counters['actions.succeeded'] ?? 0} failed=${counters['actions.failed'] ?? 0}`);
59
+ lines.push(`Cron runs: ok=${counters['cron.run.success'] ?? 0} error=${counters['cron.run.error'] ?? 0} skipped=${counters['cron.run.skipped'] ?? 0}`);
60
+ lines.push(`Latency(ms): msg p50=${snap.latencies.message.p50Ms} p95=${snap.latencies.message.p95Ms}; ` +
61
+ `reaction p50=${snap.latencies.reaction.p50Ms} p95=${snap.latencies.reaction.p95Ms}; ` +
62
+ `cron p50=${snap.latencies.cron.p50Ms} p95=${snap.latencies.cron.p95Ms}`);
63
+ if (opts.mode === 'verbose') {
64
+ lines.push('');
65
+ lines.push('Config (safe)');
66
+ lines.push(`runtimeModel=${opts.config.runtimeModel} timeoutMs=${opts.config.runtimeTimeoutMs} tools=${opts.config.runtimeTools.join(',')}`);
67
+ lines.push(`runtimeSessions=${opts.config.useRuntimeSessions} toolAwareStreaming=${opts.config.toolAwareStreaming} maxConcurrent=${opts.config.maxConcurrentInvocations}`);
68
+ lines.push(`actions=${opts.config.discordActionsEnabled} summary=${opts.config.summaryEnabled} durableMemory=${opts.config.durableMemoryEnabled}`);
69
+ lines.push(`historyBudget=${opts.config.messageHistoryBudget} requireChannelContext=${opts.config.requireChannelContext} autoIndexContext=${opts.config.autoIndexChannelContext}`);
70
+ const tasksActive = opts.config.tasksActive;
71
+ const tasksEnabled = opts.config.tasksEnabled;
72
+ const tasksState = tasksActive ? 'active' : tasksEnabled ? 'degraded' : 'off';
73
+ lines.push(`reactionHandler=${opts.config.reactionHandlerEnabled} reactionRemoveHandler=${opts.config.reactionRemoveHandlerEnabled} cron=${opts.config.cronEnabled} tasks=${tasksState}`);
74
+ lines.push(`taskSyncPolicy: failureRetry=${opts.config.tasksSyncFailureRetryEnabled ? 'on' : 'off'} failureDelayMs=${opts.config.tasksSyncFailureRetryDelayMs} deferredDelayMs=${opts.config.tasksSyncDeferredRetryDelayMs}`);
75
+ lines.push(...formatTaskSyncVerboseLines(counters));
76
+ if (snap.memory) {
77
+ lines.push(renderMemoryLine(snap.memory));
78
+ }
79
+ const errorClasses = Object.keys(counters)
80
+ .filter((k) => k.includes('.error_class.'))
81
+ .sort();
82
+ if (errorClasses.length > 0) {
83
+ lines.push('Error classes:');
84
+ for (const key of errorClasses) {
85
+ lines.push(`- ${key}=${counters[key]}`);
86
+ }
87
+ }
88
+ }
89
+ return `\`\`\`text\n${lines.join('\n')}\n\`\`\``;
90
+ }
91
+ export function renderHealthToolsReport(opts) {
92
+ const lines = [];
93
+ lines.push(`${opts.botDisplayName ?? 'Discoclaw'} Tools`);
94
+ lines.push(`Permission tier: ${opts.permissionTier}`);
95
+ lines.push(`Effective tools: ${opts.effectiveTools.length > 0 ? opts.effectiveTools.join(', ') : '(none)'}`);
96
+ lines.push(`Configured runtime tools: ${opts.configuredRuntimeTools.length > 0 ? opts.configuredRuntimeTools.join(', ') : '(none)'}`);
97
+ return `\`\`\`text\n${lines.join('\n')}\n\`\`\``;
98
+ }