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,33 @@
1
+ import fs from 'node:fs/promises';
2
+ import { bdList } from './bd-cli.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Write an array of TaskData records to a JSONL file (one JSON object per line).
8
+ * Exported separately so tests can exercise the write path without a live bd install.
9
+ */
10
+ export async function writeJsonl(destPath, tasks) {
11
+ const lines = tasks.map((b) => JSON.stringify(b)).join('\n');
12
+ await fs.writeFile(destPath, lines ? lines + '\n' : '', 'utf8');
13
+ }
14
+ // ---------------------------------------------------------------------------
15
+ // Migration entry point
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * One-shot migration: reads **all** tasks from the bd CLI (all statuses, no
19
+ * limit) and writes them as JSONL to `destPath` so that `TaskStore.load()`
20
+ * can consume them.
21
+ *
22
+ * The output file is a full replacement — any existing content is overwritten.
23
+ * After migration, create a `TaskStore` with `persistPath: destPath` and call
24
+ * `await store.load()` to make the data available in-process.
25
+ */
26
+ export async function migrateFromBd(opts) {
27
+ const tasks = await bdList({ status: 'all', limit: 0 }, opts.cwd);
28
+ if (tasks.length === 0) {
29
+ console.warn('[migrate] bd exported zero tasks — if you expected data, check that bd is pointed at the right workspace. Writing empty JSONL.');
30
+ }
31
+ await writeJsonl(opts.destPath, tasks);
32
+ return { migrated: tasks.length };
33
+ }
@@ -0,0 +1,156 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { migrateFromBd, writeJsonl } from './migrate.js';
3
+ import { TaskStore } from './store.js';
4
+ vi.mock('./bd-cli.js', () => ({
5
+ bdList: vi.fn(),
6
+ }));
7
+ import { bdList } from './bd-cli.js';
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ const TMP_PATH = (suffix) => `/tmp/discoclaw-migrate-test-${suffix}.jsonl`;
12
+ async function cleanup(path) {
13
+ const { unlink } = await import('node:fs/promises');
14
+ await unlink(path).catch(() => { });
15
+ }
16
+ // ---------------------------------------------------------------------------
17
+ // writeJsonl
18
+ // ---------------------------------------------------------------------------
19
+ describe('migrate — writeJsonl', () => {
20
+ it('writes beads as JSONL and allows TaskStore to load them', async () => {
21
+ const path = TMP_PATH('write-load');
22
+ await cleanup(path);
23
+ const tasks = [
24
+ { id: 'ws-001', title: 'Alpha', status: 'open', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
25
+ { id: 'ws-002', title: 'Beta', status: 'closed', closed_at: '2024-01-02T00:00:00Z', updated_at: '2024-01-02T00:00:00Z' },
26
+ ];
27
+ await writeJsonl(path, tasks);
28
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
29
+ await store.load();
30
+ expect(store.size()).toBe(2);
31
+ expect(store.list({ status: 'all' }).map((b) => b.id).sort()).toEqual(['ws-001', 'ws-002']);
32
+ await cleanup(path);
33
+ });
34
+ it('preserves all TaskData fields in the JSONL output', async () => {
35
+ const path = TMP_PATH('fields');
36
+ await cleanup(path);
37
+ const bead = {
38
+ id: 'ws-005',
39
+ title: 'Rich task',
40
+ status: 'in_progress',
41
+ description: 'some desc',
42
+ priority: 1,
43
+ issue_type: 'bug',
44
+ owner: 'alice',
45
+ external_ref: 'discord:123',
46
+ labels: ['plan', 'tag:feature'],
47
+ created_at: '2024-01-01T00:00:00Z',
48
+ updated_at: '2024-01-02T00:00:00Z',
49
+ };
50
+ await writeJsonl(path, [bead]);
51
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
52
+ await store.load();
53
+ const loaded = store.get('ws-005');
54
+ expect(loaded.description).toBe('some desc');
55
+ expect(loaded.priority).toBe(1);
56
+ expect(loaded.issue_type).toBe('bug');
57
+ expect(loaded.owner).toBe('alice');
58
+ expect(loaded.external_ref).toBe('discord:123');
59
+ expect(loaded.labels).toEqual(['plan', 'tag:feature']);
60
+ await cleanup(path);
61
+ });
62
+ it('writes an empty file for an empty bead array', async () => {
63
+ const path = TMP_PATH('empty');
64
+ await cleanup(path);
65
+ await writeJsonl(path, []);
66
+ const { readFile } = await import('node:fs/promises');
67
+ const content = await readFile(path, 'utf8');
68
+ expect(content).toBe('');
69
+ await cleanup(path);
70
+ });
71
+ it('counter advances past the highest migrated ID so new tasks get non-colliding IDs', async () => {
72
+ const path = TMP_PATH('counter');
73
+ await cleanup(path);
74
+ const tasks = [
75
+ { id: 'ws-003', title: 'C', status: 'open' },
76
+ { id: 'ws-001', title: 'A', status: 'open' },
77
+ { id: 'ws-007', title: 'G', status: 'open' },
78
+ ];
79
+ await writeJsonl(path, tasks);
80
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
81
+ await store.load();
82
+ const next = store.create({ title: 'New' });
83
+ expect(next.id).toBe('ws-008');
84
+ await cleanup(path);
85
+ });
86
+ });
87
+ // ---------------------------------------------------------------------------
88
+ // migrateFromBd
89
+ // ---------------------------------------------------------------------------
90
+ describe('migrate — migrateFromBd', () => {
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ });
94
+ it('calls bdList with status:all and limit:0 and writes all beads to destPath', async () => {
95
+ const path = TMP_PATH('bd-basic');
96
+ await cleanup(path);
97
+ const mockBeads = [
98
+ { id: 'dc-001', title: 'Task one', status: 'open', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
99
+ { id: 'dc-002', title: 'Task two', status: 'in_progress', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
100
+ ];
101
+ vi.mocked(bdList).mockResolvedValueOnce(mockBeads);
102
+ const result = await migrateFromBd({ cwd: '/tmp/fake-workspace', destPath: path });
103
+ expect(result.migrated).toBe(2);
104
+ expect(bdList).toHaveBeenCalledOnce();
105
+ expect(bdList).toHaveBeenCalledWith({ status: 'all', limit: 0 }, '/tmp/fake-workspace');
106
+ const store = new TaskStore({ prefix: 'dc', persistPath: path });
107
+ await store.load();
108
+ expect(store.size()).toBe(2);
109
+ expect(store.list({ status: 'all' }).map((b) => b.title).sort()).toEqual(['Task one', 'Task two']);
110
+ await cleanup(path);
111
+ });
112
+ it('returns migrated: 0, writes an empty file, and warns when bd has no beads', async () => {
113
+ const path = TMP_PATH('bd-empty');
114
+ await cleanup(path);
115
+ vi.mocked(bdList).mockResolvedValueOnce([]);
116
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
117
+ try {
118
+ const result = await migrateFromBd({ cwd: '/tmp/fake-workspace', destPath: path });
119
+ expect(result.migrated).toBe(0);
120
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('zero tasks'));
121
+ const { readFile } = await import('node:fs/promises');
122
+ const content = await readFile(path, 'utf8');
123
+ expect(content).toBe('');
124
+ }
125
+ finally {
126
+ warnSpy.mockRestore();
127
+ await cleanup(path);
128
+ }
129
+ });
130
+ it('overwrites an existing JSONL file', async () => {
131
+ const path = TMP_PATH('bd-overwrite');
132
+ await cleanup(path);
133
+ // First write
134
+ vi.mocked(bdList).mockResolvedValueOnce([
135
+ { id: 'ws-001', title: 'Old', status: 'open' },
136
+ ]);
137
+ await migrateFromBd({ cwd: '/tmp/fake-workspace', destPath: path });
138
+ // Second write with different data
139
+ vi.mocked(bdList).mockResolvedValueOnce([
140
+ { id: 'ws-010', title: 'New', status: 'open' },
141
+ ]);
142
+ const result = await migrateFromBd({ cwd: '/tmp/fake-workspace', destPath: path });
143
+ expect(result.migrated).toBe(1);
144
+ const store = new TaskStore({ prefix: 'ws', persistPath: path });
145
+ await store.load();
146
+ expect(store.size()).toBe(1);
147
+ expect(store.get('ws-010')?.title).toBe('New');
148
+ expect(store.get('ws-001')).toBeUndefined();
149
+ await cleanup(path);
150
+ });
151
+ it('propagates bdList errors to the caller', async () => {
152
+ const path = TMP_PATH('bd-error');
153
+ vi.mocked(bdList).mockRejectedValueOnce(new Error('bd: database not found'));
154
+ await expect(migrateFromBd({ cwd: '/tmp/fake-workspace', destPath: path })).rejects.toThrow('bd: database not found');
155
+ });
156
+ });
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Resolve canonical task data paths.
5
+ *
6
+ * Returns `<dataDir>/tasks/<fileName>`.
7
+ */
8
+ export function resolveTaskDataPath(dataDir, fileName) {
9
+ if (!dataDir)
10
+ return undefined;
11
+ return path.join(dataDir, 'tasks', fileName);
12
+ }
13
+ /**
14
+ * Resolve legacy task data paths retained from the pre-hard-cut layout.
15
+ *
16
+ * Returns `<dataDir>/beads/<fileName>`.
17
+ */
18
+ export function resolveLegacyTaskDataPath(dataDir, fileName) {
19
+ if (!dataDir)
20
+ return undefined;
21
+ return path.join(dataDir, 'beads', fileName);
22
+ }
23
+ async function fileExists(filePath) {
24
+ if (!filePath)
25
+ return false;
26
+ try {
27
+ await fs.access(filePath);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /**
35
+ * Resolve the best task data file path for read/load operations:
36
+ * - canonical path when present
37
+ * - legacy path when canonical is missing but legacy exists
38
+ * - canonical path as the write target when neither exists yet
39
+ */
40
+ export async function resolveTaskDataLoadPath(dataDir, fileName) {
41
+ const canonicalPath = resolveTaskDataPath(dataDir, fileName);
42
+ if (!canonicalPath)
43
+ return undefined;
44
+ if (await fileExists(canonicalPath))
45
+ return canonicalPath;
46
+ const legacyPath = resolveLegacyTaskDataPath(dataDir, fileName);
47
+ if (await fileExists(legacyPath))
48
+ return legacyPath;
49
+ return canonicalPath;
50
+ }
51
+ /**
52
+ * Copy a legacy task data file from `<dataDir>/beads/` to the canonical
53
+ * `<dataDir>/tasks/` path when the canonical file is missing.
54
+ */
55
+ export async function migrateLegacyTaskDataFile(dataDir, fileName) {
56
+ const toPath = resolveTaskDataPath(dataDir, fileName);
57
+ const fromPath = resolveLegacyTaskDataPath(dataDir, fileName);
58
+ if (!toPath || !fromPath)
59
+ return { migrated: false };
60
+ if (await fileExists(toPath))
61
+ return { migrated: false };
62
+ if (!(await fileExists(fromPath)))
63
+ return { migrated: false };
64
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
65
+ await fs.copyFile(fromPath, toPath);
66
+ return { migrated: true, fromPath, toPath };
67
+ }
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import os from 'node:os';
5
+ import { migrateLegacyTaskDataFile, resolveLegacyTaskDataPath, resolveTaskDataLoadPath, resolveTaskDataPath, } from './path-defaults.js';
6
+ describe('resolveTaskDataPath', () => {
7
+ it('returns undefined when dataDir is not provided', () => {
8
+ expect(resolveTaskDataPath(undefined, 'tasks.jsonl')).toBeUndefined();
9
+ });
10
+ it('returns canonical path for tasks.jsonl', () => {
11
+ const dataDir = '/tmp/discoclaw-data';
12
+ const result = resolveTaskDataPath(dataDir, 'tasks.jsonl');
13
+ expect(result).toBe(path.join(dataDir, 'tasks', 'tasks.jsonl'));
14
+ });
15
+ it('returns canonical path for tag-map.json', () => {
16
+ const dataDir = '/tmp/discoclaw-data';
17
+ const canonical = path.join(dataDir, 'tasks', 'tag-map.json');
18
+ const result = resolveTaskDataPath(dataDir, 'tag-map.json');
19
+ expect(result).toBe(canonical);
20
+ });
21
+ });
22
+ describe('resolveLegacyTaskDataPath', () => {
23
+ it('returns undefined when dataDir is not provided', () => {
24
+ expect(resolveLegacyTaskDataPath(undefined, 'tasks.jsonl')).toBeUndefined();
25
+ });
26
+ it('returns legacy path for tasks.jsonl', () => {
27
+ const dataDir = '/tmp/discoclaw-data';
28
+ const result = resolveLegacyTaskDataPath(dataDir, 'tasks.jsonl');
29
+ expect(result).toBe(path.join(dataDir, 'beads', 'tasks.jsonl'));
30
+ });
31
+ });
32
+ describe('resolveTaskDataLoadPath', () => {
33
+ it('prefers legacy path when canonical is missing', async () => {
34
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-path-'));
35
+ const legacyPath = path.join(dataDir, 'beads', 'tasks.jsonl');
36
+ await fs.mkdir(path.dirname(legacyPath), { recursive: true });
37
+ await fs.writeFile(legacyPath, '{"id":"dev-001"}\n', 'utf8');
38
+ const resolved = await resolveTaskDataLoadPath(dataDir, 'tasks.jsonl');
39
+ expect(resolved).toBe(legacyPath);
40
+ });
41
+ it('returns canonical path when neither file exists yet', async () => {
42
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-path-'));
43
+ const resolved = await resolveTaskDataLoadPath(dataDir, 'tasks.jsonl');
44
+ expect(resolved).toBe(path.join(dataDir, 'tasks', 'tasks.jsonl'));
45
+ });
46
+ });
47
+ describe('migrateLegacyTaskDataFile', () => {
48
+ it('copies legacy tasks file to canonical location when missing', async () => {
49
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-migrate-'));
50
+ const fromPath = path.join(dataDir, 'beads', 'tasks.jsonl');
51
+ const toPath = path.join(dataDir, 'tasks', 'tasks.jsonl');
52
+ const payload = '{"id":"dev-5ayj","status":"closed"}\n';
53
+ await fs.mkdir(path.dirname(fromPath), { recursive: true });
54
+ await fs.writeFile(fromPath, payload, 'utf8');
55
+ const result = await migrateLegacyTaskDataFile(dataDir, 'tasks.jsonl');
56
+ const copied = await fs.readFile(toPath, 'utf8');
57
+ expect(result).toEqual({ migrated: true, fromPath, toPath });
58
+ expect(copied).toBe(payload);
59
+ });
60
+ it('does nothing when canonical file already exists', async () => {
61
+ const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-migrate-'));
62
+ const toPath = path.join(dataDir, 'tasks', 'tasks.jsonl');
63
+ const fromPath = path.join(dataDir, 'beads', 'tasks.jsonl');
64
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
65
+ await fs.mkdir(path.dirname(fromPath), { recursive: true });
66
+ await fs.writeFile(toPath, '{"id":"ws-001"}\n', 'utf8');
67
+ await fs.writeFile(fromPath, '{"id":"dev-001"}\n', 'utf8');
68
+ const result = await migrateLegacyTaskDataFile(dataDir, 'tasks.jsonl');
69
+ const existing = await fs.readFile(toPath, 'utf8');
70
+ expect(result).toEqual({ migrated: false });
71
+ expect(existing).toBe('{"id":"ws-001"}\n');
72
+ });
73
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Track 2 mutation entrypoint.
3
+ * This wraps TaskStore so callers can depend on one service contract while
4
+ * we progressively move domain rules out of adapters.
5
+ */
6
+ export function createTaskService(store) {
7
+ return {
8
+ get(id) {
9
+ return store.get(id);
10
+ },
11
+ list(params) {
12
+ return store.list(params);
13
+ },
14
+ findByTitle(title, opts) {
15
+ return store.findByTitle(title, opts);
16
+ },
17
+ create(params) {
18
+ return store.create(params);
19
+ },
20
+ update(id, params) {
21
+ return store.update(id, params);
22
+ },
23
+ close(id, reason) {
24
+ return store.close(id, reason);
25
+ },
26
+ addLabel(id, label) {
27
+ return store.addLabel(id, label);
28
+ },
29
+ removeLabel(id, label) {
30
+ return store.removeLabel(id, label);
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createTaskService } from './service.js';
3
+ import { TaskStore } from './store.js';
4
+ describe('createTaskService', () => {
5
+ it('delegates reads and writes to TaskStore', () => {
6
+ const store = new TaskStore({ prefix: 'ws' });
7
+ const service = createTaskService(store);
8
+ const created = service.create({ title: 'Service task', labels: ['plan'] });
9
+ expect(service.get(created.id)?.title).toBe('Service task');
10
+ expect(service.list({ status: 'all' }).map((t) => t.id)).toContain(created.id);
11
+ expect(service.findByTitle('service task')?.id).toBe(created.id);
12
+ service.update(created.id, { status: 'in_progress' });
13
+ expect(service.get(created.id)?.status).toBe('in_progress');
14
+ service.addLabel(created.id, 'tag:feature');
15
+ expect(service.get(created.id)?.labels).toContain('tag:feature');
16
+ service.removeLabel(created.id, 'tag:feature');
17
+ expect(service.get(created.id)?.labels).not.toContain('tag:feature');
18
+ service.close(created.id, 'done');
19
+ expect(service.get(created.id)?.status).toBe('closed');
20
+ });
21
+ it('preserves TaskStore event semantics through service writes', () => {
22
+ const store = new TaskStore({ prefix: 'ws' });
23
+ const service = createTaskService(store);
24
+ const events = [];
25
+ store.on('created', () => events.push('created'));
26
+ store.on('updated', () => events.push('updated'));
27
+ store.on('labeled', () => events.push('labeled'));
28
+ store.on('closed', () => events.push('closed'));
29
+ const task = service.create({ title: 'Event task' });
30
+ service.update(task.id, { title: 'Event task v2' });
31
+ service.addLabel(task.id, 'plan');
32
+ service.removeLabel(task.id, 'plan');
33
+ service.close(task.id);
34
+ expect(events).toEqual(['created', 'updated', 'labeled', 'updated', 'closed']);
35
+ });
36
+ it('can be passed a mocked store contract for isolated callers', () => {
37
+ const store = {
38
+ get: vi.fn(),
39
+ list: vi.fn(() => []),
40
+ findByTitle: vi.fn(() => null),
41
+ create: vi.fn((params) => ({ id: 'ws-001', ...params, status: 'open' })),
42
+ update: vi.fn(),
43
+ close: vi.fn(),
44
+ addLabel: vi.fn(),
45
+ removeLabel: vi.fn(),
46
+ };
47
+ const service = createTaskService(store);
48
+ service.create({ title: 'mocked' });
49
+ expect(store.create).toHaveBeenCalledWith({ title: 'mocked' });
50
+ });
51
+ });
@@ -0,0 +1,238 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs/promises';
3
+ // ---------------------------------------------------------------------------
4
+ // TaskStore
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * In-process task store — an EventEmitter-backed Map that owns the read/write
8
+ * path for task data. Replaces the external `bd` CLI dependency.
9
+ *
10
+ * All mutations are synchronous on the in-memory store and emit typed events
11
+ * immediately. Persistence to a JSONL file (if configured) is fire-and-forget;
12
+ * call `flush()` to await the latest write.
13
+ */
14
+ export class TaskStore extends EventEmitter {
15
+ tasks = new Map();
16
+ counter = 0;
17
+ prefix;
18
+ persistPath;
19
+ persistPromise = null;
20
+ constructor(opts = {}) {
21
+ super();
22
+ this.prefix = opts.prefix ?? 't';
23
+ this.persistPath = opts.persistPath;
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Persistence
27
+ // ---------------------------------------------------------------------------
28
+ /**
29
+ * Load tasks from the configured JSONL file. No-op if no persistPath was
30
+ * given. Silently succeeds if the file does not exist yet.
31
+ */
32
+ async load() {
33
+ if (!this.persistPath)
34
+ return;
35
+ let content;
36
+ try {
37
+ content = await fs.readFile(this.persistPath, 'utf8');
38
+ }
39
+ catch (err) {
40
+ if (err.code === 'ENOENT')
41
+ return;
42
+ throw err;
43
+ }
44
+ for (const line of content.split('\n')) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed)
47
+ continue;
48
+ const task = JSON.parse(trimmed);
49
+ this.tasks.set(task.id, task);
50
+ // Advance the counter to be ≥ the highest numeric suffix seen,
51
+ // but only for IDs that share our prefix to avoid contamination
52
+ // from migrated tasks with different prefixes (e.g. dev-899 → ws-900).
53
+ const match = new RegExp(`^${this.prefix}-(\\d+)$`).exec(task.id);
54
+ if (match) {
55
+ const n = parseInt(match[1], 10);
56
+ if (n > this.counter)
57
+ this.counter = n;
58
+ }
59
+ }
60
+ }
61
+ /** Await the most recently scheduled persist, if any. */
62
+ async flush() {
63
+ await this.persistPromise;
64
+ }
65
+ schedulePersist() {
66
+ if (!this.persistPath)
67
+ return;
68
+ this.persistPromise = (this.persistPromise ?? Promise.resolve())
69
+ .then(() => this.writeToDisk())
70
+ .catch(() => {
71
+ // Persist errors are non-fatal; in-memory state remains authoritative.
72
+ });
73
+ }
74
+ async writeToDisk() {
75
+ if (!this.persistPath)
76
+ return;
77
+ const lines = [...this.tasks.values()].map((t) => JSON.stringify(t)).join('\n');
78
+ await fs.writeFile(this.persistPath, lines ? lines + '\n' : '', 'utf8');
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // ID generation
82
+ // ---------------------------------------------------------------------------
83
+ generateId() {
84
+ this.counter++;
85
+ return `${this.prefix}-${String(this.counter).padStart(3, '0')}`;
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Reads
89
+ // ---------------------------------------------------------------------------
90
+ /** Return the task with the given ID, or `undefined` if not found. */
91
+ get(id) {
92
+ return this.tasks.get(id);
93
+ }
94
+ /**
95
+ * List tasks matching the given filters.
96
+ *
97
+ * - Default (no `status`): excludes closed tasks.
98
+ * - `status: 'all'`: returns all tasks regardless of status.
99
+ * - `status: <value>`: returns only tasks with that status.
100
+ * - `label`: further filter by a label string.
101
+ * - `limit`: cap the number of results (0 or omitted = no cap).
102
+ */
103
+ list(params = {}) {
104
+ let results = [...this.tasks.values()];
105
+ if (params.status === 'all') {
106
+ // no status filter
107
+ }
108
+ else if (params.status) {
109
+ results = results.filter((t) => t.status === params.status);
110
+ }
111
+ else {
112
+ results = results.filter((t) => t.status !== 'closed');
113
+ }
114
+ if (params.label) {
115
+ const label = params.label;
116
+ results = results.filter((t) => t.labels?.includes(label));
117
+ }
118
+ if (params.limit != null && params.limit > 0) {
119
+ results = results.slice(0, params.limit);
120
+ }
121
+ return results;
122
+ }
123
+ /**
124
+ * Find a non-closed task whose title matches the given string
125
+ * (case-insensitive, trimmed). Optionally filter by label.
126
+ * Returns the first match, or null if none found.
127
+ */
128
+ findByTitle(title, opts) {
129
+ const normalized = title.trim().toLowerCase();
130
+ if (!normalized)
131
+ return null;
132
+ const candidates = opts?.label ? this.list({ label: opts.label }) : this.list();
133
+ const match = candidates.find((t) => t.status !== 'closed' && t.title.trim().toLowerCase() === normalized);
134
+ return match ?? null;
135
+ }
136
+ /** Total number of tasks in the store (all statuses). */
137
+ size() {
138
+ return this.tasks.size;
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Writes (synchronous in-memory; async persist)
142
+ // ---------------------------------------------------------------------------
143
+ /** Create a new task. Emits `"created"` synchronously. */
144
+ create(params) {
145
+ const now = new Date().toISOString();
146
+ const task = {
147
+ id: this.generateId(),
148
+ title: params.title,
149
+ status: 'open',
150
+ ...(params.description !== undefined && { description: params.description }),
151
+ ...(params.priority !== undefined && { priority: params.priority }),
152
+ ...(params.issueType !== undefined && { issue_type: params.issueType }),
153
+ ...(params.owner !== undefined && { owner: params.owner }),
154
+ ...(params.labels?.length && { labels: [...params.labels] }),
155
+ created_at: now,
156
+ updated_at: now,
157
+ };
158
+ this.tasks.set(task.id, task);
159
+ this.emit('created', task);
160
+ this.schedulePersist();
161
+ return task;
162
+ }
163
+ /** Update fields on an existing task. Emits `"updated"` synchronously. */
164
+ update(id, params) {
165
+ const prev = this.tasks.get(id);
166
+ if (!prev)
167
+ throw new Error(`task not found: ${id}`);
168
+ const now = new Date().toISOString();
169
+ const updated = {
170
+ ...prev,
171
+ ...(params.title !== undefined && { title: params.title }),
172
+ ...(params.description !== undefined && { description: params.description }),
173
+ ...(params.priority !== undefined && { priority: params.priority }),
174
+ ...(params.status !== undefined && { status: params.status }),
175
+ ...(params.owner !== undefined && { owner: params.owner }),
176
+ ...(params.externalRef !== undefined && { external_ref: params.externalRef }),
177
+ updated_at: now,
178
+ };
179
+ this.tasks.set(id, updated);
180
+ this.emit('updated', updated, prev);
181
+ this.schedulePersist();
182
+ return updated;
183
+ }
184
+ /** Close a task. Emits `"closed"` synchronously. */
185
+ close(id, reason) {
186
+ const prev = this.tasks.get(id);
187
+ if (!prev)
188
+ throw new Error(`task not found: ${id}`);
189
+ const now = new Date().toISOString();
190
+ const closed = {
191
+ ...prev,
192
+ status: 'closed',
193
+ closed_at: now,
194
+ updated_at: now,
195
+ ...(reason !== undefined && { close_reason: reason }),
196
+ };
197
+ this.tasks.set(id, closed);
198
+ this.emit('closed', closed);
199
+ this.schedulePersist();
200
+ return closed;
201
+ }
202
+ /** Add a label to a task. No-op (returns existing task) if already present. Emits `"labeled"` synchronously. */
203
+ addLabel(id, label) {
204
+ const prev = this.tasks.get(id);
205
+ if (!prev)
206
+ throw new Error(`task not found: ${id}`);
207
+ if (prev.labels?.includes(label))
208
+ return prev;
209
+ const now = new Date().toISOString();
210
+ const updated = {
211
+ ...prev,
212
+ labels: [...(prev.labels ?? []), label],
213
+ updated_at: now,
214
+ };
215
+ this.tasks.set(id, updated);
216
+ this.emit('labeled', updated, label);
217
+ this.schedulePersist();
218
+ return updated;
219
+ }
220
+ /** Remove a label from a task. No-op (returns existing task) if label is absent. Emits `"updated"` synchronously. */
221
+ removeLabel(id, label) {
222
+ const prev = this.tasks.get(id);
223
+ if (!prev)
224
+ throw new Error(`task not found: ${id}`);
225
+ if (!prev.labels?.includes(label))
226
+ return prev;
227
+ const now = new Date().toISOString();
228
+ const updated = {
229
+ ...prev,
230
+ labels: prev.labels.filter((l) => l !== label),
231
+ updated_at: now,
232
+ };
233
+ this.tasks.set(id, updated);
234
+ this.emit('updated', updated, prev);
235
+ this.schedulePersist();
236
+ return updated;
237
+ }
238
+ }