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,181 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // pa-safety.md is retired from runtime loading: ROOT_POLICY now inlines the
4
+ // injection-defence rules as an immutable preamble in every prompt.
5
+ const PA_CONTEXT_MODULES = ['pa.md'];
6
+ function parseChannelIndexMarkdown(md, channelsDir) {
7
+ // Parse rows like:
8
+ // | #gallery | 1234567890123456789 | `discord/gallery.md` | ... |
9
+ // We only need channel name + id + context file hint.
10
+ const out = new Map();
11
+ const lines = md.split(/\r?\n/);
12
+ for (const line of lines) {
13
+ const trimmed = line.trim();
14
+ if (!trimmed.startsWith('|'))
15
+ continue;
16
+ const cells = trimmed
17
+ .split('|')
18
+ .map((c) => c.trim())
19
+ .filter((c) => c.length > 0);
20
+ if (cells.length < 2)
21
+ continue;
22
+ const chanCell = cells[0] ?? '';
23
+ const idCell = cells[1] ?? '';
24
+ if (!chanCell.startsWith('#'))
25
+ continue;
26
+ if (!/^\d{17,20}$/.test(idCell))
27
+ continue;
28
+ const channelName = chanCell.replace(/^#+/, '').trim();
29
+ const channelId = idCell;
30
+ let contextPath = path.join(channelsDir, `${channelName}.md`);
31
+ const ctxCell = cells[2] ?? '';
32
+ // Prefer explicit context file mapping when present.
33
+ if (ctxCell && ctxCell !== '—' && ctxCell !== '-') {
34
+ const unquoted = ctxCell.replace(/`/g, '').trim();
35
+ const base = path.basename(unquoted);
36
+ if (base && base.toLowerCase().endsWith('.md')) {
37
+ contextPath = path.join(channelsDir, base);
38
+ }
39
+ }
40
+ out.set(channelId, { channelId, channelName, contextPath });
41
+ }
42
+ return out;
43
+ }
44
+ async function fileExists(p) {
45
+ try {
46
+ await fs.stat(p);
47
+ return true;
48
+ }
49
+ catch (err) {
50
+ const code = err.code;
51
+ if (code === 'ENOENT')
52
+ return false;
53
+ throw err;
54
+ }
55
+ }
56
+ async function writeFileIfMissing(p, body) {
57
+ if (await fileExists(p))
58
+ return false;
59
+ await fs.mkdir(path.dirname(p), { recursive: true });
60
+ await fs.writeFile(p, body, 'utf8');
61
+ return true;
62
+ }
63
+ function channelFileNameFromName(name) {
64
+ const safe = name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
65
+ return (safe || 'channel') + '.md';
66
+ }
67
+ function channelContextTemplate(args) {
68
+ return [
69
+ `# #${args.channelName} Context`,
70
+ `Channel ID: ${args.channelId}`,
71
+ '',
72
+ 'Channel-specific notes:',
73
+ '-',
74
+ '',
75
+ ].join('\n');
76
+ }
77
+ async function ensureDiscordIndexExists(indexPath) {
78
+ if (await fileExists(indexPath))
79
+ return;
80
+ await fs.mkdir(path.dirname(indexPath), { recursive: true });
81
+ const body = [
82
+ '# DISCORD.md - Channel Context Index',
83
+ '',
84
+ '| Channel | ID | Context File | Purpose |',
85
+ '|---------|----|--------------|---------|',
86
+ '',
87
+ ].join('\n');
88
+ await fs.writeFile(indexPath, body, 'utf8');
89
+ }
90
+ export async function ensureIndexedDiscordChannelContext(args) {
91
+ const existing = args.ctx.byChannelId.get(args.channelId);
92
+ if (existing)
93
+ return existing;
94
+ const channelName = (args.channelName ?? '').trim() || `channel-${args.channelId}`;
95
+ const fileName = channelFileNameFromName(channelName === `channel-${args.channelId}`
96
+ ? `channel-${args.channelId}`
97
+ : channelName);
98
+ const contextPath = path.join(args.ctx.channelsDir, fileName);
99
+ await ensureDiscordIndexExists(args.ctx.indexPath);
100
+ const row = `| #${channelName} | ${args.channelId} | \`discord/channels/${fileName}\` | — |`;
101
+ await fs.appendFile(args.ctx.indexPath, row + '\n', 'utf8');
102
+ const entry = { channelId: args.channelId, channelName, contextPath };
103
+ args.ctx.byChannelId.set(args.channelId, entry);
104
+ const didCreate = await writeFileIfMissing(contextPath, channelContextTemplate({
105
+ channelName,
106
+ channelId: args.channelId,
107
+ }));
108
+ if (didCreate) {
109
+ args.log?.info({ channelId: args.channelId, contextPath }, 'discord:context created placeholder for new channel');
110
+ }
111
+ return entry;
112
+ }
113
+ export async function validatePaContextModules(contextModulesDir) {
114
+ for (const mod of PA_CONTEXT_MODULES) {
115
+ const p = path.join(contextModulesDir, mod);
116
+ try {
117
+ await fs.access(p);
118
+ }
119
+ catch {
120
+ throw new Error(`Required PA context module not found: ${p}. ` +
121
+ `Ensure .context/${mod} exists in the repo root.`);
122
+ }
123
+ }
124
+ }
125
+ export async function loadDiscordChannelContext(opts) {
126
+ const contentDir = opts.contentDir;
127
+ const indexPath = path.join(contentDir, 'discord', 'DISCORD.md');
128
+ const channelsDir = path.join(contentDir, 'discord', 'channels');
129
+ const dmContextPath = path.join(channelsDir, 'dm.md');
130
+ const paContextFiles = PA_CONTEXT_MODULES.map((f) => path.join(opts.contextModulesDir, f));
131
+ await writeFileIfMissing(dmContextPath, channelContextTemplate({
132
+ channelName: 'dm',
133
+ channelId: 'dm',
134
+ }));
135
+ let md = '';
136
+ try {
137
+ md = await fs.readFile(indexPath, 'utf8');
138
+ }
139
+ catch (err) {
140
+ const code = err.code;
141
+ if (code !== 'ENOENT')
142
+ throw err;
143
+ opts.log?.warn({ indexPath }, 'discord:context index missing; using fallback only');
144
+ md = '';
145
+ }
146
+ const byChannelId = md ? parseChannelIndexMarkdown(md, channelsDir) : new Map();
147
+ // Guarantee that every indexed channel has a context file (create placeholders for missing).
148
+ let created = 0;
149
+ for (const entry of byChannelId.values()) {
150
+ const didCreate = await writeFileIfMissing(entry.contextPath, channelContextTemplate({
151
+ channelName: entry.channelName,
152
+ channelId: entry.channelId,
153
+ }));
154
+ if (didCreate)
155
+ created++;
156
+ }
157
+ if (created > 0) {
158
+ opts.log?.info({ created, channelsDir }, 'discord:context bootstrapped missing channel context files');
159
+ }
160
+ return {
161
+ contentDir,
162
+ indexPath,
163
+ paContextFiles,
164
+ channelsDir,
165
+ byChannelId,
166
+ dmContextPath,
167
+ };
168
+ }
169
+ export function resolveDiscordChannelContext(args) {
170
+ const ctx = args.ctx;
171
+ if (!ctx)
172
+ return { channelId: args.channelId };
173
+ if (args.isDm) {
174
+ return { channelId: args.channelId, channelName: 'dm', contextPath: ctx.dmContextPath };
175
+ }
176
+ const id = (args.threadParentId && args.threadParentId.trim()) ? args.threadParentId : args.channelId;
177
+ const hit = ctx.byChannelId.get(id);
178
+ if (hit)
179
+ return { channelId: id, channelName: hit.channelName, contextPath: hit.contextPath };
180
+ return { channelId: id, channelName: 'unknown' };
181
+ }
@@ -0,0 +1,45 @@
1
+ export class DeferScheduler {
2
+ activeCount = 0;
3
+ maxDelaySeconds;
4
+ maxConcurrent;
5
+ jobHandler;
6
+ constructor(opts) {
7
+ this.maxDelaySeconds = opts.maxDelaySeconds;
8
+ this.maxConcurrent = opts.maxConcurrent;
9
+ this.jobHandler = opts.jobHandler;
10
+ }
11
+ schedule(job) {
12
+ const delaySeconds = job.action.delaySeconds;
13
+ if (!Number.isFinite(delaySeconds)) {
14
+ return { ok: false, error: 'delaySeconds must be a number' };
15
+ }
16
+ if (delaySeconds <= 0) {
17
+ return { ok: false, error: 'delaySeconds must be greater than zero' };
18
+ }
19
+ if (delaySeconds > this.maxDelaySeconds) {
20
+ return {
21
+ ok: false,
22
+ error: `delaySeconds cannot exceed ${this.maxDelaySeconds} seconds`,
23
+ };
24
+ }
25
+ if (this.activeCount >= this.maxConcurrent) {
26
+ return {
27
+ ok: false,
28
+ error: `Maximum of ${this.maxConcurrent} deferred actions are already scheduled`,
29
+ };
30
+ }
31
+ this.activeCount++;
32
+ const runsAt = new Date(Date.now() + delaySeconds * 1000);
33
+ const delayMs = delaySeconds * 1000;
34
+ const invokeHandler = async () => {
35
+ try {
36
+ await Promise.resolve(this.jobHandler({ action: job.action, context: job.context, runsAt }));
37
+ }
38
+ finally {
39
+ this.activeCount = Math.max(0, this.activeCount - 1);
40
+ }
41
+ };
42
+ setTimeout(invokeHandler, delayMs);
43
+ return { ok: true, runsAt, delaySeconds };
44
+ }
45
+ }
@@ -0,0 +1,128 @@
1
+ import crypto from 'node:crypto';
2
+ const CONFIRM_TTL_MS = 10 * 60_000;
3
+ const DESTRUCTIVE_ACTION_TYPES = new Set([
4
+ 'channelDelete',
5
+ 'forumTagDelete',
6
+ 'deleteMessage',
7
+ 'bulkDelete',
8
+ 'eventDelete',
9
+ 'timeout',
10
+ 'kick',
11
+ 'ban',
12
+ ]);
13
+ const pendingByToken = new Map();
14
+ const tokenByKey = new Map();
15
+ function stableStringify(value) {
16
+ if (value === null || typeof value !== 'object') {
17
+ return JSON.stringify(value);
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return '[' + value.map((v) => stableStringify(v)).join(',') + ']';
21
+ }
22
+ const entries = Object.entries(value)
23
+ .sort(([a], [b]) => a.localeCompare(b))
24
+ .map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v));
25
+ return '{' + entries.join(',') + '}';
26
+ }
27
+ function actionFingerprint(action) {
28
+ return crypto
29
+ .createHash('sha256')
30
+ .update(stableStringify(action))
31
+ .digest('hex')
32
+ .slice(0, 12);
33
+ }
34
+ function makeKey(sessionKey, userId, fingerprint) {
35
+ return `${sessionKey}:${userId}:${fingerprint}`;
36
+ }
37
+ function randomToken() {
38
+ return crypto.randomBytes(4).toString('hex');
39
+ }
40
+ function uniqueRandomToken() {
41
+ let token = randomToken();
42
+ while (pendingByToken.has(token))
43
+ token = randomToken();
44
+ return token;
45
+ }
46
+ function purgeExpired(now = Date.now()) {
47
+ for (const [token, pending] of pendingByToken) {
48
+ if (pending.expiresAt > now)
49
+ continue;
50
+ pendingByToken.delete(token);
51
+ tokenByKey.delete(makeKey(pending.sessionKey, pending.userId, pending.actionFingerprint));
52
+ }
53
+ }
54
+ export function isDestructiveActionType(type) {
55
+ return DESTRUCTIVE_ACTION_TYPES.has(type);
56
+ }
57
+ export function requestDestructiveConfirmation(action, sessionKey, userId) {
58
+ purgeExpired();
59
+ const now = Date.now();
60
+ const fingerprint = actionFingerprint(action);
61
+ const key = makeKey(sessionKey, userId, fingerprint);
62
+ const existingToken = tokenByKey.get(key);
63
+ if (existingToken) {
64
+ const existing = pendingByToken.get(existingToken);
65
+ if (existing && existing.expiresAt > now) {
66
+ return existing;
67
+ }
68
+ tokenByKey.delete(key);
69
+ if (existingToken)
70
+ pendingByToken.delete(existingToken);
71
+ }
72
+ const token = uniqueRandomToken();
73
+ const pending = {
74
+ token,
75
+ sessionKey,
76
+ userId,
77
+ action,
78
+ actionType: action.type,
79
+ actionFingerprint: fingerprint,
80
+ createdAt: now,
81
+ expiresAt: now + CONFIRM_TTL_MS,
82
+ };
83
+ pendingByToken.set(token, pending);
84
+ tokenByKey.set(key, token);
85
+ return pending;
86
+ }
87
+ export function consumeDestructiveConfirmation(token, sessionKey, userId) {
88
+ purgeExpired();
89
+ const pending = pendingByToken.get(token);
90
+ if (!pending)
91
+ return null;
92
+ if (pending.sessionKey !== sessionKey)
93
+ return null;
94
+ if (pending.userId !== userId)
95
+ return null;
96
+ pendingByToken.delete(token);
97
+ tokenByKey.delete(makeKey(pending.sessionKey, pending.userId, pending.actionFingerprint));
98
+ return pending;
99
+ }
100
+ export function describeDestructiveConfirmationRequirement(action, confirmation) {
101
+ if (!isDestructiveActionType(action.type))
102
+ return { allow: true };
103
+ if (confirmation?.bypassDestructive)
104
+ return { allow: true };
105
+ if (confirmation?.mode !== 'interactive') {
106
+ return {
107
+ allow: false,
108
+ error: `Blocked destructive action "${action.type}": destructive actions require interactive user confirmation and are disabled in automated flows.`,
109
+ };
110
+ }
111
+ const sessionKey = confirmation.sessionKey?.trim();
112
+ const userId = confirmation.userId?.trim();
113
+ if (!sessionKey || !userId) {
114
+ return {
115
+ allow: false,
116
+ error: `Blocked destructive action "${action.type}": missing confirmation context.`,
117
+ };
118
+ }
119
+ const pending = requestDestructiveConfirmation(action, sessionKey, userId);
120
+ return {
121
+ allow: false,
122
+ error: `Destructive action "${action.type}" requires confirmation. Run \`!confirm ${pending.token}\` in this channel within 10 minutes to execute.`,
123
+ };
124
+ }
125
+ export function _resetDestructiveConfirmationForTest() {
126
+ pendingByToken.clear();
127
+ tokenByKey.clear();
128
+ }
@@ -0,0 +1,49 @@
1
+ import crypto from 'node:crypto';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { _resetDestructiveConfirmationForTest as resetState, requestDestructiveConfirmation, consumeDestructiveConfirmation, describeDestructiveConfirmationRequirement, } from './destructive-confirmation.js';
4
+ describe('destructive confirmation state', () => {
5
+ beforeEach(() => {
6
+ resetState();
7
+ });
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+ it('reuses token for the same action/session/user while pending', () => {
12
+ const action = { type: 'channelDelete', channelId: '123' };
13
+ const first = requestDestructiveConfirmation(action, 'discord:channel:1', 'u1');
14
+ const second = requestDestructiveConfirmation(action, 'discord:channel:1', 'u1');
15
+ expect(first.token).toBe(second.token);
16
+ });
17
+ it('consumes token only for matching session and user', () => {
18
+ const action = { type: 'channelDelete', channelId: '123' };
19
+ const pending = requestDestructiveConfirmation(action, 'discord:channel:1', 'u1');
20
+ expect(consumeDestructiveConfirmation(pending.token, 'discord:channel:2', 'u1')).toBeNull();
21
+ expect(consumeDestructiveConfirmation(pending.token, 'discord:channel:1', 'u2')).toBeNull();
22
+ const consumed = consumeDestructiveConfirmation(pending.token, 'discord:channel:1', 'u1');
23
+ expect(consumed?.actionType).toBe('channelDelete');
24
+ expect(consumeDestructiveConfirmation(pending.token, 'discord:channel:1', 'u1')).toBeNull();
25
+ });
26
+ it('blocks destructive actions in automated mode', () => {
27
+ const decision = describeDestructiveConfirmationRequirement({ type: 'ban', userId: '42' }, { mode: 'automated' });
28
+ expect(decision.allow).toBe(false);
29
+ if (decision.allow)
30
+ throw new Error('expected block');
31
+ expect(decision.error).toContain('disabled in automated flows');
32
+ });
33
+ it('allows non-destructive actions without confirmation metadata', () => {
34
+ const decision = describeDestructiveConfirmationRequirement({ type: 'channelList' }, undefined);
35
+ expect(decision).toEqual({ allow: true });
36
+ });
37
+ it('generates a different token when random token collides with a pending entry', () => {
38
+ const randomSpy = vi.spyOn(crypto, 'randomBytes');
39
+ randomSpy
40
+ .mockReturnValueOnce(Buffer.from('aaaaaaaa', 'hex'))
41
+ .mockReturnValueOnce(Buffer.from('aaaaaaaa', 'hex'))
42
+ .mockReturnValueOnce(Buffer.from('bbbbbbbb', 'hex'));
43
+ const first = requestDestructiveConfirmation({ type: 'channelDelete', channelId: 'c1' }, 'discord:channel:1', 'u1');
44
+ const second = requestDestructiveConfirmation({ type: 'ban', userId: '42' }, 'discord:channel:1', 'u1');
45
+ expect(first.token).toBe('aaaaaaaa');
46
+ expect(second.token).toBe('bbbbbbbb');
47
+ expect(first.token).not.toBe(second.token);
48
+ });
49
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildPlanImplementationMessage, legacyPlanImplementationCta, } from './forge-commands.js';
3
+ describe('buildPlanImplementationMessage', () => {
4
+ const planId = 'plan-123';
5
+ it('returns only the CTA when there is no skip reason', () => {
6
+ expect(buildPlanImplementationMessage(undefined, planId)).toBe(legacyPlanImplementationCta(planId));
7
+ });
8
+ it('appends the CTA when the skip reason does not already include it', () => {
9
+ const reason = 'Skipped auto-implementation because severity warnings remain.';
10
+ const message = buildPlanImplementationMessage(reason, planId);
11
+ expect(message).toBe(`${reason}\n\n${legacyPlanImplementationCta(planId)}`);
12
+ });
13
+ it('does not duplicate the CTA when the skip reason already contains it', () => {
14
+ const cta = legacyPlanImplementationCta(planId);
15
+ const reason = `Auto-implementation skipped.\n\n${cta}`;
16
+ expect(buildPlanImplementationMessage(reason, planId)).toBe(reason);
17
+ });
18
+ });
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ export const CURRENT_VERSION = 1;
5
+ export function emptyStore() {
6
+ return { version: CURRENT_VERSION, updatedAt: Date.now(), items: [] };
7
+ }
8
+ function migrateStore(store) {
9
+ // Migration blocks run first, then the unknown-version guard.
10
+ // Future migrations follow the run-stats.ts pattern:
11
+ // if ((store as any).version === 1) { /* transform fields */; store.version = 2; }
12
+ if (store.version !== CURRENT_VERSION) {
13
+ // Unrecognized (future) version — caller will create a fresh store.
14
+ return null;
15
+ }
16
+ return store;
17
+ }
18
+ function safeUserId(userId) {
19
+ if (!/^[a-zA-Z0-9_-]+$/.test(userId)) {
20
+ throw new Error(`Invalid userId for durable memory path: ${userId}`);
21
+ }
22
+ return userId;
23
+ }
24
+ export async function loadDurableMemory(dir, userId) {
25
+ const filePath = path.join(dir, `${safeUserId(userId)}.json`);
26
+ try {
27
+ const raw = await fs.readFile(filePath, 'utf8');
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed &&
30
+ typeof parsed === 'object' &&
31
+ 'version' in parsed &&
32
+ 'items' in parsed &&
33
+ Array.isArray(parsed.items)) {
34
+ return migrateStore(parsed) ?? emptyStore();
35
+ }
36
+ return null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function saveDurableMemory(dir, userId, store) {
43
+ await fs.mkdir(dir, { recursive: true });
44
+ const filePath = path.join(dir, `${safeUserId(userId)}.json`);
45
+ const tmp = `${filePath}.tmp.${process.pid}`;
46
+ await fs.writeFile(tmp, JSON.stringify(store, null, 2) + '\n', 'utf8');
47
+ await fs.rename(tmp, filePath);
48
+ }
49
+ export function deriveItemId(kind, text) {
50
+ const normalized = kind + ':' + text.trim().toLowerCase().replace(/\s+/g, ' ');
51
+ return 'durable-' + crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
52
+ }
53
+ export function addItem(store, text, source, maxItems, kind = 'fact') {
54
+ const now = Date.now();
55
+ const id = deriveItemId(kind, text);
56
+ const existing = store.items.find((item) => item.id === id && item.status === 'active');
57
+ if (existing) {
58
+ existing.kind = kind;
59
+ existing.text = text;
60
+ existing.source = source;
61
+ existing.updatedAt = now;
62
+ store.updatedAt = now;
63
+ return store;
64
+ }
65
+ const item = {
66
+ id,
67
+ kind,
68
+ text,
69
+ tags: [],
70
+ status: 'active',
71
+ source,
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ };
75
+ store.items.push(item);
76
+ store.updatedAt = now;
77
+ // Enforce maxItems cap.
78
+ while (store.items.length > maxItems) {
79
+ // Drop oldest deprecated first.
80
+ const deprecatedIdx = store.items
81
+ .map((it, i) => ({ it, i }))
82
+ .filter(({ it }) => it.status === 'deprecated')
83
+ .sort((a, b) => a.it.updatedAt - b.it.updatedAt)[0];
84
+ if (deprecatedIdx) {
85
+ store.items.splice(deprecatedIdx.i, 1);
86
+ }
87
+ else {
88
+ // Drop oldest active.
89
+ const activeIdx = store.items
90
+ .map((it, i) => ({ it, i }))
91
+ .filter(({ it }) => it.status === 'active')
92
+ .sort((a, b) => a.it.updatedAt - b.it.updatedAt)[0];
93
+ if (activeIdx) {
94
+ store.items.splice(activeIdx.i, 1);
95
+ }
96
+ else {
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ return store;
102
+ }
103
+ export function deprecateItems(store, substring) {
104
+ const now = Date.now();
105
+ const needle = substring.toLowerCase();
106
+ let deprecatedCount = 0;
107
+ for (const item of store.items) {
108
+ if (item.status !== 'active')
109
+ continue;
110
+ // Match if substring covers >= 60% of item's text length.
111
+ const textLower = item.text.toLowerCase();
112
+ if (textLower.includes(needle) && needle.length >= item.text.length * 0.6) {
113
+ item.status = 'deprecated';
114
+ item.updatedAt = now;
115
+ deprecatedCount++;
116
+ }
117
+ }
118
+ if (deprecatedCount > 0)
119
+ store.updatedAt = now;
120
+ return { store, deprecatedCount };
121
+ }
122
+ export function selectItemsForInjection(store, maxChars) {
123
+ const active = store.items
124
+ .filter((item) => item.status === 'active')
125
+ .sort((a, b) => b.updatedAt - a.updatedAt);
126
+ const selected = [];
127
+ let chars = 0;
128
+ for (const item of active) {
129
+ const lineLen = formatItemLine(item).length;
130
+ const sep = selected.length > 0 ? 1 : 0; // \n between items
131
+ if (chars + sep + lineLen > maxChars)
132
+ continue;
133
+ selected.push(item);
134
+ chars += sep + lineLen;
135
+ }
136
+ return selected;
137
+ }
138
+ function formatItemLine(item) {
139
+ const date = new Date(item.updatedAt).toISOString().slice(0, 10);
140
+ const ch = item.source.channelName ? `, #${item.source.channelName}` : '';
141
+ return `- [${item.kind}] ${item.text} (src: ${item.source.type}${ch}, updated ${date})`;
142
+ }
143
+ export function formatDurableSection(items) {
144
+ return items.map(formatItemLine).join('\n');
145
+ }