rubik 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 (523) hide show
  1. checksums.yaml +7 -0
  2. data/ATTRIBUTION.md +139 -0
  3. data/CHANGELOG.md +236 -0
  4. data/LICENSE +21 -0
  5. data/README.md +269 -0
  6. data/bin/rubik +82 -0
  7. data/lib/rubik/adapter/base.rb +195 -0
  8. data/lib/rubik/adapter/c.rb +36 -0
  9. data/lib/rubik/adapter/crystal.rb +78 -0
  10. data/lib/rubik/adapter/generic.rb +86 -0
  11. data/lib/rubik/adapter/go.rb +36 -0
  12. data/lib/rubik/adapter/node.rb +146 -0
  13. data/lib/rubik/adapter/process.rb +78 -0
  14. data/lib/rubik/adapter/python.rb +30 -0
  15. data/lib/rubik/adapter/registry.rb +81 -0
  16. data/lib/rubik/adapter/ruby.rb +30 -0
  17. data/lib/rubik/adapter/rust.rb +36 -0
  18. data/lib/rubik/adapter/zig.rb +36 -0
  19. data/lib/rubik/adapter.rb +18 -0
  20. data/lib/rubik/ast/body_loader.rb +19 -0
  21. data/lib/rubik/ast/c_extractor.rb +114 -0
  22. data/lib/rubik/ast/cache_store.rb +191 -0
  23. data/lib/rubik/ast/extractor.rb +59 -0
  24. data/lib/rubik/ast/fallback_cleaner.rb +145 -0
  25. data/lib/rubik/ast/go_extractor.rb +148 -0
  26. data/lib/rubik/ast/graph.rb +272 -0
  27. data/lib/rubik/ast/python_extractor.rb +120 -0
  28. data/lib/rubik/ast/ruby_extractor.rb +260 -0
  29. data/lib/rubik/ast/rust_extractor.rb +127 -0
  30. data/lib/rubik/ast/symbol.rb +103 -0
  31. data/lib/rubik/ast/treesitter/base_walker.rb +109 -0
  32. data/lib/rubik/ast/treesitter/c_walker.rb +122 -0
  33. data/lib/rubik/ast/treesitter/go_walker.rb +120 -0
  34. data/lib/rubik/ast/treesitter/python_walker.rb +107 -0
  35. data/lib/rubik/ast/treesitter/rust_walker.rb +130 -0
  36. data/lib/rubik/ast/treesitter/typescript_walker.rb +182 -0
  37. data/lib/rubik/ast/treesitter/zig_walker.rb +109 -0
  38. data/lib/rubik/ast/treesitter_extractor.rb +93 -0
  39. data/lib/rubik/ast/typescript_extractor.rb +283 -0
  40. data/lib/rubik/ast/zig_extractor.rb +116 -0
  41. data/lib/rubik/ast.rb +13 -0
  42. data/lib/rubik/benchmark/runner.rb +224 -0
  43. data/lib/rubik/benchmark/synth_repo.rb +59 -0
  44. data/lib/rubik/benchmark.rb +9 -0
  45. data/lib/rubik/buffer/diff.rb +43 -0
  46. data/lib/rubik/buffer/manager.rb +427 -0
  47. data/lib/rubik/buffer/path_validator.rb +60 -0
  48. data/lib/rubik/buffer/snapshot.rb +99 -0
  49. data/lib/rubik/buffer.rb +10 -0
  50. data/lib/rubik/coeffects.rb +129 -0
  51. data/lib/rubik/concurrency/boundary.rb +74 -0
  52. data/lib/rubik/concurrency/dns_pool.rb +68 -0
  53. data/lib/rubik/concurrency/errors.rb +17 -0
  54. data/lib/rubik/concurrency/event_loop.rb +441 -0
  55. data/lib/rubik/concurrency/join.rb +55 -0
  56. data/lib/rubik/concurrency/process_waiter.rb +55 -0
  57. data/lib/rubik/concurrency/scheduler.rb +84 -0
  58. data/lib/rubik/concurrency/timers.rb +109 -0
  59. data/lib/rubik/concurrency.rb +132 -0
  60. data/lib/rubik/config/deep_ops.rb +80 -0
  61. data/lib/rubik/config/defaults.rb +248 -0
  62. data/lib/rubik/config/model_catalog.rb +93 -0
  63. data/lib/rubik/config/store.rb +155 -0
  64. data/lib/rubik/config.rb +216 -0
  65. data/lib/rubik/context/git_info.rb +84 -0
  66. data/lib/rubik/context/git_lister.rb +37 -0
  67. data/lib/rubik/context/index.rb +134 -0
  68. data/lib/rubik/context/repo_context_reader.rb +121 -0
  69. data/lib/rubik/context/scanner.rb +439 -0
  70. data/lib/rubik/context.rb +11 -0
  71. data/lib/rubik/diagnostics/collector.rb +200 -0
  72. data/lib/rubik/diagnostics/entry.rb +60 -0
  73. data/lib/rubik/diagnostics/parser.rb +107 -0
  74. data/lib/rubik/diagnostics.rb +10 -0
  75. data/lib/rubik/event_bus.rb +56 -0
  76. data/lib/rubik/kernel/dispatcher.rb +171 -0
  77. data/lib/rubik/kernel/dmesg.rb +116 -0
  78. data/lib/rubik/kernel/init.rb +177 -0
  79. data/lib/rubik/kernel/record.rb +85 -0
  80. data/lib/rubik/kernel/unit.rb +160 -0
  81. data/lib/rubik/llm/anthropic.rb +202 -0
  82. data/lib/rubik/llm/chunk_assembler.rb +285 -0
  83. data/lib/rubik/llm/client.rb +526 -0
  84. data/lib/rubik/llm/formatter.rb +450 -0
  85. data/lib/rubik/llm/message_sanitizer.rb +87 -0
  86. data/lib/rubik/llm/ollama.rb +167 -0
  87. data/lib/rubik/llm/openai.rb +21 -0
  88. data/lib/rubik/llm/openai_compatible.rb +216 -0
  89. data/lib/rubik/llm/provider.rb +125 -0
  90. data/lib/rubik/llm/registry.rb +336 -0
  91. data/lib/rubik/llm/router.rb +100 -0
  92. data/lib/rubik/llm.rb +18 -0
  93. data/lib/rubik/log.rb +38 -0
  94. data/lib/rubik/lsp/client.rb +475 -0
  95. data/lib/rubik/lsp/manager.rb +366 -0
  96. data/lib/rubik/lsp.rb +9 -0
  97. data/lib/rubik/mcp/boot_lifecycle.rb +114 -0
  98. data/lib/rubik/mcp/client.rb +169 -0
  99. data/lib/rubik/mcp/errors.rb +11 -0
  100. data/lib/rubik/mcp/manager.rb +619 -0
  101. data/lib/rubik/mcp/proxy_tool.rb +104 -0
  102. data/lib/rubik/mcp/transport/stdio.rb +255 -0
  103. data/lib/rubik/mcp.rb +12 -0
  104. data/lib/rubik/orchestrator/activity_log.rb +75 -0
  105. data/lib/rubik/orchestrator/auto_apply.rb +166 -0
  106. data/lib/rubik/orchestrator/budget_manager.rb +97 -0
  107. data/lib/rubik/orchestrator/continuation_manager.rb +161 -0
  108. data/lib/rubik/orchestrator/finish_message_selector.rb +55 -0
  109. data/lib/rubik/orchestrator/goal_ledger/drift.rb +71 -0
  110. data/lib/rubik/orchestrator/goal_ledger/ledger.rb +399 -0
  111. data/lib/rubik/orchestrator/goal_ledger/observation_projection.rb +179 -0
  112. data/lib/rubik/orchestrator/goal_ledger/patch.rb +268 -0
  113. data/lib/rubik/orchestrator/goal_ledger/schema.rb +123 -0
  114. data/lib/rubik/orchestrator/goal_ledger/validator.rb +361 -0
  115. data/lib/rubik/orchestrator/goal_ledger/verifier.rb +129 -0
  116. data/lib/rubik/orchestrator/goal_ledger/verify_spec.rb +50 -0
  117. data/lib/rubik/orchestrator/goal_ledger.rb +65 -0
  118. data/lib/rubik/orchestrator/goal_state.rb +184 -0
  119. data/lib/rubik/orchestrator/goal_wait.rb +125 -0
  120. data/lib/rubik/orchestrator/loop.rb +388 -0
  121. data/lib/rubik/orchestrator/lsp_feedback.rb +55 -0
  122. data/lib/rubik/orchestrator/maestro_gate.rb +65 -0
  123. data/lib/rubik/orchestrator/provider_recovery.rb +90 -0
  124. data/lib/rubik/orchestrator/provider_resolver.rb +69 -0
  125. data/lib/rubik/orchestrator/shell_deps.rb +85 -0
  126. data/lib/rubik/orchestrator/step_executor.rb +328 -0
  127. data/lib/rubik/orchestrator/stream_manager.rb +118 -0
  128. data/lib/rubik/orchestrator/subagent/activation.rb +65 -0
  129. data/lib/rubik/orchestrator/subagent/budget.rb +108 -0
  130. data/lib/rubik/orchestrator/subagent/completion.rb +61 -0
  131. data/lib/rubik/orchestrator/subagent/concurrency_gate.rb +37 -0
  132. data/lib/rubik/orchestrator/subagent/conversation_id.rb +67 -0
  133. data/lib/rubik/orchestrator/subagent/dependency.rb +398 -0
  134. data/lib/rubik/orchestrator/subagent/instance.rb +384 -0
  135. data/lib/rubik/orchestrator/subagent/lifecycle.rb +118 -0
  136. data/lib/rubik/orchestrator/subagent/messaging.rb +61 -0
  137. data/lib/rubik/orchestrator/subagent/narration.rb +125 -0
  138. data/lib/rubik/orchestrator/subagent/notices.rb +209 -0
  139. data/lib/rubik/orchestrator/subagent/questioner.rb +80 -0
  140. data/lib/rubik/orchestrator/subagent/reaper.rb +98 -0
  141. data/lib/rubik/orchestrator/subagent/resilience.rb +70 -0
  142. data/lib/rubik/orchestrator/subagent/reuse.rb +127 -0
  143. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +79 -0
  144. data/lib/rubik/orchestrator/subagent/step_runner.rb +240 -0
  145. data/lib/rubik/orchestrator/subagent/storage_id.rb +26 -0
  146. data/lib/rubik/orchestrator/subagent/supervisor.rb +327 -0
  147. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +141 -0
  148. data/lib/rubik/orchestrator/subagent/type_registry.rb +199 -0
  149. data/lib/rubik/orchestrator/subagent_notice_inbox.rb +80 -0
  150. data/lib/rubik/orchestrator/tool_call_id.rb +47 -0
  151. data/lib/rubik/orchestrator/tool_circuit_breaker.rb +91 -0
  152. data/lib/rubik/orchestrator/tool_history.rb +39 -0
  153. data/lib/rubik/orchestrator/turn_executor/subagent_channel.rb +102 -0
  154. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +172 -0
  155. data/lib/rubik/orchestrator/turn_executor.rb +376 -0
  156. data/lib/rubik/orchestrator.rb +11 -0
  157. data/lib/rubik/platform/filesystem.rb +64 -0
  158. data/lib/rubik/platform/io_drain.rb +53 -0
  159. data/lib/rubik/platform/path.rb +210 -0
  160. data/lib/rubik/platform/process.rb +38 -0
  161. data/lib/rubik/platform/sanitize.rb +157 -0
  162. data/lib/rubik/platform.rb +113 -0
  163. data/lib/rubik/plugin/dsl.rb +51 -0
  164. data/lib/rubik/plugin/manager.rb +247 -0
  165. data/lib/rubik/plugin/storage.rb +195 -0
  166. data/lib/rubik/plugin.rb +10 -0
  167. data/lib/rubik/prompt/context_builder.rb +377 -0
  168. data/lib/rubik/prompt/context_compactor.rb +326 -0
  169. data/lib/rubik/prompt/conversation_rows.rb +74 -0
  170. data/lib/rubik/prompt/doctrine.rb +160 -0
  171. data/lib/rubik/prompt/emergency_compactor.rb +81 -0
  172. data/lib/rubik/prompt/services.rb +60 -0
  173. data/lib/rubik/prompt/token_estimator.rb +63 -0
  174. data/lib/rubik/prompt/tool_teachings.rb +78 -0
  175. data/lib/rubik/prompt.rb +13 -0
  176. data/lib/rubik/repair/loop.rb +95 -0
  177. data/lib/rubik/repair.rb +8 -0
  178. data/lib/rubik/repl/ast_warmup.rb +129 -0
  179. data/lib/rubik/repl/command_context.rb +214 -0
  180. data/lib/rubik/repl/command_parser.rb +48 -0
  181. data/lib/rubik/repl/commands/agent.rb +30 -0
  182. data/lib/rubik/repl/commands/agent_context.rb +289 -0
  183. data/lib/rubik/repl/commands/agent_execution.rb +342 -0
  184. data/lib/rubik/repl/commands/agent_models.rb +212 -0
  185. data/lib/rubik/repl/commands/agent_providers.rb +232 -0
  186. data/lib/rubik/repl/commands/base.rb +55 -0
  187. data/lib/rubik/repl/commands/buffer.rb +124 -0
  188. data/lib/rubik/repl/commands/dmesg.rb +87 -0
  189. data/lib/rubik/repl/commands/lsp.rb +235 -0
  190. data/lib/rubik/repl/commands/mcp.rb +194 -0
  191. data/lib/rubik/repl/commands/plugins.rb +208 -0
  192. data/lib/rubik/repl/commands/pruner.rb +71 -0
  193. data/lib/rubik/repl/commands/session.rb +183 -0
  194. data/lib/rubik/repl/commands/skills.rb +111 -0
  195. data/lib/rubik/repl/commands/storage.rb +85 -0
  196. data/lib/rubik/repl/commands/subagents.rb +195 -0
  197. data/lib/rubik/repl/commands/system.rb +85 -0
  198. data/lib/rubik/repl/commands/tasks.rb +120 -0
  199. data/lib/rubik/repl/commands/workspace.rb +443 -0
  200. data/lib/rubik/repl/commands.rb +44 -0
  201. data/lib/rubik/repl/composition.rb +32 -0
  202. data/lib/rubik/repl/config_wiring.rb +142 -0
  203. data/lib/rubik/repl/confirmations.rb +56 -0
  204. data/lib/rubik/repl/hooks.rb +47 -0
  205. data/lib/rubik/repl/input_execution.rb +323 -0
  206. data/lib/rubik/repl/input_loop.rb +213 -0
  207. data/lib/rubik/repl/interrupt_handler.rb +217 -0
  208. data/lib/rubik/repl/lifecycle.rb +410 -0
  209. data/lib/rubik/repl/paste_support.rb +88 -0
  210. data/lib/rubik/repl/pending_notice_queue.rb +60 -0
  211. data/lib/rubik/repl/presentation.rb +132 -0
  212. data/lib/rubik/repl/pruner/brief.rb +101 -0
  213. data/lib/rubik/repl/pruner/gate.rb +279 -0
  214. data/lib/rubik/repl/pruner/harness.rb +236 -0
  215. data/lib/rubik/repl/pruner/rules.rb +316 -0
  216. data/lib/rubik/repl/pruner/service.rb +82 -0
  217. data/lib/rubik/repl/pruner.rb +39 -0
  218. data/lib/rubik/repl/service_catalog.rb +374 -0
  219. data/lib/rubik/repl/setup_factories.rb +66 -0
  220. data/lib/rubik/repl/shell.rb +231 -0
  221. data/lib/rubik/repl/step_formatter.rb +324 -0
  222. data/lib/rubik/repl/tee_io.rb +105 -0
  223. data/lib/rubik/repl/terminal_guard.rb +157 -0
  224. data/lib/rubik/repl/turn_recorder.rb +234 -0
  225. data/lib/rubik/repl.rb +14 -0
  226. data/lib/rubik/runtime/ast_service.rb +78 -0
  227. data/lib/rubik/runtime/context.rb +335 -0
  228. data/lib/rubik/runtime/diagnostic_engine.rb +188 -0
  229. data/lib/rubik/runtime/discovery.rb +342 -0
  230. data/lib/rubik/runtime/fiber.rb +142 -0
  231. data/lib/rubik/runtime/fiber_ordering.rb +52 -0
  232. data/lib/rubik/runtime/fiber_retry_gate.rb +84 -0
  233. data/lib/rubik/runtime/index_service.rb +58 -0
  234. data/lib/rubik/runtime/loop_service.rb +73 -0
  235. data/lib/rubik/runtime/manager.rb +241 -0
  236. data/lib/rubik/runtime/overlay_storage.rb +481 -0
  237. data/lib/rubik/runtime/service_definition.rb +54 -0
  238. data/lib/rubik/runtime/service_registration.rb +33 -0
  239. data/lib/rubik/runtime/task_manager.rb +302 -0
  240. data/lib/rubik/runtime.rb +19 -0
  241. data/lib/rubik/sandbox/driver/base.rb +30 -0
  242. data/lib/rubik/sandbox/driver/bubblewrap.rb +111 -0
  243. data/lib/rubik/sandbox/driver/docker.rb +113 -0
  244. data/lib/rubik/sandbox/driver/podman.rb +98 -0
  245. data/lib/rubik/sandbox/driver/process.rb +190 -0
  246. data/lib/rubik/sandbox/driver.rb +91 -0
  247. data/lib/rubik/sandbox/runner.rb +218 -0
  248. data/lib/rubik/sandbox/shell_resolver.rb +178 -0
  249. data/lib/rubik/sandbox.rb +8 -0
  250. data/lib/rubik/security/blocked_guidance.rb +187 -0
  251. data/lib/rubik/security/classifier/classifier.rb +440 -0
  252. data/lib/rubik/security/classifier/commands/agent-browser.yaml +9 -0
  253. data/lib/rubik/security/classifier/commands/apt.yaml +25 -0
  254. data/lib/rubik/security/classifier/commands/awk.yaml +10 -0
  255. data/lib/rubik/security/classifier/commands/basename.yaml +7 -0
  256. data/lib/rubik/security/classifier/commands/bash-classify.yaml +8 -0
  257. data/lib/rubik/security/classifier/commands/bash.yaml +20 -0
  258. data/lib/rubik/security/classifier/commands/black.yaml +12 -0
  259. data/lib/rubik/security/classifier/commands/brew.yaml +31 -0
  260. data/lib/rubik/security/classifier/commands/builtin.yaml +8 -0
  261. data/lib/rubik/security/classifier/commands/cargo.yaml +114 -0
  262. data/lib/rubik/security/classifier/commands/cat.yaml +7 -0
  263. data/lib/rubik/security/classifier/commands/chgrp.yaml +10 -0
  264. data/lib/rubik/security/classifier/commands/chmod.yaml +10 -0
  265. data/lib/rubik/security/classifier/commands/chown.yaml +10 -0
  266. data/lib/rubik/security/classifier/commands/column.yaml +7 -0
  267. data/lib/rubik/security/classifier/commands/comm.yaml +7 -0
  268. data/lib/rubik/security/classifier/commands/command.yaml +12 -0
  269. data/lib/rubik/security/classifier/commands/cp.yaml +7 -0
  270. data/lib/rubik/security/classifier/commands/crystal.yaml +63 -0
  271. data/lib/rubik/security/classifier/commands/curl.yaml +30 -0
  272. data/lib/rubik/security/classifier/commands/cut.yaml +7 -0
  273. data/lib/rubik/security/classifier/commands/date.yaml +10 -0
  274. data/lib/rubik/security/classifier/commands/df.yaml +7 -0
  275. data/lib/rubik/security/classifier/commands/diff.yaml +7 -0
  276. data/lib/rubik/security/classifier/commands/dig.yaml +7 -0
  277. data/lib/rubik/security/classifier/commands/dirname.yaml +7 -0
  278. data/lib/rubik/security/classifier/commands/docker.yaml +177 -0
  279. data/lib/rubik/security/classifier/commands/du.yaml +7 -0
  280. data/lib/rubik/security/classifier/commands/echo.yaml +7 -0
  281. data/lib/rubik/security/classifier/commands/env.yaml +15 -0
  282. data/lib/rubik/security/classifier/commands/eslint.yaml +12 -0
  283. data/lib/rubik/security/classifier/commands/eval.yaml +8 -0
  284. data/lib/rubik/security/classifier/commands/exec.yaml +12 -0
  285. data/lib/rubik/security/classifier/commands/expand.yaml +7 -0
  286. data/lib/rubik/security/classifier/commands/false.yaml +7 -0
  287. data/lib/rubik/security/classifier/commands/file.yaml +7 -0
  288. data/lib/rubik/security/classifier/commands/find.yaml +25 -0
  289. data/lib/rubik/security/classifier/commands/fmt.yaml +7 -0
  290. data/lib/rubik/security/classifier/commands/fold.yaml +7 -0
  291. data/lib/rubik/security/classifier/commands/free.yaml +7 -0
  292. data/lib/rubik/security/classifier/commands/gawk.yaml +10 -0
  293. data/lib/rubik/security/classifier/commands/gcloud.yaml +979 -0
  294. data/lib/rubik/security/classifier/commands/gh.yaml +615 -0
  295. data/lib/rubik/security/classifier/commands/git.yaml +524 -0
  296. data/lib/rubik/security/classifier/commands/glab-discussion.yaml +21 -0
  297. data/lib/rubik/security/classifier/commands/glab.yaml +807 -0
  298. data/lib/rubik/security/classifier/commands/go.yaml +44 -0
  299. data/lib/rubik/security/classifier/commands/gog.yaml +983 -0
  300. data/lib/rubik/security/classifier/commands/gradle.yaml +92 -0
  301. data/lib/rubik/security/classifier/commands/gradlew.yaml +92 -0
  302. data/lib/rubik/security/classifier/commands/grep.yaml +7 -0
  303. data/lib/rubik/security/classifier/commands/groups.yaml +7 -0
  304. data/lib/rubik/security/classifier/commands/gunzip.yaml +8 -0
  305. data/lib/rubik/security/classifier/commands/gws.yaml +211 -0
  306. data/lib/rubik/security/classifier/commands/gzip.yaml +7 -0
  307. data/lib/rubik/security/classifier/commands/hadolint.yaml +9 -0
  308. data/lib/rubik/security/classifier/commands/halt.yaml +8 -0
  309. data/lib/rubik/security/classifier/commands/head.yaml +7 -0
  310. data/lib/rubik/security/classifier/commands/helm.yaml +129 -0
  311. data/lib/rubik/security/classifier/commands/hostname.yaml +7 -0
  312. data/lib/rubik/security/classifier/commands/id.yaml +7 -0
  313. data/lib/rubik/security/classifier/commands/install.yaml +7 -0
  314. data/lib/rubik/security/classifier/commands/isort.yaml +14 -0
  315. data/lib/rubik/security/classifier/commands/java.yaml +35 -0
  316. data/lib/rubik/security/classifier/commands/javac.yaml +39 -0
  317. data/lib/rubik/security/classifier/commands/join.yaml +7 -0
  318. data/lib/rubik/security/classifier/commands/journalctl.yaml +14 -0
  319. data/lib/rubik/security/classifier/commands/jq.yaml +7 -0
  320. data/lib/rubik/security/classifier/commands/kill.yaml +7 -0
  321. data/lib/rubik/security/classifier/commands/killall.yaml +7 -0
  322. data/lib/rubik/security/classifier/commands/kubectl.yaml +211 -0
  323. data/lib/rubik/security/classifier/commands/less.yaml +7 -0
  324. data/lib/rubik/security/classifier/commands/ln.yaml +7 -0
  325. data/lib/rubik/security/classifier/commands/ls.yaml +7 -0
  326. data/lib/rubik/security/classifier/commands/make.yaml +21 -0
  327. data/lib/rubik/security/classifier/commands/md5sum.yaml +7 -0
  328. data/lib/rubik/security/classifier/commands/mise.yaml +579 -0
  329. data/lib/rubik/security/classifier/commands/mkdir.yaml +8 -0
  330. data/lib/rubik/security/classifier/commands/more.yaml +7 -0
  331. data/lib/rubik/security/classifier/commands/mv.yaml +7 -0
  332. data/lib/rubik/security/classifier/commands/mvn.yaml +83 -0
  333. data/lib/rubik/security/classifier/commands/mvnw.yaml +83 -0
  334. data/lib/rubik/security/classifier/commands/mypy.yaml +9 -0
  335. data/lib/rubik/security/classifier/commands/netstat.yaml +7 -0
  336. data/lib/rubik/security/classifier/commands/nice.yaml +11 -0
  337. data/lib/rubik/security/classifier/commands/nl.yaml +7 -0
  338. data/lib/rubik/security/classifier/commands/node.yaml +20 -0
  339. data/lib/rubik/security/classifier/commands/nohup.yaml +9 -0
  340. data/lib/rubik/security/classifier/commands/npm.yaml +71 -0
  341. data/lib/rubik/security/classifier/commands/npx.yaml +42 -0
  342. data/lib/rubik/security/classifier/commands/nslookup.yaml +7 -0
  343. data/lib/rubik/security/classifier/commands/paste.yaml +7 -0
  344. data/lib/rubik/security/classifier/commands/ping.yaml +7 -0
  345. data/lib/rubik/security/classifier/commands/pip.yaml +33 -0
  346. data/lib/rubik/security/classifier/commands/pip3.yaml +33 -0
  347. data/lib/rubik/security/classifier/commands/pipx.yaml +33 -0
  348. data/lib/rubik/security/classifier/commands/pkill.yaml +7 -0
  349. data/lib/rubik/security/classifier/commands/pnpm.yaml +346 -0
  350. data/lib/rubik/security/classifier/commands/poetry.yaml +97 -0
  351. data/lib/rubik/security/classifier/commands/poweroff.yaml +8 -0
  352. data/lib/rubik/security/classifier/commands/prettier.yaml +13 -0
  353. data/lib/rubik/security/classifier/commands/printenv.yaml +7 -0
  354. data/lib/rubik/security/classifier/commands/printf.yaml +7 -0
  355. data/lib/rubik/security/classifier/commands/ps.yaml +7 -0
  356. data/lib/rubik/security/classifier/commands/pup.yaml +882 -0
  357. data/lib/rubik/security/classifier/commands/pwd.yaml +7 -0
  358. data/lib/rubik/security/classifier/commands/pytest.yaml +7 -0
  359. data/lib/rubik/security/classifier/commands/python.yaml +9 -0
  360. data/lib/rubik/security/classifier/commands/python3.yaml +9 -0
  361. data/lib/rubik/security/classifier/commands/read.yaml +7 -0
  362. data/lib/rubik/security/classifier/commands/readlink.yaml +7 -0
  363. data/lib/rubik/security/classifier/commands/realpath.yaml +7 -0
  364. data/lib/rubik/security/classifier/commands/reboot.yaml +8 -0
  365. data/lib/rubik/security/classifier/commands/rev.yaml +7 -0
  366. data/lib/rubik/security/classifier/commands/rg.yaml +7 -0
  367. data/lib/rubik/security/classifier/commands/rm.yaml +8 -0
  368. data/lib/rubik/security/classifier/commands/rsync.yaml +12 -0
  369. data/lib/rubik/security/classifier/commands/rtk.yaml +140 -0
  370. data/lib/rubik/security/classifier/commands/ruff.yaml +29 -0
  371. data/lib/rubik/security/classifier/commands/rustfmt.yaml +14 -0
  372. data/lib/rubik/security/classifier/commands/scp.yaml +7 -0
  373. data/lib/rubik/security/classifier/commands/sed.yaml +14 -0
  374. data/lib/rubik/security/classifier/commands/seq.yaml +7 -0
  375. data/lib/rubik/security/classifier/commands/sh.yaml +14 -0
  376. data/lib/rubik/security/classifier/commands/sha1sum.yaml +7 -0
  377. data/lib/rubik/security/classifier/commands/sha256sum.yaml +7 -0
  378. data/lib/rubik/security/classifier/commands/shellcheck.yaml +10 -0
  379. data/lib/rubik/security/classifier/commands/shutdown.yaml +8 -0
  380. data/lib/rubik/security/classifier/commands/slack.yaml +113 -0
  381. data/lib/rubik/security/classifier/commands/sleep.yaml +8 -0
  382. data/lib/rubik/security/classifier/commands/snip.yaml +79 -0
  383. data/lib/rubik/security/classifier/commands/sort.yaml +10 -0
  384. data/lib/rubik/security/classifier/commands/ss.yaml +7 -0
  385. data/lib/rubik/security/classifier/commands/ssh.yaml +41 -0
  386. data/lib/rubik/security/classifier/commands/stat.yaml +7 -0
  387. data/lib/rubik/security/classifier/commands/stdbuf.yaml +15 -0
  388. data/lib/rubik/security/classifier/commands/strace.yaml +20 -0
  389. data/lib/rubik/security/classifier/commands/strings.yaml +11 -0
  390. data/lib/rubik/security/classifier/commands/sudo.yaml +24 -0
  391. data/lib/rubik/security/classifier/commands/systemctl.yaml +58 -0
  392. data/lib/rubik/security/classifier/commands/systemd-run.yaml +30 -0
  393. data/lib/rubik/security/classifier/commands/tac.yaml +7 -0
  394. data/lib/rubik/security/classifier/commands/tail.yaml +7 -0
  395. data/lib/rubik/security/classifier/commands/tar.yaml +25 -0
  396. data/lib/rubik/security/classifier/commands/tee.yaml +8 -0
  397. data/lib/rubik/security/classifier/commands/terraform.yaml +70 -0
  398. data/lib/rubik/security/classifier/commands/test.yaml +7 -0
  399. data/lib/rubik/security/classifier/commands/time.yaml +8 -0
  400. data/lib/rubik/security/classifier/commands/timeout.yaml +18 -0
  401. data/lib/rubik/security/classifier/commands/top.yaml +7 -0
  402. data/lib/rubik/security/classifier/commands/touch.yaml +8 -0
  403. data/lib/rubik/security/classifier/commands/tr.yaml +7 -0
  404. data/lib/rubik/security/classifier/commands/traceroute.yaml +7 -0
  405. data/lib/rubik/security/classifier/commands/true.yaml +7 -0
  406. data/lib/rubik/security/classifier/commands/tsc.yaml +16 -0
  407. data/lib/rubik/security/classifier/commands/type.yaml +7 -0
  408. data/lib/rubik/security/classifier/commands/uname.yaml +7 -0
  409. data/lib/rubik/security/classifier/commands/unexpand.yaml +7 -0
  410. data/lib/rubik/security/classifier/commands/uniq.yaml +7 -0
  411. data/lib/rubik/security/classifier/commands/unzip.yaml +10 -0
  412. data/lib/rubik/security/classifier/commands/uptime.yaml +7 -0
  413. data/lib/rubik/security/classifier/commands/uv.yaml +156 -0
  414. data/lib/rubik/security/classifier/commands/wait.yaml +11 -0
  415. data/lib/rubik/security/classifier/commands/wc.yaml +7 -0
  416. data/lib/rubik/security/classifier/commands/wget.yaml +7 -0
  417. data/lib/rubik/security/classifier/commands/whereis.yaml +7 -0
  418. data/lib/rubik/security/classifier/commands/which.yaml +7 -0
  419. data/lib/rubik/security/classifier/commands/whoami.yaml +7 -0
  420. data/lib/rubik/security/classifier/commands/xargs.yaml +26 -0
  421. data/lib/rubik/security/classifier/commands/xmllint.yaml +7 -0
  422. data/lib/rubik/security/classifier/commands/yarn.yaml +72 -0
  423. data/lib/rubik/security/classifier/commands/yes.yaml +7 -0
  424. data/lib/rubik/security/classifier/commands/yq.yaml +10 -0
  425. data/lib/rubik/security/classifier/commands/zip.yaml +8 -0
  426. data/lib/rubik/security/classifier/commands/zsh.yaml +14 -0
  427. data/lib/rubik/security/classifier/database.rb +216 -0
  428. data/lib/rubik/security/classifier/matcher/delegation.rb +190 -0
  429. data/lib/rubik/security/classifier/matcher/options.rb +245 -0
  430. data/lib/rubik/security/classifier/matcher/subcommands.rb +103 -0
  431. data/lib/rubik/security/classifier/matcher.rb +249 -0
  432. data/lib/rubik/security/classifier/models.rb +170 -0
  433. data/lib/rubik/security/classifier/parser.rb +23 -0
  434. data/lib/rubik/security/classifier/parsers/bash.rb +280 -0
  435. data/lib/rubik/security/classifier/parsers/bash_extractors.rb +221 -0
  436. data/lib/rubik/security/classifier/parsers/powershell.rb +185 -0
  437. data/lib/rubik/security/classifier/pwsh_rules.rb +224 -0
  438. data/lib/rubik/security/classifier.rb +25 -0
  439. data/lib/rubik/security/errors.rb +39 -0
  440. data/lib/rubik/security/guard.rb +954 -0
  441. data/lib/rubik/security/redactor.rb +89 -0
  442. data/lib/rubik/security.rb +11 -0
  443. data/lib/rubik/session/manager.rb +331 -0
  444. data/lib/rubik/session/recorder.rb +149 -0
  445. data/lib/rubik/session/replayer.rb +65 -0
  446. data/lib/rubik/session.rb +10 -0
  447. data/lib/rubik/skills/frontmatter.rb +41 -0
  448. data/lib/rubik/skills/manager.rb +223 -0
  449. data/lib/rubik/skills/registry.rb +64 -0
  450. data/lib/rubik/skills/skill.rb +33 -0
  451. data/lib/rubik/skills/storage.rb +77 -0
  452. data/lib/rubik/skills/validator.rb +116 -0
  453. data/lib/rubik/skills.rb +20 -0
  454. data/lib/rubik/storage/folding.rb +23 -0
  455. data/lib/rubik/storage/layout.rb +145 -0
  456. data/lib/rubik/storage/render.rb +97 -0
  457. data/lib/rubik/storage/service.rb +42 -0
  458. data/lib/rubik/storage/store.rb +514 -0
  459. data/lib/rubik/storage/transcript_indexer.rb +116 -0
  460. data/lib/rubik/storage/uri_reader.rb +168 -0
  461. data/lib/rubik/storage/vakanuvis/annal_writer.rb +92 -0
  462. data/lib/rubik/storage/vakanuvis/distill_gate.rb +323 -0
  463. data/lib/rubik/storage/vakanuvis/gate.rb +250 -0
  464. data/lib/rubik/storage/vakanuvis/harness.rb +421 -0
  465. data/lib/rubik/storage/vakanuvis/memory_steward.rb +163 -0
  466. data/lib/rubik/storage/vakanuvis/service.rb +69 -0
  467. data/lib/rubik/storage/vakanuvis/vocabulary.rb +99 -0
  468. data/lib/rubik/storage/vakanuvis/wiki_skeleton.rb +67 -0
  469. data/lib/rubik/storage/vakanuvis.rb +77 -0
  470. data/lib/rubik/storage.rb +29 -0
  471. data/lib/rubik/tools/base.rb +107 -0
  472. data/lib/rubik/tools/builtins/dmesg.rb +74 -0
  473. data/lib/rubik/tools/builtins/exec.rb +293 -0
  474. data/lib/rubik/tools/builtins/files.rb +354 -0
  475. data/lib/rubik/tools/builtins/goal_ledger.rb +89 -0
  476. data/lib/rubik/tools/builtins/grep.rb +281 -0
  477. data/lib/rubik/tools/builtins/interactive.rb +175 -0
  478. data/lib/rubik/tools/builtins/lifecycle.rb +359 -0
  479. data/lib/rubik/tools/builtins/lsp.rb +283 -0
  480. data/lib/rubik/tools/builtins/pruner.rb +88 -0
  481. data/lib/rubik/tools/builtins/runtime.rb +120 -0
  482. data/lib/rubik/tools/builtins/send_message.rb +109 -0
  483. data/lib/rubik/tools/builtins/show_file.rb +84 -0
  484. data/lib/rubik/tools/builtins/skill.rb +72 -0
  485. data/lib/rubik/tools/builtins/storage.rb +232 -0
  486. data/lib/rubik/tools/builtins/subagent.rb +356 -0
  487. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +72 -0
  488. data/lib/rubik/tools/builtins/symbols.rb +209 -0
  489. data/lib/rubik/tools/builtins/synthesis.rb +314 -0
  490. data/lib/rubik/tools/builtins/task.rb +168 -0
  491. data/lib/rubik/tools/builtins/unsynthesize.rb +111 -0
  492. data/lib/rubik/tools/builtins/vakanuvis.rb +161 -0
  493. data/lib/rubik/tools/builtins.rb +30 -0
  494. data/lib/rubik/tools/catalog.rb +70 -0
  495. data/lib/rubik/tools/declaration.rb +90 -0
  496. data/lib/rubik/tools/diagnostic_reporter.rb +283 -0
  497. data/lib/rubik/tools/fuzzy_matcher.rb +172 -0
  498. data/lib/rubik/tools/native_search.rb +180 -0
  499. data/lib/rubik/tools/output.rb +54 -0
  500. data/lib/rubik/tools/preflight_runner.rb +209 -0
  501. data/lib/rubik/tools/registry.rb +247 -0
  502. data/lib/rubik/tools/result.rb +71 -0
  503. data/lib/rubik/tools/services.rb +96 -0
  504. data/lib/rubik/tools/truncator.rb +78 -0
  505. data/lib/rubik/tools/uri_resolver.rb +195 -0
  506. data/lib/rubik/tools.rb +14 -0
  507. data/lib/rubik/ui/approvals.rb +52 -0
  508. data/lib/rubik/ui/banner.rb +43 -0
  509. data/lib/rubik/ui/capability.rb +122 -0
  510. data/lib/rubik/ui/dialog.rb +106 -0
  511. data/lib/rubik/ui/diff_viewer.rb +78 -0
  512. data/lib/rubik/ui/frame.rb +95 -0
  513. data/lib/rubik/ui/markdown_stream.rb +107 -0
  514. data/lib/rubik/ui/panel.rb +98 -0
  515. data/lib/rubik/ui/renderer.rb +206 -0
  516. data/lib/rubik/ui/spinner.rb +120 -0
  517. data/lib/rubik/ui/statusline.rb +134 -0
  518. data/lib/rubik/ui/theme.rb +118 -0
  519. data/lib/rubik/ui.rb +57 -0
  520. data/lib/rubik/uris.rb +101 -0
  521. data/lib/rubik/version.rb +5 -0
  522. data/lib/rubik.rb +65 -0
  523. metadata +770 -0
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "boot_lifecycle"
5
+ require_relative "client"
6
+ require_relative "proxy_tool"
7
+ require_relative "errors"
8
+ require_relative "../runtime/context"
9
+
10
+ module Rubik
11
+ module MCP
12
+ class Manager
13
+ include BootLifecycle
14
+
15
+ attr_reader :config, :root_dir, :servers, :clients, :tools, :guard
16
+ attr_accessor :tools_registry, :runtime_manager, :context
17
+
18
+ # Frozen-by-copy snapshot of the boot states (Context#services
19
+ # precedent); the guarded write path is transition_server!. The former
20
+ # attr_reader handed out the live hash — an unlocked race against the
21
+ # boot thread. Read through this door.
22
+ def statuses
23
+ @monitor.synchronize { @statuses.dup }
24
+ end
25
+
26
+ # Boot race gate state (mcp-async-boot D5): true while the manager is
27
+ # being torn down, so an in-flight boot thread never applies state into
28
+ # a dying context.
29
+ def stopped?
30
+ @stopped
31
+ end
32
+
33
+ def initialize(config: nil, tools_registry: nil, runtime_manager: nil, context: nil, root_dir: Dir.pwd, guard: nil,
34
+ client_factory: nil)
35
+ @config = config
36
+ @tools_registry = tools_registry
37
+ @runtime_manager = runtime_manager
38
+ @context = context || runtime_manager&.context
39
+ @root_dir = File.expand_path(root_dir)
40
+ @guard = guard
41
+ # DI seam for tests: production always builds Rubik::MCP::Client.
42
+ @client_factory = client_factory
43
+
44
+ @servers = {}
45
+ @clients = {}
46
+ @contexts = {}
47
+ @tools = {}
48
+ @statuses = {}
49
+ # Boot race guards (mcp-async-boot D5/D6): @stopped flips when the
50
+ # manager is being torn down; :connecting marks are tagged with the
51
+ # boot epoch that owns them (@boot_marks).
52
+ @stopped = false
53
+ @boot_thread = nil
54
+ @boot_marks = {}
55
+ @monitor = Monitor.new
56
+ end
57
+
58
+ def enabled?
59
+ return true unless @config
60
+
61
+ @config.fetch("mcp.enabled", true) != false
62
+ end
63
+
64
+ # Returns {ok:, tools:} — the config persist outcome plus the registered
65
+ # tool count; ok is false when the disk write failed (the server still
66
+ # runs for this session).
67
+ def add_server(name, defn, auto_start: true, persist: nil)
68
+ name_str = name.to_s
69
+ parsed_defn = defn.is_a?(Hash) ? defn.transform_keys(&:to_s) : {}
70
+ persist_res = { ok: true }
71
+ tools_count = 0
72
+
73
+ @monitor.synchronize do
74
+ @servers[name_str] = parsed_defn
75
+ @statuses[name_str] ||= :disabled
76
+
77
+ if @config && persist && @config.persisting?(persist)
78
+ server_hash = {
79
+ "command" => parsed_defn["command"],
80
+ "args" => Array(parsed_defn["args"]),
81
+ "env" => parsed_defn["env"] || {}
82
+ }
83
+ # Per-server handshake override round-trips only when present,
84
+ # so `!mcp add` output stays minimal (the knob is hand-written
85
+ # config, not a CLI flag).
86
+ server_hash["timeout"] = parsed_defn["timeout"] if parsed_defn["timeout"]
87
+ persist_res = @config.set("mcp.servers.#{name_str}", server_hash, persist: persist)
88
+ end
89
+
90
+ if auto_start && enabled?
91
+ enable_server(name_str)
92
+ else
93
+ transition_server!(name_str, :disabled)
94
+ end
95
+ tools_count = (@tools[name_str] || []).size
96
+ end
97
+
98
+ persist_res.merge(tools: tools_count)
99
+ end
100
+
101
+ # Returns the config persist outcome ({ok:, error?:}); the server is
102
+ # always torn down from the live session, deletion is idempotent.
103
+ def remove_server(name, persist: nil)
104
+ name_str = name.to_s
105
+ persist_res = { ok: true }
106
+ @monitor.synchronize do
107
+ disable_server(name_str) if @statuses[name_str] == :active || @clients.key?(name_str)
108
+
109
+ @servers.delete(name_str)
110
+ @contexts.delete(name_str)
111
+ @clients.delete(name_str)
112
+ @tools.delete(name_str)
113
+ clear_server_record!(name_str)
114
+
115
+ persist_res = @config.delete("mcp.servers.#{name_str}", persist: persist) if @config
116
+ end
117
+ persist_res
118
+ end
119
+
120
+ # Connect phase of enable_server: PURE I/O — client construction,
121
+ # spawn + initialize handshake, tools/list. No monitor, no context
122
+ # wiring, no registry mutation, so config-boot fan-out can overlap
123
+ # many of these on the concurrency boundary while every state change
124
+ # stays sequential in apply_connected_server.
125
+ def connect_server(name_str, server_defn, timeout: nil)
126
+ cmd = server_defn["command"] || server_defn[:command]
127
+ args = server_defn["args"] || server_defn[:args] || []
128
+ env = server_defn["env"] || server_defn[:env] || {}
129
+ base_ctx = @context || @runtime_manager&.context
130
+ connect_timeout = resolve_connect_timeout(server_defn, timeout)
131
+ # Config boundary (D2 mirror): validated for BOTH construction
132
+ # routes — the client_factory DI seam replaces the client, not the
133
+ # manager's own knob validation.
134
+ call_timeout = resolve_call_timeout_config
135
+
136
+ client =
137
+ if @client_factory
138
+ @client_factory.call(cmd, args: args, env: env, name: name_str, root_dir: @root_dir,
139
+ guard: @guard || (base_ctx ? Rubik::Coeffects.get(base_ctx, Rubik::Coeffects::GUARD) : nil))
140
+ else
141
+ Rubik::MCP::Client.new(
142
+ cmd,
143
+ args: args,
144
+ env: env,
145
+ name: name_str,
146
+ root_dir: @root_dir,
147
+ # Resolved live at connect time: the composed kwarg wins, but a
148
+ # Manager built before wire_runtime_context still picks up the
149
+ # session guard once the context provides it (construction-time
150
+ # snapshots of coeffects go stale — this matches base_ctx above).
151
+ guard: @guard || (base_ctx ? Rubik::Coeffects.get(base_ctx, Rubik::Coeffects::GUARD) : nil),
152
+ call_timeout: call_timeout
153
+ )
154
+ end
155
+
156
+ begin
157
+ client.start!(timeout: connect_timeout)
158
+ { client: client, tools: client.list_tools(timeout: connect_timeout) }
159
+ rescue StandardError, Interrupt => e
160
+ # A failed handshake must not leak the spawned child process (the
161
+ # boot fan-out's slot capture alone cannot stop it). Client#stop is
162
+ # idempotent, so apply-phase rollback stays exactly-once.
163
+ begin
164
+ client.stop
165
+ rescue StandardError => stop_err
166
+ Rubik::Log.warn(:mcp, "failed to stop MCP client '#{name_str}' after failed handshake (#{stop_err.class}: #{stop_err.message})")
167
+ end
168
+ raise e
169
+ end
170
+ end
171
+
172
+ # Apply phase of enable_server: the post-connect half, extracted
173
+ # verbatim — context wiring FIRST (so any later failure cleans up via
174
+ # the child drain), proxy registration, registry flips, and the
175
+ # original rollback on failure. On ANY failure the client is stopped
176
+ # exactly once: post-wiring via the context drain's on_dispose
177
+ # inverse, pre-wiring via the explicit stop in the rescue (Client#stop
178
+ # is idempotent, so the overlap is harmless). Callers hold @monitor.
179
+ def apply_connected_server(name_str, _server_defn, client:, tools:)
180
+ base_ctx = nil
181
+ server_ctx = nil
182
+ begin
183
+ @contexts[name_str].dispose if @contexts[name_str] && !@contexts[name_str].disposed?
184
+
185
+ base_ctx = @context || @runtime_manager&.context
186
+ server_ctx = base_ctx ? base_ctx.extend : Rubik::Runtime::Context.new
187
+ @contexts[name_str] = server_ctx
188
+
189
+ if base_ctx
190
+ base_ctx.provide("mcp:#{name_str}", client)
191
+ else
192
+ server_ctx.provide("mcp:#{name_str}", client)
193
+ end
194
+ server_ctx.on_dispose { client.stop }
195
+
196
+ server_tools = []
197
+ if @tools_registry
198
+ tools.each do |tdef|
199
+ proxy = Rubik::MCP::ProxyTool.new(server_name: name_str, tool_def: tdef, client: client)
200
+ @tools_registry.register(proxy, origin: :mcp)
201
+ server_tools << proxy
202
+ server_ctx.on_dispose { @tools_registry.unregister(proxy.name) }
203
+ end
204
+ end
205
+
206
+ @tools[name_str] = server_tools
207
+ @clients[name_str] = client
208
+ transition_server!(name_str, :active)
209
+ client
210
+ rescue StandardError => e
211
+ transition_server!(name_str, :failed)
212
+ server_ctx.dispose if server_ctx && !server_ctx.disposed?
213
+ # Lock order: unprovide OUTSIDE the child drain — child disposal
214
+ # must never acquire an ancestor's monitor (see Context#prune_child).
215
+ base_ctx&.unprovide("mcp:#{name_str}")
216
+ @contexts.delete(name_str)
217
+ @clients.delete(name_str)
218
+ @tools.delete(name_str)
219
+ # Post-wiring failures stopped the client via the ctx drain above;
220
+ # pre-wiring failures (stale-context dispose, ctx.extend) reach
221
+ # only this line — idempotent Client#stop makes it exactly-once.
222
+ begin
223
+ client.stop
224
+ rescue StandardError
225
+ nil
226
+ end
227
+ raise e
228
+ end
229
+ end
230
+
231
+ def enable_server(name)
232
+ name_str = name.to_s
233
+ @monitor.synchronize do
234
+ server_defn = @servers[name_str]
235
+ raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless server_defn
236
+
237
+ return @clients[name_str] if @statuses[name_str] == :active && @clients[name_str]&.running?
238
+
239
+ connected = begin
240
+ connect_server(name_str, server_defn)
241
+ rescue StandardError
242
+ transition_server!(name_str, :failed)
243
+ raise
244
+ end
245
+ apply_connected_server(name_str, server_defn, **connected)
246
+ end
247
+ end
248
+
249
+ def disable_server(name)
250
+ name_str = name.to_s
251
+ @monitor.synchronize do
252
+ raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless @servers.key?(name_str)
253
+
254
+ base_ctx = @context || @runtime_manager&.context
255
+ ctx = @contexts[name_str]
256
+ if ctx && !ctx.disposed?
257
+ ctx.dispose
258
+ # Lock order: unprovide OUTSIDE the child drain — child disposal
259
+ # must never acquire an ancestor's monitor (see Context#prune_child).
260
+ base_ctx&.unprovide("mcp:#{name_str}")
261
+ elsif @clients[name_str]
262
+ @clients[name_str].stop
263
+ base_ctx&.unprovide("mcp:#{name_str}")
264
+ end
265
+
266
+ @contexts.delete(name_str)
267
+ @clients.delete(name_str)
268
+ @tools.delete(name_str)
269
+ transition_server!(name_str, :disabled)
270
+ true
271
+ end
272
+ end
273
+
274
+ def connect(name, command, args = [], env = {})
275
+ add_server(name, { "command" => command, "args" => args, "env" => env }, auto_start: true, persist: nil)
276
+ end
277
+
278
+ def disconnect(name)
279
+ remove_server(name, persist: nil)
280
+ end
281
+
282
+ def ping(name)
283
+ name_str = name.to_s
284
+ client = @monitor.synchronize { @clients[name_str] }
285
+ raise Rubik::MCP::Error, "MCP server '#{name_str}' is not running" unless client&.running?
286
+
287
+ client.ping
288
+ end
289
+
290
+ # Config boot dispatcher (mcp-async-boot D4): synchronous by default,
291
+ # or spawned onto a named background thread with async: true. The
292
+ # thread is returned in async mode (test seam); sync mode returns nil
293
+ # exactly as before. Repeated async boots first drain any previous
294
+ # boot thread (AstWarmup precedent) and re-open the @stopped gate; the
295
+ # sync path re-opens it only when no boot thread is alive, so a wedged
296
+ # background boot stays gated after a stop_all.
297
+ def load_from_config(cfg = nil, async: false)
298
+ unless async
299
+ @monitor.synchronize { @stopped = false if @boot_thread.nil? }
300
+ return perform_load_from_config(cfg)
301
+ end
302
+
303
+ join_boot_safely(2.0)
304
+ @monitor.synchronize { @stopped = false }
305
+ thread = Thread.new do
306
+ perform_load_from_config(cfg)
307
+ rescue StandardError, Interrupt => e
308
+ Rubik::Log.warn(:mcp, "background MCP boot failed (#{e.class}: #{e.message})")
309
+ ensure
310
+ # Only clear OUR reference: a wedged boot A expiring past the drain
311
+ # bound must not clobber the handle to a newer boot B's thread.
312
+ @monitor.synchronize { @boot_thread = nil if @boot_thread.equal?(Thread.current) }
313
+ end
314
+ thread.name = "rubik-mcp-boot" if thread.respond_to?(:name=)
315
+ @boot_thread = thread
316
+ end
317
+
318
+ private
319
+
320
+ # Config boot, decomposed into the named phases over the concurrency
321
+ # boundary so N servers' connect handshakes (spawn + initialize +
322
+ # tools/list — the slow I/O) overlap while every observable state
323
+ # change stays sequential in config order. Boot wall clock goes
324
+ # sum(per-server) → max(per-server); one hung server no longer delays
325
+ # the rest. Pure orchestration below: each phase is a private method,
326
+ # moved verbatim from the former single body (zero logic change).
327
+ def perform_load_from_config(cfg = nil)
328
+ target_config = cfg || @config
329
+ return unless target_config
330
+
331
+ mcp_enabled = target_config.fetch("mcp.enabled", true) != false
332
+ servers_cfg = target_config.fetch("mcp.servers", {})
333
+ return unless servers_cfg.is_a?(Hash)
334
+
335
+ # Boot epoch (review round): :connecting marks are tagged with a
336
+ # per-invocation identity so a wedged older boot's failure branch or
337
+ # ensure sweep can never downgrade a newer boot's fresh mark.
338
+ boot_epoch = Object.new
339
+ entries = register_config_servers(servers_cfg, mcp_enabled, boot_epoch)
340
+
341
+ # Phase 1 deliberately did no context wiring, so stopping the client
342
+ # is the ONLY cleanup an unapplied prepared slot owes — no orphaned
343
+ # child processes, even when an interrupt lands mid-boot. One ensure
344
+ # spans both post-registration phases.
345
+ connected = ::Array.new(entries.size)
346
+ outcomes = {}
347
+ begin
348
+ connect_fanout(entries, connected)
349
+ apply_connected_slots(entries, connected, boot_epoch, outcomes)
350
+ ensure
351
+ stop_unapplied_clients(entries, connected, outcomes)
352
+ fail_orphaned_connecting_marks(entries, boot_epoch)
353
+ end
354
+ nil
355
+ end
356
+
357
+ # Phase 0 (sequential, config order): registration still goes
358
+ # through add_server — subclasses and plugins wrap that seam, and a
359
+ # broken definition must fail exactly as before (logged, skipped,
360
+ # not half-registered). auto_start is deliberately false here: the
361
+ # connect half moved to the fan-out. Ends each auto-start candidate
362
+ # with the phase-0 claim (D5) and returns the entries to boot.
363
+ def register_config_servers(servers_cfg, mcp_enabled, boot_epoch)
364
+ entries = []
365
+ servers_cfg.each do |sname, sdefn|
366
+ next unless sdefn.is_a?(Hash)
367
+
368
+ auto_start = mcp_enabled && (sdefn.fetch("enabled", true) != false)
369
+ begin
370
+ add_server(sname, sdefn, auto_start: false, persist: nil)
371
+ rescue StandardError => e
372
+ # ENSURE-LOG (G3 round-1): one broken server config used to
373
+ # vanish behind an ENV["RUBIK_DEBUG"] gate; a misconfigured
374
+ # server must surface without debug mode.
375
+ Rubik::Log.warn(:mcp, "failed to load MCP server '#{sname}' (#{e.class}: #{e.message})")
376
+ emit_mcp_event(:mcp_server_failed, :err, sname.to_s, error: e.message)
377
+ next
378
+ end
379
+ next unless auto_start
380
+
381
+ name_str = sname.to_s
382
+ # Phase-0 claim (D5): check+mark in ONE monitor hold — a user
383
+ # enable/disable/remove that races the boot reads a definite
384
+ # state, and phase 2's gate below can never miss a window.
385
+ entries << [name_str, sdefn.transform_keys(&:to_s)] unless claim_boot_slot!(name_str, boot_epoch)
386
+ end
387
+ entries
388
+ end
389
+
390
+ # Phase 1 (fan-out): pure I/O connects. Slots are captured by SIDE
391
+ # EFFECT inside the item blocks: when an Exception-level error (a
392
+ # boot interrupt) kills .map before it returns, the assignment below
393
+ # never happens — a value-only `connected` would leave the ensure
394
+ # blind to every successfully-started client. Hence the pre-built
395
+ # array passed in rather than a returned value.
396
+ def connect_fanout(entries, connected)
397
+ return if entries.empty?
398
+
399
+ Rubik::Concurrency.map(entries.each_with_index.to_a) do |(name_str, parsed_defn), index|
400
+ connected[index] = { ok: true, **connect_server(name_str, parsed_defn) }
401
+ rescue StandardError => e
402
+ connected[index] = { ok: false, error: e }
403
+ end
404
+ end
405
+
406
+ # Phase 2 (sequential, config order): apply state; identical
407
+ # per-server warning on either phase's failure. Outcomes ledger is an
408
+ # explicit atom map (index -> :applied | :failed; absence = skipped or
409
+ # never ran — exactly what owes_stop? reads).
410
+ def apply_connected_slots(entries, connected, boot_epoch, outcomes)
411
+ entries.each_with_index do |(name_str, parsed_defn), index|
412
+ slot = connected[index]
413
+ next unless slot # never connected (interrupt during phase 1)
414
+
415
+ if slot[:ok]
416
+ begin
417
+ outcome = @monitor.synchronize do
418
+ # Single race gate (D5), checked at apply time under the
419
+ # monitor: a disable/remove/enable during boot wins over
420
+ # the boot's own apply, and a stop_all skips it entirely.
421
+ # Epoch-complete gate: this boot may only apply into the
422
+ # slot its own phase-0 claim marked, so a user decision
423
+ # that moved the slot or a newer boot's fresh mark wins.
424
+ # In the synchronous path nothing can interleave, so the
425
+ # guard is provably inert there.
426
+ if @stopped || @statuses[name_str] != :connecting || !@boot_marks[name_str].equal?(boot_epoch)
427
+ :skipped
428
+ else
429
+ apply_connected_server(name_str, parsed_defn, client: slot[:client], tools: slot[:tools])
430
+ # Post-apply revalidation under the boot actor: a no-op
431
+ # when apply already landed :active.
432
+ transition_server!(name_str, :active, epoch: boot_epoch)
433
+ :applied
434
+ end
435
+ end
436
+ outcomes[index] = :applied if outcome == :applied
437
+ emit_mcp_event(:mcp_server_loaded, :info, name_str, tools: (@tools[name_str] || []).size) if outcome == :applied
438
+ rescue StandardError => e
439
+ # ENSURE-LOG (G3 round-1): one broken server config used to
440
+ # vanish behind an ENV["RUBIK_DEBUG"] gate; a misconfigured
441
+ # server must surface without debug mode. apply's own
442
+ # rollback already set :failed and stopped its client via
443
+ # the context drain; :failed marks the slot so the ensure
444
+ # never double-stops it.
445
+ outcomes[index] = :failed
446
+ Rubik::Log.warn(:mcp, "failed to load MCP server '#{name_str}' (#{e.class}: #{e.message})")
447
+ emit_mcp_event(:mcp_server_failed, :err, name_str, error: e.message)
448
+ end
449
+ else
450
+ # Only our own epoch's mark may flip to :failed — a user
451
+ # disable/remove during the handshake, or a newer boot's fresh
452
+ # mark, must win over this boot's connect failure (D5). The
453
+ # epoch gate and flip happen in the same monitor hold inside
454
+ # transition_server!.
455
+ transition_server!(name_str, :failed, epoch: boot_epoch)
456
+ error = slot[:error]
457
+ Rubik::Log.warn(:mcp, "failed to load MCP server '#{name_str}' (#{error.class}: #{error.message})")
458
+ emit_mcp_event(:mcp_server_failed, :err, name_str, error: error.message)
459
+ end
460
+ end
461
+ end
462
+
463
+ # Ensure part 1: stop every prepared client that did not end up
464
+ # registered: a failed apply already rolled its own client back via
465
+ # the context drain, while a never-applied one (interrupt between
466
+ # phases) owes a stop here. A :skipped slot also owes a stop —
467
+ # skipping the APPLY must not orphan its prepared child process (that
468
+ # is the exact leak D10 closed for handshakes). owes_stop? is the
469
+ # structural form of that rule: skipped and never-ran slots owe.
470
+ def stop_unapplied_clients(entries, connected, outcomes)
471
+ entries.each_with_index do |(name_str, _defn), index|
472
+ slot = connected[index]
473
+ next if !slot.is_a?(Hash) || !slot[:client] || !BootLifecycle.owes_stop?(outcomes[index])
474
+
475
+ registered = @monitor.synchronize { @clients[name_str] }
476
+ next if registered.equal?(slot[:client])
477
+
478
+ begin
479
+ slot[:client].stop
480
+ rescue StandardError => e
481
+ Rubik::Log.warn(:mcp, "failed to stop unapplied MCP server '#{name_str}' (#{e.class}: #{e.message})")
482
+ end
483
+ end
484
+ end
485
+
486
+ # Ensure part 2 — terminal state for boot entries that never reached
487
+ # apply (D6): anything still :connecting failed to boot; user-chosen
488
+ # values (:disabled, :active, deleted-by-remove) are left untouched.
489
+ # The epoch check inside transition_server! keeps an older wedged
490
+ # boot's sweep from downgrading a newer boot's fresh mark.
491
+ def fail_orphaned_connecting_marks(entries, boot_epoch)
492
+ entries.each do |(name_str, _defn)|
493
+ transition_server!(name_str, :failed, epoch: boot_epoch)
494
+ end
495
+ end
496
+
497
+ public
498
+
499
+ # Teardown (mcp-async-boot slice 3): arms the boot gate, drains an
500
+ # in-flight boot thread, then disables every server. The join happens
501
+ # OUTSIDE the monitor (G5): the boot thread takes @monitor in its apply
502
+ # phase, so joining under it would deadlock.
503
+ def stop_all(join_timeout: 2.0)
504
+ @monitor.synchronize { @stopped = true }
505
+ join_boot_safely(join_timeout)
506
+
507
+ @monitor.synchronize do
508
+ @servers.each_key do |name|
509
+ disable_server(name)
510
+ rescue StandardError => e
511
+ Rubik::Log.warn(:mcp, "failed to stop MCP server '#{name}' during teardown (#{e.class}: #{e.message})")
512
+ nil
513
+ end
514
+ end
515
+ end
516
+ alias dispose! stop_all
517
+ alias dispose stop_all
518
+
519
+ def status_summary
520
+ @monitor.synchronize do
521
+ @servers.map do |name, _defn|
522
+ server_status(name)
523
+ end.compact
524
+ end
525
+ end
526
+
527
+ def server_status(name)
528
+ name_str = name.to_s
529
+ defn = @servers[name_str]
530
+ return nil unless defn
531
+
532
+ cmd_display = ([defn["command"] || defn[:command]] + Array(defn["args"] || defn[:args])).compact.join(" ")
533
+ {
534
+ name: name_str,
535
+ status: @statuses[name_str] || :disabled,
536
+ tools_count: (@tools[name_str] || []).size,
537
+ tools: (@tools[name_str] || []).map(&:name),
538
+ command: cmd_display
539
+ }
540
+ end
541
+
542
+ def client(name)
543
+ @monitor.synchronize { @clients[name.to_s] }
544
+ end
545
+
546
+ def active_tools
547
+ @monitor.synchronize { @tools.values.flatten }
548
+ end
549
+
550
+ private
551
+
552
+ # Boot observability door (mcp-async-boot D7): publish through the
553
+ # session event bus so the dispatcher's dmesg door records boot
554
+ # outcomes under the "mcp" subsystem. post (enqueue) — never emit
555
+ # (drain): the caller may be the boot thread. An unresolved bus
556
+ # (headless manager) is a no-op; a failing post is a warn, never a
557
+ # boot failure.
558
+ def emit_mcp_event(event_name, level, name_str, **data)
559
+ ctx = @context || @runtime_manager&.context
560
+ bus = Rubik::Coeffects.get(ctx, Rubik::Coeffects::EVENTS)
561
+ return unless bus.respond_to?(:post)
562
+
563
+ bus.post(event_name, { server: name_str }, subsystem: :mcp, level: level, **data)
564
+ rescue StandardError => e
565
+ Rubik::Log.warn(:mcp, "failed to emit MCP event '#{event_name}' (#{e.class}: #{e.message})")
566
+ end
567
+
568
+ # Bounded drain of the boot thread. Mechanics live in
569
+ # Concurrency::Join (monotonic-deadline poll, never raises); the
570
+ # still-alive warn and the :mcp log channel stay here. Reads
571
+ # @boot_thread under the monitor but joins OUTSIDE it (G5).
572
+ def join_boot_safely(timeout = nil)
573
+ result = Rubik::Concurrency::Join.thread(@monitor.synchronize { @boot_thread }, timeout: timeout)
574
+ case result
575
+ when :timeout
576
+ Rubik::Log.warn(:mcp, "MCP boot thread still alive after #{timeout}s join; continuing (stop gate armed)")
577
+ when StandardError
578
+ Rubik::Log.warn(:mcp, "MCP boot thread join failed (#{result.class}: #{result.message})")
579
+ end
580
+ nil
581
+ rescue StandardError => e
582
+ # Join.thread never raises; this covers the monitor acquisition
583
+ # (and Log itself) — hence "drain", not "join".
584
+ Rubik::Log.warn(:mcp, "MCP boot thread drain failed (#{e.class}: #{e.message})")
585
+ nil
586
+ end
587
+
588
+ # mcp.call_timeout at the config boundary (D2 mirror): an invalid
589
+ # value warns and falls through to the client default, exactly like a
590
+ # bad mcp.timeout — the knob must never fail the boot silently.
591
+ def resolve_call_timeout_config
592
+ raw = @config&.fetch("mcp.call_timeout", nil)
593
+ return nil if raw.nil?
594
+ return raw if raw.is_a?(Numeric) && raw.positive?
595
+
596
+ Rubik::Log.warn(:mcp, "ignoring non-positive MCP call_timeout #{raw.inspect}; using the client default")
597
+ nil
598
+ end
599
+
600
+ # Handshake timeout resolution (explicit kwarg > per-server "timeout" >
601
+ # mcp.timeout config > DEFAULT_TIMEOUT). A non-positive value warns and
602
+ # falls through to the next source — a miswritten knob must never fail
603
+ # the boot at the config boundary.
604
+ def resolve_connect_timeout(server_defn, explicit)
605
+ sources = [explicit, server_defn["timeout"] || server_defn[:timeout]]
606
+ cfg_timeout = @config&.fetch("mcp.timeout", nil)
607
+ sources << cfg_timeout unless cfg_timeout.nil?
608
+
609
+ sources.each do |candidate|
610
+ return candidate if candidate.is_a?(Numeric) && candidate.positive?
611
+ next if candidate.nil?
612
+
613
+ Rubik::Log.warn(:mcp, "ignoring non-positive MCP timeout #{candidate.inspect}; falling back to the next source")
614
+ end
615
+ Client::DEFAULT_TIMEOUT
616
+ end
617
+ end
618
+ end
619
+ end