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,245 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { loadSummary, saveSummary, generateSummary } from './summarizer.js';
6
+ async function makeTmpDir() {
7
+ return fs.mkdtemp(path.join(os.tmpdir(), 'summarizer-test-'));
8
+ }
9
+ describe('loadSummary', () => {
10
+ it('returns null for missing file', async () => {
11
+ const dir = await makeTmpDir();
12
+ const result = await loadSummary(dir, 'nonexistent-session');
13
+ expect(result).toBeNull();
14
+ });
15
+ it('parses valid JSON file', async () => {
16
+ const dir = await makeTmpDir();
17
+ const data = { summary: 'test summary', updatedAt: 1000 };
18
+ await fs.writeFile(path.join(dir, 'test-session.json'), JSON.stringify(data), 'utf8');
19
+ const result = await loadSummary(dir, 'test-session');
20
+ expect(result).toEqual(data);
21
+ });
22
+ it('returns null on malformed JSON', async () => {
23
+ const dir = await makeTmpDir();
24
+ await fs.writeFile(path.join(dir, 'bad.json'), '{not json!!!', 'utf8');
25
+ const result = await loadSummary(dir, 'bad');
26
+ expect(result).toBeNull();
27
+ });
28
+ it('returns null when JSON lacks summary field', async () => {
29
+ const dir = await makeTmpDir();
30
+ await fs.writeFile(path.join(dir, 'no-summary.json'), '{"updatedAt":1}', 'utf8');
31
+ const result = await loadSummary(dir, 'no-summary');
32
+ expect(result).toBeNull();
33
+ });
34
+ it('returns turnsSinceUpdate when present in JSON', async () => {
35
+ const dir = await makeTmpDir();
36
+ const data = { summary: 'ctx', updatedAt: 1, turnsSinceUpdate: 3 };
37
+ await fs.writeFile(path.join(dir, 'with-turns.json'), JSON.stringify(data), 'utf8');
38
+ const result = await loadSummary(dir, 'with-turns');
39
+ expect(result).toEqual(data);
40
+ expect(result.turnsSinceUpdate).toBe(3);
41
+ });
42
+ it('loads old files without turnsSinceUpdate (backward compat)', async () => {
43
+ const dir = await makeTmpDir();
44
+ await fs.writeFile(path.join(dir, 'old-format.json'), JSON.stringify({ summary: 'old', updatedAt: 100 }), 'utf8');
45
+ const result = await loadSummary(dir, 'old-format');
46
+ expect(result).toEqual({ summary: 'old', updatedAt: 100 });
47
+ expect(result.turnsSinceUpdate).toBeUndefined();
48
+ });
49
+ });
50
+ describe('saveSummary', () => {
51
+ it('creates file with correct content', async () => {
52
+ const dir = await makeTmpDir();
53
+ const data = { summary: 'saved summary', updatedAt: 2000 };
54
+ await saveSummary(dir, 'save-test', data);
55
+ const raw = await fs.readFile(path.join(dir, 'save-test.json'), 'utf8');
56
+ expect(JSON.parse(raw)).toEqual(data);
57
+ });
58
+ it('overwrites existing file', async () => {
59
+ const dir = await makeTmpDir();
60
+ await saveSummary(dir, 'overwrite', { summary: 'old', updatedAt: 1 });
61
+ await saveSummary(dir, 'overwrite', { summary: 'new', updatedAt: 2 });
62
+ const raw = await fs.readFile(path.join(dir, 'overwrite.json'), 'utf8');
63
+ expect(JSON.parse(raw).summary).toBe('new');
64
+ });
65
+ it('persists turnsSinceUpdate field', async () => {
66
+ const dir = await makeTmpDir();
67
+ await saveSummary(dir, 'turns-persist', { summary: 's', updatedAt: 1, turnsSinceUpdate: 0 });
68
+ const raw = await fs.readFile(path.join(dir, 'turns-persist.json'), 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ expect(parsed.turnsSinceUpdate).toBe(0);
71
+ });
72
+ it('creates parent directory if missing', async () => {
73
+ const dir = await makeTmpDir();
74
+ const nested = path.join(dir, 'a', 'b', 'c');
75
+ await saveSummary(nested, 'nested', { summary: 'deep', updatedAt: 3 });
76
+ const raw = await fs.readFile(path.join(nested, 'nested.json'), 'utf8');
77
+ expect(JSON.parse(raw).summary).toBe('deep');
78
+ });
79
+ });
80
+ describe('generateSummary', () => {
81
+ const baseOpts = {
82
+ previousSummary: null,
83
+ recentExchange: '[User]: hello\n[Bot]: hi there',
84
+ model: 'haiku',
85
+ cwd: '/tmp',
86
+ maxChars: 2000,
87
+ timeoutMs: 30_000,
88
+ };
89
+ it('collects text_final into summary string', async () => {
90
+ const runtime = {
91
+ invoke: vi.fn(async function* () {
92
+ yield { type: 'text_delta', text: 'partial ' };
93
+ yield { type: 'text_final', text: 'User greeted the bot.' };
94
+ }),
95
+ };
96
+ const result = await generateSummary(runtime, baseOpts);
97
+ expect(result).toBe('User greeted the bot.');
98
+ });
99
+ it('collects text_delta when no text_final', async () => {
100
+ const runtime = {
101
+ invoke: vi.fn(async function* () {
102
+ yield { type: 'text_delta', text: 'User ' };
103
+ yield { type: 'text_delta', text: 'greeted the bot.' };
104
+ }),
105
+ };
106
+ const result = await generateSummary(runtime, baseOpts);
107
+ expect(result).toBe('User greeted the bot.');
108
+ });
109
+ it('returns previous summary on runtime error event', async () => {
110
+ const runtime = {
111
+ invoke: vi.fn(async function* () {
112
+ yield { type: 'error', message: 'timeout' };
113
+ }),
114
+ };
115
+ const result = await generateSummary(runtime, {
116
+ ...baseOpts,
117
+ previousSummary: 'existing summary',
118
+ });
119
+ expect(result).toBe('existing summary');
120
+ });
121
+ it('returns empty string on error when no previous summary', async () => {
122
+ const runtime = {
123
+ invoke: vi.fn(async function* () {
124
+ yield { type: 'error', message: 'timeout' };
125
+ }),
126
+ };
127
+ const result = await generateSummary(runtime, baseOpts);
128
+ expect(result).toBe('');
129
+ });
130
+ it('returns previous summary when runtime throws', async () => {
131
+ const runtime = {
132
+ invoke: vi.fn(async function* () {
133
+ throw new Error('network failure');
134
+ }),
135
+ };
136
+ const result = await generateSummary(runtime, {
137
+ ...baseOpts,
138
+ previousSummary: 'kept summary',
139
+ });
140
+ expect(result).toBe('kept summary');
141
+ });
142
+ it('passes correct prompt with previous summary', async () => {
143
+ let seenPrompt = '';
144
+ const runtime = {
145
+ invoke: vi.fn(async function* (p) {
146
+ seenPrompt = p.prompt;
147
+ yield { type: 'text_final', text: 'ok' };
148
+ }),
149
+ };
150
+ await generateSummary(runtime, {
151
+ ...baseOpts,
152
+ previousSummary: 'old context',
153
+ });
154
+ expect(seenPrompt).toContain('Current summary:\nold context');
155
+ expect(seenPrompt).toContain('[User]: hello');
156
+ });
157
+ it('passes empty tools array to runtime', async () => {
158
+ let seenTools;
159
+ const runtime = {
160
+ invoke: vi.fn(async function* (p) {
161
+ seenTools = p.tools;
162
+ yield { type: 'text_final', text: 'ok' };
163
+ }),
164
+ };
165
+ await generateSummary(runtime, baseOpts);
166
+ expect(seenTools).toEqual([]);
167
+ });
168
+ it('includes task status context section in prompt when provided', async () => {
169
+ let seenPrompt = '';
170
+ const runtime = {
171
+ invoke: vi.fn(async function* (p) {
172
+ seenPrompt = p.prompt;
173
+ yield { type: 'text_final', text: 'ok' };
174
+ }),
175
+ };
176
+ await generateSummary(runtime, {
177
+ ...baseOpts,
178
+ taskStatusContext: 't-001: open, "Fix login bug"',
179
+ });
180
+ expect(seenPrompt).toContain('Current task statuses:');
181
+ expect(seenPrompt).toContain('t-001: open, "Fix login bug"');
182
+ });
183
+ it('includes recently-closed tasks in task status context prompt', async () => {
184
+ let seenPrompt = '';
185
+ const runtime = {
186
+ invoke: vi.fn(async function* (p) {
187
+ seenPrompt = p.prompt;
188
+ yield { type: 'text_final', text: 'ok' };
189
+ }),
190
+ };
191
+ await generateSummary(runtime, {
192
+ ...baseOpts,
193
+ taskStatusContext: 't-002: in_progress, "Refactor auth"\nRecently closed:\nt-001: closed, "Fix login bug"',
194
+ });
195
+ expect(seenPrompt).toContain('Recently closed:');
196
+ expect(seenPrompt).toContain('t-001: closed, "Fix login bug"');
197
+ expect(seenPrompt).toContain('t-002: in_progress, "Refactor auth"');
198
+ });
199
+ it('prompt rule mentions recently closed tasks when taskStatusContext is provided', async () => {
200
+ let seenPrompt = '';
201
+ const runtime = {
202
+ invoke: vi.fn(async function* (p) {
203
+ seenPrompt = p.prompt;
204
+ yield { type: 'text_final', text: 'ok' };
205
+ }),
206
+ };
207
+ await generateSummary(runtime, {
208
+ ...baseOpts,
209
+ taskStatusContext: 'No active tasks.\nRecently closed:\nt-003: closed, "Deploy pipeline"',
210
+ });
211
+ expect(seenPrompt).toContain('Recently closed');
212
+ expect(seenPrompt).toContain('stale open');
213
+ });
214
+ it('omits task status rule and section when taskStatusContext is not provided', async () => {
215
+ let seenPrompt = '';
216
+ const runtime = {
217
+ invoke: vi.fn(async function* (p) {
218
+ seenPrompt = p.prompt;
219
+ yield { type: 'text_final', text: 'ok' };
220
+ }),
221
+ };
222
+ await generateSummary(runtime, baseOpts);
223
+ expect(seenPrompt).not.toContain('Current task statuses:');
224
+ expect(seenPrompt).not.toContain('Recently closed');
225
+ });
226
+ });
227
+ describe('safe session key', () => {
228
+ it('uses filesystem-safe characters', async () => {
229
+ const dir = await makeTmpDir();
230
+ // Session key with special chars like discord:dm:<userId>
231
+ await saveSummary(dir, 'discord:dm:12345', { summary: 'dm summary', updatedAt: 1 });
232
+ // The file should exist with colons preserved (they're allowed by the regex)
233
+ const files = await fs.readdir(dir);
234
+ expect(files).toHaveLength(1);
235
+ expect(files[0]).toBe('discord:dm:12345.json');
236
+ expect(files[0]).toMatch(/^[a-zA-Z0-9:_.-]+$/);
237
+ });
238
+ it('replaces unsafe characters with hyphens', async () => {
239
+ const dir = await makeTmpDir();
240
+ await saveSummary(dir, 'has spaces/and/slashes!', { summary: 'x', updatedAt: 1 });
241
+ const files = await fs.readdir(dir);
242
+ expect(files).toHaveLength(1);
243
+ expect(files[0]).toBe('has-spaces-and-slashes-.json');
244
+ });
245
+ });
@@ -0,0 +1,396 @@
1
+ import fs from 'node:fs/promises';
2
+ import { ChannelType } from 'discord.js';
3
+ import { stripCountSuffix } from './forum-count-sync.js';
4
+ function norm(s) {
5
+ return (s ?? '').trim().toLowerCase();
6
+ }
7
+ function isCountSuffixOnly(currentName, canonicalName) {
8
+ return norm(currentName) !== norm(canonicalName) &&
9
+ norm(stripCountSuffix(currentName)) === norm(canonicalName);
10
+ }
11
+ export function isSnowflake(s) {
12
+ return /^\d{8,}$/.test((s ?? '').trim());
13
+ }
14
+ export function selectBootstrapGuild(client, guildIdFromEnv, log) {
15
+ const gid = (guildIdFromEnv ?? '').trim();
16
+ if (gid) {
17
+ const g = client.guilds.cache.get(gid) ?? null;
18
+ if (!g)
19
+ log?.warn({ guildId: gid }, 'system-bootstrap: guild not found; skipping');
20
+ return g;
21
+ }
22
+ const guilds = [...client.guilds.cache.values()];
23
+ if (guilds.length === 1)
24
+ return guilds[0] ?? null;
25
+ log?.warn({ guildCount: guilds.length }, 'system-bootstrap: multiple guilds (or none) and DISCORD_GUILD_ID not set; skipping');
26
+ return null;
27
+ }
28
+ async function tryFetchChannel(guild, id) {
29
+ const trimmed = (id ?? '').trim();
30
+ if (!trimmed || !isSnowflake(trimmed))
31
+ return null;
32
+ try {
33
+ const fetched = await guild.channels.fetch(trimmed);
34
+ return fetched ?? null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function findByNameAndType(guild, name, type) {
41
+ const want = norm(name);
42
+ // Pass 1: exact name match.
43
+ const exact = guild.channels.cache.find((c) => c.type === type && norm(c.name) === want);
44
+ if (exact)
45
+ return exact;
46
+ // Pass 2: match after stripping count suffix (defense-in-depth).
47
+ const stripped = guild.channels.cache.find((c) => c.type === type && norm(stripCountSuffix(c.name)) === want);
48
+ return stripped ?? null;
49
+ }
50
+ function findAnyByName(guild, name) {
51
+ const want = norm(name);
52
+ const out = [];
53
+ for (const c of guild.channels.cache.values()) {
54
+ if (norm(c?.name ?? '') === want)
55
+ out.push(c);
56
+ }
57
+ return out;
58
+ }
59
+ async function moveUnderCategory(ch, parentCategoryId, log) {
60
+ const current = String(ch?.parentId ?? '');
61
+ if (current === parentCategoryId)
62
+ return false;
63
+ try {
64
+ if (typeof ch.setParent === 'function') {
65
+ await ch.setParent(parentCategoryId);
66
+ }
67
+ else if (typeof ch.edit === 'function') {
68
+ await ch.edit({ parent: parentCategoryId });
69
+ }
70
+ else {
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+ catch (err) {
76
+ log?.warn({ err, channelId: ch?.id, name: ch?.name }, 'system-bootstrap: failed to move channel');
77
+ return false;
78
+ }
79
+ }
80
+ export async function ensureSystemCategory(guild, log) {
81
+ const existing = findByNameAndType(guild, 'System', ChannelType.GuildCategory);
82
+ if (existing)
83
+ return existing;
84
+ try {
85
+ const created = await guild.channels.create({
86
+ name: 'System',
87
+ type: ChannelType.GuildCategory,
88
+ });
89
+ return created;
90
+ }
91
+ catch (err) {
92
+ log?.warn({ err }, 'system-bootstrap: failed to create System category');
93
+ return null;
94
+ }
95
+ }
96
+ async function ensureChild(guild, parentCategoryId, spec, log, existingId) {
97
+ // ID-first lookup when configured.
98
+ if (existingId && isSnowflake(existingId)) {
99
+ const byId = guild.channels.cache.get(existingId) ?? await tryFetchChannel(guild, existingId);
100
+ if (byId) {
101
+ if (byId.type !== spec.type) {
102
+ log?.error({ existingId, name: spec.name, expected: ChannelType[spec.type], actual: ChannelType[byId.type] }, 'system-bootstrap: configured ID has wrong channel type');
103
+ return { created: false, moved: false }; // fail closed — no ID returned
104
+ }
105
+ const moved = await moveUnderCategory(byId, parentCategoryId, log);
106
+ // Reconcile name if it differs from canonical (e.g. count-sync stacking).
107
+ const currentName = String(byId.name ?? '');
108
+ if (norm(currentName) !== norm(spec.name)) {
109
+ if (isCountSuffixOnly(currentName, spec.name)) {
110
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: skipping name reconciliation (count suffix only)');
111
+ }
112
+ else {
113
+ try {
114
+ await byId.edit({ name: spec.name });
115
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: reconciled name');
116
+ }
117
+ catch (err) {
118
+ log?.warn({ err, name: spec.name, was: currentName }, 'system-bootstrap: failed to reconcile name');
119
+ }
120
+ }
121
+ }
122
+ // Reconcile topic if it differs from expected.
123
+ if (spec.topic && byId.topic !== spec.topic) {
124
+ try {
125
+ await byId.edit({ topic: spec.topic });
126
+ log?.info({ name: spec.name }, 'system-bootstrap: reconciled topic');
127
+ }
128
+ catch (err) {
129
+ log?.warn({ err, name: spec.name }, 'system-bootstrap: failed to reconcile topic');
130
+ }
131
+ }
132
+ return { id: String(byId.id ?? ''), created: false, moved };
133
+ }
134
+ // ID not found — stale config or deleted channel. Fall through to name-based lookup / creation.
135
+ log?.warn({ existingId, name: spec.name }, 'system-bootstrap: configured channel ID not found in guild, falling back to name lookup');
136
+ }
137
+ const exact = findByNameAndType(guild, spec.name, spec.type);
138
+ if (exact) {
139
+ const moved = await moveUnderCategory(exact, parentCategoryId, log);
140
+ // Reconcile name if it differs from canonical (e.g. found via stripped count suffix).
141
+ const currentName = String(exact.name ?? '');
142
+ if (norm(currentName) !== norm(spec.name)) {
143
+ if (isCountSuffixOnly(currentName, spec.name)) {
144
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: skipping name reconciliation (count suffix only)');
145
+ }
146
+ else {
147
+ try {
148
+ await exact.edit({ name: spec.name });
149
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: reconciled name');
150
+ }
151
+ catch (err) {
152
+ log?.warn({ err, name: spec.name, was: currentName }, 'system-bootstrap: failed to reconcile name');
153
+ }
154
+ }
155
+ }
156
+ // Reconcile topic if it differs from expected.
157
+ if (spec.topic && exact.topic !== spec.topic) {
158
+ try {
159
+ await exact.edit({ topic: spec.topic });
160
+ log?.info({ name: spec.name }, 'system-bootstrap: reconciled topic');
161
+ }
162
+ catch (err) {
163
+ log?.warn({ err, name: spec.name }, 'system-bootstrap: failed to reconcile topic');
164
+ }
165
+ }
166
+ return { id: String(exact.id ?? ''), created: false, moved };
167
+ }
168
+ // Legacy name lookup — find by old names and reconcile to canonical.
169
+ if (spec.legacyNames) {
170
+ for (const legacyName of spec.legacyNames) {
171
+ const legacy = findByNameAndType(guild, legacyName, spec.type);
172
+ if (legacy) {
173
+ const moved = await moveUnderCategory(legacy, parentCategoryId, log);
174
+ const currentName = String(legacy.name ?? '');
175
+ if (norm(currentName) !== norm(spec.name)) {
176
+ if (isCountSuffixOnly(currentName, spec.name)) {
177
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: skipping name reconciliation (count suffix only)');
178
+ }
179
+ else {
180
+ try {
181
+ await legacy.edit({ name: spec.name });
182
+ log?.info({ name: spec.name, was: currentName }, 'system-bootstrap: reconciled name');
183
+ }
184
+ catch (err) {
185
+ log?.warn({ err, name: spec.name, was: currentName }, 'system-bootstrap: failed to reconcile name');
186
+ }
187
+ }
188
+ }
189
+ if (spec.topic && legacy.topic !== spec.topic) {
190
+ try {
191
+ await legacy.edit({ topic: spec.topic });
192
+ log?.info({ name: spec.name }, 'system-bootstrap: reconciled topic');
193
+ }
194
+ catch (err) {
195
+ log?.warn({ err, name: spec.name }, 'system-bootstrap: failed to reconcile topic');
196
+ }
197
+ }
198
+ return { id: String(legacy.id ?? ''), created: false, moved };
199
+ }
200
+ }
201
+ }
202
+ const nameClash = findAnyByName(guild, spec.name).filter((c) => c.type !== spec.type);
203
+ if (nameClash.length > 0) {
204
+ log?.warn({ name: spec.name, wantType: ChannelType[spec.type], foundTypes: nameClash.map((c) => ChannelType[c.type] ?? String(c.type)) }, 'system-bootstrap: name exists with different type; skipping creation');
205
+ return { created: false, moved: false };
206
+ }
207
+ try {
208
+ const created = await guild.channels.create({
209
+ name: spec.name,
210
+ type: spec.type,
211
+ parent: parentCategoryId,
212
+ topic: spec.topic,
213
+ });
214
+ return { id: String(created?.id ?? ''), created: true, moved: false };
215
+ }
216
+ catch (err) {
217
+ log?.warn({ err, name: spec.name, type: ChannelType[spec.type] }, 'system-bootstrap: failed to create channel');
218
+ return { created: false, moved: false };
219
+ }
220
+ }
221
+ export async function ensureSystemScaffold(params, log) {
222
+ const { guild, ensureTasks } = params;
223
+ const system = await ensureSystemCategory(guild, log);
224
+ if (!system)
225
+ return null;
226
+ const created = [];
227
+ const moved = [];
228
+ const status = await ensureChild(guild, system.id, { name: 'status', type: ChannelType.GuildText, topic: `${params.botDisplayName ?? 'Discoclaw'} status (online/offline/errors).` }, log);
229
+ if (status.created)
230
+ created.push('status');
231
+ if (status.moved)
232
+ moved.push('status');
233
+ const crons = await ensureChild(guild, system.id, { name: 'automations', type: ChannelType.GuildForum, topic: 'Automations are managed by the bot. Use bot commands to create scheduled tasks. Do not create threads manually — they will be archived.', legacyNames: ['agents'] }, log, params.existingCronsId);
234
+ if (crons.created)
235
+ created.push('automations');
236
+ if (crons.moved)
237
+ moved.push('automations');
238
+ let tasks = null;
239
+ if (ensureTasks) {
240
+ tasks = await ensureChild(guild, system.id, { name: 'tasks', type: ChannelType.GuildForum, topic: 'Tasks are managed by the bot. Use task commands or actions to create tasks. Do not create threads manually — they will be archived.' }, log, params.existingTasksId);
241
+ if (tasks.created)
242
+ created.push('tasks');
243
+ if (tasks.moved)
244
+ moved.push('tasks');
245
+ // Bootstrap status tags on the tasks forum.
246
+ if (tasks.id && params.tasksTagMapPath) {
247
+ try {
248
+ await ensureForumTags(guild, tasks.id, params.tasksTagMapPath, { log });
249
+ }
250
+ catch (err) {
251
+ log?.warn({ err, forumId: tasks.id }, 'system-bootstrap: tasks forum tag bootstrap failed');
252
+ }
253
+ }
254
+ }
255
+ if (created.length > 0 || moved.length > 0) {
256
+ log?.info({
257
+ guildId: guild.id,
258
+ systemCategoryId: system.id,
259
+ created,
260
+ moved,
261
+ }, 'system-bootstrap:ensured');
262
+ }
263
+ const result = {
264
+ guildId: guild.id,
265
+ systemCategoryId: system.id,
266
+ };
267
+ if (status.id)
268
+ result.statusChannelId = status.id;
269
+ if (crons.id)
270
+ result.cronsForumId = crons.id;
271
+ if (tasks?.id)
272
+ result.tasksForumId = tasks.id;
273
+ return result;
274
+ }
275
+ const STATUS_PRIORITY = ['open', 'in_progress', 'blocked', 'closed'];
276
+ const STATUS_TAG_NAMES = new Set(STATUS_PRIORITY);
277
+ /**
278
+ * Ensure a forum channel has tags matching a tag-map file.
279
+ * Creates missing tags on the Discord forum and writes their IDs back to the
280
+ * **dataDir** tag-map file (never mutates repo files).
281
+ *
282
+ * Returns the number of tags created.
283
+ */
284
+ export async function ensureForumTags(guild, forumId, tagMapPath, options) {
285
+ const log = options?.log;
286
+ const seedPath = options?.seedPath;
287
+ let tagMap;
288
+ try {
289
+ const raw = await fs.readFile(tagMapPath, 'utf8');
290
+ tagMap = JSON.parse(raw);
291
+ }
292
+ catch {
293
+ return 0;
294
+ }
295
+ // Key-merge: if a seed file is provided, merge any new keys from the seed
296
+ // into the data-dir tag map (existing keys are preserved).
297
+ if (seedPath) {
298
+ try {
299
+ const seedRaw = await fs.readFile(seedPath, 'utf8');
300
+ const seedMap = JSON.parse(seedRaw);
301
+ let merged = 0;
302
+ for (const key of Object.keys(seedMap)) {
303
+ if (!(key in tagMap)) {
304
+ tagMap[key] = '';
305
+ merged++;
306
+ }
307
+ }
308
+ if (merged > 0) {
309
+ log?.info({ seedPath, merged }, 'system-bootstrap: merged new keys from seed tag map');
310
+ }
311
+ }
312
+ catch {
313
+ // Seed file missing or invalid — continue with existing tag map.
314
+ }
315
+ }
316
+ const forum = guild.channels.cache.get(forumId);
317
+ if (!forum || forum.type !== ChannelType.GuildForum)
318
+ return 0;
319
+ const forumChannel = forum;
320
+ // Build lookup structures for existing forum tags.
321
+ const existingTags = forumChannel.availableTags ?? [];
322
+ const existingById = new Map(existingTags.map((t) => [t.id, t]));
323
+ const existingNames = new Set(existingTags.map((t) => t.name.toLowerCase()));
324
+ // Stale-ID reconciliation: validate entries that have non-empty IDs.
325
+ // If the ID doesn't exist on the forum, or maps to a different tag name, clear it.
326
+ let staleCleared = 0;
327
+ for (const [name, id] of Object.entries(tagMap)) {
328
+ if (!id)
329
+ continue;
330
+ const forumTag = existingById.get(id);
331
+ if (!forumTag || forumTag.name.toLowerCase() !== name.toLowerCase()) {
332
+ log?.warn({ tagName: name, staleId: id, forumTagName: forumTag?.name }, 'system-bootstrap: clearing stale/swapped tag ID');
333
+ tagMap[name] = '';
334
+ staleCleared++;
335
+ }
336
+ }
337
+ // Identify tags that need to be created.
338
+ const toCreate = [];
339
+ let backfilled = 0;
340
+ for (const [name, id] of Object.entries(tagMap)) {
341
+ if (id)
342
+ continue; // Already has a validated Discord tag ID.
343
+ if (existingNames.has(name.toLowerCase())) {
344
+ // Tag exists on the forum but not in our map — backfill the ID.
345
+ const existing = existingTags.find((t) => t.name.toLowerCase() === name.toLowerCase());
346
+ if (existing && existing.id) {
347
+ tagMap[name] = existing.id;
348
+ backfilled++;
349
+ }
350
+ continue;
351
+ }
352
+ toCreate.push(name);
353
+ }
354
+ if (toCreate.length === 0 && backfilled === 0 && staleCleared === 0) {
355
+ // Nothing to create, nothing changed.
356
+ return 0;
357
+ }
358
+ // Discord forums allow max 20 tags.
359
+ const maxNew = Math.max(0, 20 - existingTags.length);
360
+ // Status-first prioritization: ensure status tags get slots before content tags,
361
+ // in deterministic lifecycle-priority order (open > in_progress > blocked > closed).
362
+ const statusFirst = STATUS_PRIORITY.filter((n) => toCreate.includes(n));
363
+ const contentRest = toCreate.filter((n) => !STATUS_TAG_NAMES.has(n));
364
+ const prioritized = [...statusFirst, ...contentRest];
365
+ const creating = prioritized.slice(0, maxNew);
366
+ if (creating.length > 0) {
367
+ try {
368
+ const newTags = [
369
+ ...existingTags.map((t) => ({ id: t.id, name: t.name, moderated: t.moderated, emoji: t.emoji })),
370
+ ...creating.map((name) => ({ name })),
371
+ ];
372
+ await forumChannel.edit({ availableTags: newTags });
373
+ // Re-fetch to get the created tag IDs.
374
+ const updated = guild.channels.cache.get(forumId);
375
+ const updatedTags = updated?.availableTags ?? [];
376
+ for (const name of creating) {
377
+ const created = updatedTags.find((t) => t.name.toLowerCase() === name.toLowerCase());
378
+ if (created)
379
+ tagMap[name] = created.id;
380
+ }
381
+ }
382
+ catch (err) {
383
+ log?.warn({ err, forumId, tagCount: creating.length }, 'system-bootstrap: failed to create forum tags');
384
+ return 0;
385
+ }
386
+ }
387
+ // Write the updated tag map back to the dataDir file.
388
+ try {
389
+ await fs.writeFile(tagMapPath, JSON.stringify(tagMap, null, 2) + '\n', 'utf8');
390
+ }
391
+ catch (err) {
392
+ log?.warn({ err, tagMapPath }, 'system-bootstrap: failed to write tag map');
393
+ }
394
+ log?.info({ forumId, tagsCreated: creating.length }, 'system-bootstrap: forum tags ensured');
395
+ return creating.length;
396
+ }