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,614 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { executeCronJob } from './executor.js';
7
+ import { safeCronId } from './job-lock.js';
8
+ import { CronRunControl } from './run-control.js';
9
+ import { loadWorkspacePaFiles } from '../discord/prompt-common.js';
10
+ vi.mock('../discord/prompt-common.js', async (importOriginal) => {
11
+ const actual = await importOriginal();
12
+ return {
13
+ ...actual,
14
+ loadWorkspacePaFiles: vi.fn(actual.loadWorkspacePaFiles),
15
+ };
16
+ });
17
+ function makeDef(overrides) {
18
+ return {
19
+ triggerType: 'schedule',
20
+ schedule: '0 7 * * *',
21
+ timezone: 'UTC',
22
+ channel: 'general',
23
+ prompt: 'Say hello.',
24
+ ...overrides,
25
+ };
26
+ }
27
+ function makeJob(overrides) {
28
+ return {
29
+ id: 'thread-1',
30
+ cronId: 'cron-test0001',
31
+ threadId: 'thread-1',
32
+ guildId: 'guild-1',
33
+ name: 'Test Job',
34
+ def: makeDef(),
35
+ cron: null,
36
+ running: false,
37
+ ...overrides,
38
+ };
39
+ }
40
+ function makeMockRuntime(response) {
41
+ return {
42
+ id: 'claude_code',
43
+ capabilities: new Set(['streaming_text']),
44
+ async *invoke() {
45
+ yield { type: 'text_final', text: response };
46
+ yield { type: 'done' };
47
+ },
48
+ };
49
+ }
50
+ function makeMockRuntimeError(message) {
51
+ return {
52
+ id: 'claude_code',
53
+ capabilities: new Set(['streaming_text']),
54
+ async *invoke() {
55
+ yield { type: 'error', message };
56
+ yield { type: 'done' };
57
+ },
58
+ };
59
+ }
60
+ function mockLog() {
61
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
62
+ }
63
+ function mockChannel() {
64
+ return { id: 'ch-1', name: 'general', type: ChannelType.GuildText, send: vi.fn().mockResolvedValue(undefined) };
65
+ }
66
+ const BASE_CRON_ACTION_FLAGS = {
67
+ channels: false,
68
+ messaging: false,
69
+ guild: false,
70
+ moderation: false,
71
+ polls: false,
72
+ tasks: false,
73
+ crons: false,
74
+ botProfile: false,
75
+ forge: false,
76
+ plan: false,
77
+ memory: false,
78
+ config: false,
79
+ defer: false,
80
+ };
81
+ function makeCronActionFlags(overrides) {
82
+ return { ...BASE_CRON_ACTION_FLAGS, ...overrides };
83
+ }
84
+ function makeCtx(overrides) {
85
+ const channel = mockChannel();
86
+ const guild = {
87
+ channels: {
88
+ cache: {
89
+ get: vi.fn().mockReturnValue(channel),
90
+ find: vi.fn().mockReturnValue(channel),
91
+ },
92
+ },
93
+ };
94
+ const client = {
95
+ guilds: {
96
+ cache: {
97
+ get: vi.fn().mockReturnValue(guild),
98
+ },
99
+ },
100
+ };
101
+ const baseCtx = {
102
+ client: client,
103
+ runtime: makeMockRuntime('Hello from cron!'),
104
+ model: 'haiku',
105
+ cwd: '/tmp',
106
+ tools: ['Bash', 'Read', 'Edit', 'WebSearch', 'WebFetch'],
107
+ timeoutMs: 30_000,
108
+ status: null,
109
+ log: mockLog(),
110
+ discordActionsEnabled: false,
111
+ actionFlags: makeCronActionFlags(),
112
+ };
113
+ const ctx = { ...baseCtx, ...overrides };
114
+ ctx.actionFlags = makeCronActionFlags(overrides?.actionFlags);
115
+ return ctx;
116
+ }
117
+ describe('executeCronJob', () => {
118
+ it('posts result to target channel', async () => {
119
+ const ctx = makeCtx();
120
+ const job = makeJob();
121
+ await executeCronJob(job, ctx);
122
+ const guild = ctx.client.guilds.cache.get('guild-1');
123
+ const channel = guild.channels.cache.get('general');
124
+ expect(channel.send).toHaveBeenCalledOnce();
125
+ expect(channel.send.mock.calls[0][0].content).toContain('Hello from cron!');
126
+ });
127
+ it('sets running flag and clears it after', async () => {
128
+ const ctx = makeCtx();
129
+ const job = makeJob();
130
+ expect(job.running).toBe(false);
131
+ await executeCronJob(job, ctx);
132
+ expect(job.running).toBe(false);
133
+ });
134
+ it('skips if previous run is still active (overlap guard)', async () => {
135
+ const ctx = makeCtx();
136
+ const job = makeJob({ running: true });
137
+ await executeCronJob(job, ctx);
138
+ const guild = ctx.client.guilds.cache.get('guild-1');
139
+ const channel = guild.channels.cache.get('general');
140
+ expect(channel.send).not.toHaveBeenCalled();
141
+ expect(ctx.log?.warn).toHaveBeenCalled();
142
+ });
143
+ it('handles runtime error gracefully', async () => {
144
+ const status = {
145
+ online: vi.fn(),
146
+ offline: vi.fn(),
147
+ runtimeError: vi.fn(),
148
+ handlerError: vi.fn(),
149
+ actionFailed: vi.fn(),
150
+ taskSyncComplete: vi.fn(),
151
+ };
152
+ const ctx = makeCtx({ runtime: makeMockRuntimeError('timeout'), status });
153
+ const job = makeJob();
154
+ await executeCronJob(job, ctx);
155
+ expect(status.runtimeError).toHaveBeenCalledOnce();
156
+ expect(job.running).toBe(false);
157
+ });
158
+ it('handles guild not found gracefully', async () => {
159
+ const client = {
160
+ guilds: { cache: { get: vi.fn().mockReturnValue(undefined) } },
161
+ };
162
+ const ctx = makeCtx({ client: client });
163
+ const job = makeJob();
164
+ await executeCronJob(job, ctx);
165
+ expect(ctx.log?.error).toHaveBeenCalled();
166
+ expect(job.running).toBe(false);
167
+ });
168
+ it('handles channel not found gracefully', async () => {
169
+ const guild = {
170
+ channels: {
171
+ cache: {
172
+ get: vi.fn().mockReturnValue(undefined),
173
+ find: vi.fn().mockReturnValue(undefined),
174
+ },
175
+ },
176
+ };
177
+ const client = {
178
+ guilds: { cache: { get: vi.fn().mockReturnValue(guild) } },
179
+ };
180
+ const status = {
181
+ online: vi.fn(),
182
+ offline: vi.fn(),
183
+ runtimeError: vi.fn(),
184
+ handlerError: vi.fn(),
185
+ actionFailed: vi.fn(),
186
+ taskSyncComplete: vi.fn(),
187
+ };
188
+ const ctx = makeCtx({ client: client, status });
189
+ const job = makeJob();
190
+ await executeCronJob(job, ctx);
191
+ expect(status.runtimeError).toHaveBeenCalledOnce();
192
+ expect(job.running).toBe(false);
193
+ });
194
+ it('does not post if target channel is not allowlisted', async () => {
195
+ const status = {
196
+ online: vi.fn(),
197
+ offline: vi.fn(),
198
+ runtimeError: vi.fn(),
199
+ handlerError: vi.fn(),
200
+ actionFailed: vi.fn(),
201
+ taskSyncComplete: vi.fn(),
202
+ };
203
+ const ctx = makeCtx({ status, allowChannelIds: new Set(['some-other-channel']) });
204
+ const job = makeJob();
205
+ await executeCronJob(job, ctx);
206
+ const guild = ctx.client.guilds.cache.get('guild-1');
207
+ const channel = guild.channels.cache.get('general');
208
+ expect(channel.send).not.toHaveBeenCalled();
209
+ expect(status.runtimeError).toHaveBeenCalledOnce();
210
+ });
211
+ it('posts when target channel is allowlisted', async () => {
212
+ const status = {
213
+ online: vi.fn(),
214
+ offline: vi.fn(),
215
+ runtimeError: vi.fn(),
216
+ handlerError: vi.fn(),
217
+ actionFailed: vi.fn(),
218
+ taskSyncComplete: vi.fn(),
219
+ };
220
+ const ctx = makeCtx({ status, allowChannelIds: new Set(['ch-1']) });
221
+ const job = makeJob();
222
+ await executeCronJob(job, ctx);
223
+ const guild = ctx.client.guilds.cache.get('guild-1');
224
+ const channel = guild.channels.cache.get('general');
225
+ expect(channel.send).toHaveBeenCalledOnce();
226
+ });
227
+ it('suppresses sendMessage Done line from posted output', async () => {
228
+ const responseWithAction = 'Sending now.\n<discord-action>{"type":"sendMessage","channel":"general","content":"hello"}</discord-action>';
229
+ const runtime = {
230
+ id: 'claude_code',
231
+ capabilities: new Set(['streaming_text']),
232
+ async *invoke() {
233
+ yield { type: 'text_final', text: responseWithAction };
234
+ yield { type: 'done' };
235
+ },
236
+ };
237
+ const ctx = makeCtx({
238
+ runtime,
239
+ discordActionsEnabled: true,
240
+ actionFlags: { channels: false, messaging: true, guild: false, moderation: false, polls: false, tasks: false, crons: false, botProfile: false, forge: false, plan: false, memory: false, config: false, defer: false },
241
+ });
242
+ const job = makeJob();
243
+ await executeCronJob(job, ctx);
244
+ const guild = ctx.client.guilds.cache.get('guild-1');
245
+ const channel = guild.channels.cache.get('general');
246
+ // Two sends: action's sendMessage ("hello") + cron output ("Sending now.").
247
+ expect(channel.send).toHaveBeenCalledTimes(2);
248
+ // The cron output post (second call) should contain the prose but not "Done:".
249
+ const outputContent = channel.send.mock.calls[1][0].content;
250
+ expect(outputContent).not.toContain('Done: Sent message');
251
+ expect(outputContent).toContain('Sending now.');
252
+ });
253
+ it('skips cron output post when sendMessage-only with no prose', async () => {
254
+ const responseActionOnly = '<discord-action>{"type":"sendMessage","channel":"general","content":"hello"}</discord-action>';
255
+ const runtime = {
256
+ id: 'claude_code',
257
+ capabilities: new Set(['streaming_text']),
258
+ async *invoke() {
259
+ yield { type: 'text_final', text: responseActionOnly };
260
+ yield { type: 'done' };
261
+ },
262
+ };
263
+ const ctx = makeCtx({
264
+ runtime,
265
+ discordActionsEnabled: true,
266
+ actionFlags: { channels: false, messaging: true, guild: false, moderation: false, polls: false, tasks: false, crons: false, botProfile: false, forge: false, plan: false, memory: false, config: false, defer: false },
267
+ });
268
+ const job = makeJob();
269
+ await executeCronJob(job, ctx);
270
+ const guild = ctx.client.guilds.cache.get('guild-1');
271
+ const channel = guild.channels.cache.get('general');
272
+ // Only one send: the action's sendMessage. No cron output post.
273
+ expect(channel.send).toHaveBeenCalledTimes(1);
274
+ expect(channel.send.mock.calls[0][0].content).toBe('hello');
275
+ });
276
+ it('posts unavailable action notice when action types are stripped', async () => {
277
+ const responseWithUnknownAction = '<discord-action>{"type":"totallyUnknownAction"}</discord-action>';
278
+ const runtime = {
279
+ id: 'claude_code',
280
+ capabilities: new Set(['streaming_text']),
281
+ async *invoke() {
282
+ yield { type: 'text_final', text: responseWithUnknownAction };
283
+ yield { type: 'done' };
284
+ },
285
+ };
286
+ const ctx = makeCtx({
287
+ runtime,
288
+ discordActionsEnabled: true,
289
+ actionFlags: { channels: true, messaging: false, guild: false, moderation: false, polls: false, tasks: false, crons: false, botProfile: false, forge: false, plan: false, memory: false, config: false, defer: false },
290
+ });
291
+ const job = makeJob();
292
+ await executeCronJob(job, ctx);
293
+ const guild = ctx.client.guilds.cache.get('guild-1');
294
+ const channel = guild.channels.cache.get('general');
295
+ expect(channel.send).toHaveBeenCalledOnce();
296
+ const outputContent = channel.send.mock.calls[0][0].content;
297
+ expect(outputContent).toContain('Ignored unavailable action type:');
298
+ expect(outputContent).toContain('`totallyUnknownAction`');
299
+ });
300
+ it('does not post if output is empty', async () => {
301
+ const ctx = makeCtx({ runtime: makeMockRuntime('') });
302
+ const job = makeJob();
303
+ await executeCronJob(job, ctx);
304
+ const guild = ctx.client.guilds.cache.get('guild-1');
305
+ const channel = guild.channels.cache.get('general');
306
+ expect(channel.send).not.toHaveBeenCalled();
307
+ });
308
+ it('clears running flag even on exception', async () => {
309
+ const guild = {
310
+ channels: {
311
+ cache: {
312
+ get: vi.fn().mockReturnValue({
313
+ id: 'ch-1',
314
+ name: 'general',
315
+ type: ChannelType.GuildText,
316
+ send: vi.fn().mockRejectedValue(new Error('Discord API error')),
317
+ }),
318
+ find: vi.fn(),
319
+ },
320
+ },
321
+ };
322
+ const client = {
323
+ guilds: { cache: { get: vi.fn().mockReturnValue(guild) } },
324
+ };
325
+ const ctx = makeCtx({ client: client });
326
+ const job = makeJob();
327
+ await executeCronJob(job, ctx);
328
+ expect(job.running).toBe(false);
329
+ });
330
+ it('supports cancel requests via runControl', async () => {
331
+ const runControl = new CronRunControl();
332
+ const runtime = {
333
+ id: 'claude_code',
334
+ capabilities: new Set(['streaming_text']),
335
+ async *invoke() {
336
+ yield { type: 'text_delta', text: 'working...' };
337
+ await new Promise((r) => setTimeout(r, 50));
338
+ yield { type: 'text_final', text: 'done' };
339
+ yield { type: 'done' };
340
+ },
341
+ };
342
+ const ctx = makeCtx({ runtime, runControl });
343
+ const job = makeJob();
344
+ const runPromise = executeCronJob(job, ctx);
345
+ expect(runControl.requestCancel(job.id)).toBe(true);
346
+ await runPromise;
347
+ const guild = ctx.client.guilds.cache.get('guild-1');
348
+ const channel = guild.channels.cache.get('general');
349
+ expect(channel.send).not.toHaveBeenCalled();
350
+ expect(job.running).toBe(false);
351
+ expect(runControl.has(job.id)).toBe(false);
352
+ expect(ctx.log?.warn).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, cronId: job.cronId }), 'cron:exec canceled');
353
+ });
354
+ });
355
+ // ---------------------------------------------------------------------------
356
+ // File-lock integration
357
+ // ---------------------------------------------------------------------------
358
+ describe('executeCronJob file lock integration', () => {
359
+ let lockDir;
360
+ beforeEach(async () => {
361
+ lockDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-lock-test-'));
362
+ });
363
+ afterEach(async () => {
364
+ await fs.rm(lockDir, { recursive: true, force: true });
365
+ });
366
+ it('acquires and releases lock when lockDir is set', async () => {
367
+ const ctx = makeCtx({ lockDir });
368
+ const job = makeJob();
369
+ const lockPath = path.join(lockDir, safeCronId(job.cronId) + '.lock');
370
+ await executeCronJob(job, ctx);
371
+ // Lock should be released after execution.
372
+ await expect(fs.stat(lockPath)).rejects.toThrow();
373
+ expect(job.running).toBe(false);
374
+ });
375
+ it('releases lock even on early return (guild not found)', async () => {
376
+ const client = {
377
+ guilds: { cache: { get: vi.fn().mockReturnValue(undefined) } },
378
+ };
379
+ const ctx = makeCtx({ client: client, lockDir });
380
+ const job = makeJob();
381
+ const lockPath = path.join(lockDir, safeCronId(job.cronId) + '.lock');
382
+ await executeCronJob(job, ctx);
383
+ await expect(fs.stat(lockPath)).rejects.toThrow();
384
+ expect(job.running).toBe(false);
385
+ });
386
+ it('skips execution when lock is already held by another process', async () => {
387
+ const ctx = makeCtx({ lockDir });
388
+ const job = makeJob();
389
+ // Pre-create a lock with a fake alive PID (our own PID, so it's alive).
390
+ const lockPath = path.join(lockDir, safeCronId(job.cronId) + '.lock');
391
+ await fs.mkdir(lockPath);
392
+ await fs.writeFile(path.join(lockPath, 'meta.json'), JSON.stringify({ pid: process.pid, token: 'other-token', acquiredAt: new Date().toISOString() }));
393
+ await executeCronJob(job, ctx);
394
+ // Should have been skipped — no channel send.
395
+ const guild = ctx.client.guilds.cache.get('guild-1');
396
+ const channel = guild.channels.cache.get('general');
397
+ expect(channel.send).not.toHaveBeenCalled();
398
+ expect(ctx.log?.warn).toHaveBeenCalled();
399
+ // Lock should still exist (we didn't acquire it, so we shouldn't touch it).
400
+ const stat = await fs.stat(lockPath);
401
+ expect(stat.isDirectory()).toBe(true);
402
+ });
403
+ });
404
+ // ---------------------------------------------------------------------------
405
+ // permissionNote injection
406
+ // ---------------------------------------------------------------------------
407
+ describe('executeCronJob permissionNote injection', () => {
408
+ let wsDir;
409
+ beforeEach(async () => {
410
+ wsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-perm-'));
411
+ });
412
+ afterEach(async () => {
413
+ await fs.rm(wsDir, { recursive: true, force: true });
414
+ });
415
+ function makeCapturingRuntime(response) {
416
+ const invokeSpy = vi.fn();
417
+ return {
418
+ runtime: {
419
+ id: 'claude_code',
420
+ capabilities: new Set(['streaming_text']),
421
+ async *invoke(params) {
422
+ invokeSpy(params);
423
+ yield { type: 'text_final', text: response };
424
+ yield { type: 'done' };
425
+ },
426
+ },
427
+ invokeSpy,
428
+ };
429
+ }
430
+ it('injects permissionNote into the prompt when PERMISSIONS.json has a note', async () => {
431
+ await fs.writeFile(path.join(wsDir, 'PERMISSIONS.json'), JSON.stringify({ tier: 'readonly', note: 'Read-only access for scheduled tasks.' }));
432
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
433
+ const ctx = makeCtx({ runtime, cwd: wsDir });
434
+ const job = makeJob();
435
+ await executeCronJob(job, ctx);
436
+ expect(invokeSpy).toHaveBeenCalledOnce();
437
+ const passedPrompt = invokeSpy.mock.calls[0][0].prompt;
438
+ expect(passedPrompt).toContain('Permission note: Read-only access for scheduled tasks.');
439
+ });
440
+ it('does not inject permissionNote when PERMISSIONS.json has no note', async () => {
441
+ await fs.writeFile(path.join(wsDir, 'PERMISSIONS.json'), JSON.stringify({ tier: 'standard' }));
442
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
443
+ const ctx = makeCtx({ runtime, cwd: wsDir });
444
+ const job = makeJob();
445
+ await executeCronJob(job, ctx);
446
+ expect(invokeSpy).toHaveBeenCalledOnce();
447
+ const passedPrompt = invokeSpy.mock.calls[0][0].prompt;
448
+ expect(passedPrompt).not.toContain('Permission note:');
449
+ });
450
+ });
451
+ // ---------------------------------------------------------------------------
452
+ // Workspace PA context injection
453
+ // ---------------------------------------------------------------------------
454
+ describe('executeCronJob workspace PA context', () => {
455
+ let wsDir;
456
+ beforeEach(async () => {
457
+ wsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-pa-'));
458
+ });
459
+ afterEach(async () => {
460
+ await fs.rm(wsDir, { recursive: true, force: true });
461
+ });
462
+ function makeCapturingRuntime(response) {
463
+ const invokeSpy = vi.fn();
464
+ return {
465
+ runtime: {
466
+ id: 'claude_code',
467
+ capabilities: new Set(['streaming_text']),
468
+ async *invoke(params) {
469
+ invokeSpy(params);
470
+ yield { type: 'text_final', text: response };
471
+ yield { type: 'done' };
472
+ },
473
+ },
474
+ invokeSpy,
475
+ };
476
+ }
477
+ it('inlines all PA files into the prompt', async () => {
478
+ await fs.writeFile(path.join(wsDir, 'SOUL.md'), 'Be helpful.');
479
+ await fs.writeFile(path.join(wsDir, 'IDENTITY.md'), 'Test Bot Identity');
480
+ await fs.writeFile(path.join(wsDir, 'USER.md'), 'User info here.');
481
+ await fs.writeFile(path.join(wsDir, 'TOOLS.md'), 'Tool list here.');
482
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
483
+ const ctx = makeCtx({ runtime, cwd: wsDir });
484
+ const job = makeJob();
485
+ await executeCronJob(job, ctx);
486
+ expect(invokeSpy).toHaveBeenCalledOnce();
487
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
488
+ expect(prompt).toContain('--- SOUL.md ---');
489
+ expect(prompt).toContain('Be helpful.');
490
+ expect(prompt).toContain('--- IDENTITY.md ---');
491
+ expect(prompt).toContain('Test Bot Identity');
492
+ expect(prompt).toContain('--- USER.md ---');
493
+ expect(prompt).toContain('User info here.');
494
+ expect(prompt).toContain('--- TOOLS.md ---');
495
+ expect(prompt).toContain('Tool list here.');
496
+ });
497
+ it('executes normally when no PA files exist', async () => {
498
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
499
+ const ctx = makeCtx({ runtime, cwd: wsDir });
500
+ const job = makeJob();
501
+ await executeCronJob(job, ctx);
502
+ expect(invokeSpy).toHaveBeenCalledOnce();
503
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
504
+ expect(prompt).toContain('You are executing a scheduled cron job');
505
+ expect(prompt).not.toContain('--- ');
506
+ const guild = ctx.client.guilds.cache.get('guild-1');
507
+ const channel = guild.channels.cache.get('general');
508
+ expect(channel.send).toHaveBeenCalledOnce();
509
+ });
510
+ it('includes only existing PA files (partial set)', async () => {
511
+ await fs.writeFile(path.join(wsDir, 'SOUL.md'), 'Soul content.');
512
+ await fs.writeFile(path.join(wsDir, 'IDENTITY.md'), 'Identity content');
513
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
514
+ const ctx = makeCtx({ runtime, cwd: wsDir });
515
+ const job = makeJob();
516
+ await executeCronJob(job, ctx);
517
+ expect(invokeSpy).toHaveBeenCalledOnce();
518
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
519
+ expect(prompt).toContain('--- SOUL.md ---');
520
+ expect(prompt).toContain('Soul content.');
521
+ expect(prompt).toContain('--- IDENTITY.md ---');
522
+ expect(prompt).toContain('Identity content');
523
+ expect(prompt).not.toContain('--- USER.md ---');
524
+ expect(prompt).not.toContain('--- TOOLS.md ---');
525
+ });
526
+ it('places PA context before the cron instruction', async () => {
527
+ await fs.writeFile(path.join(wsDir, 'IDENTITY.md'), 'Bot identity here');
528
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
529
+ const ctx = makeCtx({ runtime, cwd: wsDir });
530
+ const job = makeJob();
531
+ await executeCronJob(job, ctx);
532
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
533
+ const paIndex = prompt.indexOf('--- IDENTITY.md ---');
534
+ const cronIndex = prompt.indexOf('You are executing a scheduled cron job');
535
+ expect(paIndex).toBeGreaterThanOrEqual(0);
536
+ expect(cronIndex).toBeGreaterThan(paIndex);
537
+ });
538
+ it('includes IDENTITY.md alone without errors', async () => {
539
+ await fs.writeFile(path.join(wsDir, 'IDENTITY.md'), 'Just identity');
540
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
541
+ const ctx = makeCtx({ runtime, cwd: wsDir });
542
+ const job = makeJob();
543
+ await executeCronJob(job, ctx);
544
+ expect(invokeSpy).toHaveBeenCalledOnce();
545
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
546
+ expect(prompt).toContain('--- IDENTITY.md ---');
547
+ expect(prompt).toContain('Just identity');
548
+ const guild = ctx.client.guilds.cache.get('guild-1');
549
+ const channel = guild.channels.cache.get('general');
550
+ expect(channel.send).toHaveBeenCalledOnce();
551
+ });
552
+ it('continues with bare prompt when PA loading throws (try/catch fallback)', async () => {
553
+ vi.mocked(loadWorkspacePaFiles).mockRejectedValueOnce(new Error('disk exploded'));
554
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
555
+ const ctx = makeCtx({ runtime, cwd: wsDir });
556
+ const job = makeJob();
557
+ await executeCronJob(job, ctx);
558
+ expect(invokeSpy).toHaveBeenCalledOnce();
559
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
560
+ expect(prompt).toContain('You are executing a scheduled cron job');
561
+ expect(prompt).not.toContain('--- ');
562
+ const guild = ctx.client.guilds.cache.get('guild-1');
563
+ const channel = guild.channels.cache.get('general');
564
+ expect(channel.send).toHaveBeenCalledOnce();
565
+ expect(ctx.log?.warn).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, err: expect.any(Error) }), 'cron:exec PA file loading failed, continuing without context');
566
+ });
567
+ it('prompt starts with ## Security Policy when PA files exist', async () => {
568
+ await fs.writeFile(path.join(wsDir, 'SOUL.md'), 'Be helpful.');
569
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
570
+ const ctx = makeCtx({ runtime, cwd: wsDir });
571
+ const job = makeJob();
572
+ await executeCronJob(job, ctx);
573
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
574
+ expect(prompt).toMatch(/^## Security Policy/);
575
+ });
576
+ it('prompt starts with ## Security Policy when no PA files exist', async () => {
577
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
578
+ const ctx = makeCtx({ runtime, cwd: wsDir });
579
+ const job = makeJob();
580
+ await executeCronJob(job, ctx);
581
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
582
+ expect(prompt).toMatch(/^## Security Policy/);
583
+ });
584
+ it('uses cronExecModel over ctx.model when set', async () => {
585
+ let invokedModel = '';
586
+ const runtime = {
587
+ id: 'claude_code',
588
+ capabilities: new Set(['streaming_text']),
589
+ async *invoke(params) {
590
+ invokedModel = params.model;
591
+ yield { type: 'text_final', text: 'ok' };
592
+ yield { type: 'done' };
593
+ },
594
+ };
595
+ const ctx = makeCtx({ runtime, model: 'sonnet', cronExecModel: 'haiku' });
596
+ await executeCronJob(makeJob(), ctx);
597
+ expect(invokedModel).toBe('haiku');
598
+ });
599
+ it('falls back to ctx.model when cronExecModel is not set', async () => {
600
+ let invokedModel = '';
601
+ const runtime = {
602
+ id: 'claude_code',
603
+ capabilities: new Set(['streaming_text']),
604
+ async *invoke(params) {
605
+ invokedModel = params.model;
606
+ yield { type: 'text_final', text: 'ok' };
607
+ yield { type: 'done' };
608
+ },
609
+ };
610
+ const ctx = makeCtx({ runtime, model: 'sonnet' });
611
+ await executeCronJob(makeJob(), ctx);
612
+ expect(invokedModel).toBe('sonnet');
613
+ });
614
+ });