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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../../coeffects"
5
+ require_relative "../output"
6
+ require_relative "../native_search"
7
+ require_relative "../diagnostic_reporter"
8
+
9
+ module Rubik
10
+ module Tools
11
+ class GrepSearchTool < BaseTool
12
+ MAX_LINE_CHARS = 500
13
+ MAX_OUTPUT_CHARS = 30_000
14
+
15
+ DECLARATION = Declaration.new(
16
+ name: "grep_search", aliases: ["search_code", "grep", "search_files"],
17
+ description: "Search for exact string or regular expression across project files with line numbers and matched content",
18
+ risk: :read, archetypes: [:research, :coder],
19
+ params_schema: {
20
+ type: "object",
21
+ properties: {
22
+ query: { type: "string", description: "Text or regex pattern to search for" },
23
+ path: { type: "string",
24
+ description: "Subdirectory or file path relative to the project root (e.g. 'lib', 'src/main.go'). " \
25
+ "Absolute paths are converted automatically. (optional)" },
26
+ extension: { type: "string", description: "File extension filter, e.g. .rb, .go (optional)" },
27
+ case_sensitive: { type: "boolean", description: "Whether search is case-sensitive (optional, default: false)" },
28
+ max_results: { type: "integer", description: "Maximum number of matched lines to return (optional, default: 50)" }
29
+ },
30
+ required: ["query"]
31
+ }
32
+ )
33
+ def initialize(buffer_manager = nil, context_index = nil, root_dir: Dir.pwd, runtime_context: nil, context: nil, config: nil)
34
+ super(DECLARATION)
35
+ @buffer_manager = buffer_manager
36
+ @context_index = context_index
37
+ @runtime_context = runtime_context || context
38
+ @config = config
39
+ @root_dir = File.expand_path(root_dir)
40
+ end
41
+
42
+ def context_index
43
+ Rubik::Coeffects.resolve(@runtime_context, Rubik::Coeffects::CONTEXT_INDEX, @context_index)
44
+ end
45
+
46
+ def buffer_manager
47
+ Rubik::Coeffects.resolve(@runtime_context, Rubik::Coeffects::BUFFER_MANAGER, @buffer_manager)
48
+ end
49
+
50
+ def execute(args = {})
51
+ # Scrub before strip-driven string ops and result interpolation: an
52
+ # invalid-UTF-8 query (model transcription artifact) must not raise
53
+ # or leak broken bytes into the rendered tool output.
54
+ query = Rubik::Platform.safe_utf8((args["query"] || args[:query]).to_s).strip
55
+ return ToolOutput.error("Query parameter is required.") if query.empty?
56
+
57
+ path_filter = (args["path"] || args[:path]).to_s.strip
58
+ ext_filter = (args["extension"] || args[:extension]).to_s.strip
59
+
60
+ path_filter, outside_error = BaseTool.normalize_path_filter(path_filter, @root_dir)
61
+ if outside_error
62
+ allowed = [{ path: @root_dir, primary: true }]
63
+ idx = context_index
64
+ idx.additional_roots.each { |r| allowed << { path: r, primary: false } } if idx.respond_to?(:additional_roots)
65
+ return ToolOutput.error(DiagnosticReporter.render_traversal_blocked(args["path"] || args[:path], allowed_roots: allowed))
66
+ end
67
+
68
+ case_sensitive = args["case_sensitive"] || args[:case_sensitive] || false
69
+ max_results = (args["max_results"] || args[:max_results] || 50).to_i
70
+ max_results = 50 if max_results <= 0
71
+
72
+ regex = regex_for(query, case_sensitive)
73
+
74
+ matches = try_native_search(query, path_filter, ext_filter, case_sensitive, max_results, regex)
75
+ matches ||= ruby_search(path_filter, ext_filter, regex, max_results)
76
+
77
+ if matches.empty?
78
+ "No matching lines found for '#{query}'.#{" (path filter: '#{path_filter}')" unless path_filter.empty?}"
79
+ else
80
+ lines = ["Found #{matches.size} match(es) for '#{query}':"]
81
+ total_chars = 0
82
+ capped = false
83
+ matches.each do |m|
84
+ content = sanitize_line_content(m[:content].to_s)
85
+ entry = "#{m[:file]}:#{m[:line]}: #{content}"
86
+ if (total_chars + entry.length) > MAX_OUTPUT_CHARS
87
+ capped = true
88
+ break
89
+ end
90
+
91
+ lines << entry
92
+ total_chars += entry.length
93
+ end
94
+ lines << "... [Grep output capped at #{MAX_OUTPUT_CHARS / 1024}KB to protect context window. Narrow search via 'path' or 'extension']" if capped
95
+ lines.join("\n")
96
+ end
97
+ rescue Errno::EACCES => e
98
+ ToolOutput.error(DiagnosticReporter.render_permission_denied(path_filter.empty? ? @root_dir : path_filter, operation: "read", reason: e.message))
99
+ rescue Rubik::Buffer::InvalidPathError
100
+ ToolOutput.error(DiagnosticReporter.render_traversal_blocked(path_filter.empty? ? @root_dir : path_filter))
101
+ end
102
+
103
+ private
104
+
105
+ # ripgrep fast path (Plan 52 D2/D7/D8). Gated so divergence risk stays
106
+ # near zero: an unusable rg, a switched-off config, additional roots
107
+ # rg cannot see, or a failed/untrusted run all return nil and the
108
+ # pure-Ruby path renders the result instead. Dirty buffer entries are
109
+ # re-scanned from memory so the model never sees stale disk content.
110
+ def try_native_search(query, path_filter, ext_filter, case_sensitive, max_results, regex)
111
+ return nil unless NativeSearch.enabled?(@config&.fetch("tools.native"))
112
+ return nil unless NativeSearch.available?
113
+
114
+ idx = context_index
115
+ return nil if idx && !idx.additional_roots.empty?
116
+
117
+ glob = nil
118
+ unless ext_filter.empty?
119
+ ext = ext_filter.start_with?(".") ? ext_filter[1..] : ext_filter
120
+ glob = "*.#{ext}"
121
+ end
122
+
123
+ target = path_filter.empty? ? "." : path_filter.sub(%r{^\./}, "")
124
+
125
+ native = NativeSearch.search(
126
+ query,
127
+ root_dir: @root_dir,
128
+ path: target,
129
+ glob: glob,
130
+ case_sensitive: case_sensitive,
131
+ max_results: max_results
132
+ )
133
+ return nil if native.nil?
134
+
135
+ buf = buffer_manager
136
+ dirty = buf&.dirty_files ? buf.dirty_files.keys : []
137
+ native = native.reject { |m| dirty.include?(m[:file]) }
138
+ scoped_dirty = dirty.select { |f| pass_path?(f, path_filter) && pass_extension?(f, ext_filter) }
139
+
140
+ merge_native_results(native, scoped_dirty, regex, max_results)
141
+ end
142
+
143
+ # Memory-scans the dirty buffer files and merges them with the disk
144
+ # (rg) results in the exact (file, line) order the pure-Ruby path has
145
+ # always produced.
146
+ def merge_native_results(native, scoped_dirty, regex, max_results)
147
+ return native.sort_by { |m| [m[:file], m[:line]] } if scoped_dirty.empty?
148
+
149
+ memory = collect_matches(scoped_dirty, regex, max_results, from_buffer: true)
150
+ (native + memory).sort_by { |m| [m[:file], m[:line]] }.first(max_results)
151
+ end
152
+
153
+ # Pure-Ruby search over the context index (or a fresh scan). Reads
154
+ # stay on the historical data path — buffer.read for every file when a
155
+ # buffer manager exists (it warms the diff baseline), whole-content
156
+ # strings otherwise — while the per-file line iteration avoids
157
+ # materializing a line array.
158
+ def ruby_search(path_filter, ext_filter, regex, max_results)
159
+ idx = context_index
160
+ files = if idx
161
+ idx.files.empty? ? idx.full_scan : idx.files
162
+ else
163
+ Rubik::Context::Scanner.new(root_dir: @root_dir, native: @config&.fetch("tools.native")).scan
164
+ end
165
+
166
+ all_file_keys = files.keys
167
+ all_file_keys = (all_file_keys + buffer_manager.dirty_files.keys).uniq if buffer_manager&.dirty_files
168
+ all_file_keys = all_file_keys.select { |f| pass_path?(f, path_filter) }
169
+ all_file_keys = all_file_keys.select { |f| pass_extension?(f, ext_filter) }
170
+
171
+ collect_matches(all_file_keys.sort, regex, max_results, from_buffer: !buffer_manager.nil?)
172
+ end
173
+
174
+ # Iterates files in order, collecting matches until max_results.
175
+ # from_buffer: content comes from buffer.read (memory or disk read into
176
+ # a string — historical semantics, incl. the whole-content NUL check);
177
+ # otherwise lines are streamed from disk with File.foreach. A NUL byte
178
+ # in a streamed file revokes that file's earlier matches so the result
179
+ # stays identical to the whole-content binary check.
180
+ def collect_matches(file_keys, regex, max_results, from_buffer:)
181
+ matches = []
182
+ file_keys.each do |f|
183
+ if from_buffer
184
+ content = begin
185
+ buffer_manager.read(f)
186
+ rescue SystemCallError, IOError, Rubik::Buffer::InvalidPathError
187
+ # InvalidPathError: an outbound symlink stays a per-file skip,
188
+ # never an abort of the whole grep (the index lists symlinked
189
+ # files on purpose).
190
+ nil
191
+ end
192
+ next unless content.is_a?(String)
193
+ next if content.include?("\x00")
194
+
195
+ append_string_lines(matches, f, content, regex, max_results)
196
+ else
197
+ collect_disk_matches(matches, f, regex, max_results)
198
+ end
199
+ break if matches.size >= max_results
200
+ end
201
+ matches
202
+ end
203
+
204
+ def append_string_lines(matches, file_key, content, regex, max_results)
205
+ line_no = 0
206
+ content.each_line do |line_content|
207
+ line_no += 1
208
+ line_content = searchable_line(line_content)
209
+ if regex.match?(line_content)
210
+ matches << { file: file_key, line: line_no, content: sanitize_line_content(line_content.chomp) }
211
+ break if matches.size >= max_results
212
+ end
213
+ end
214
+ end
215
+
216
+ # Buffer content and dirty-file edits may carry invalid UTF-8 (tool
217
+ # output captured mid-stream, binary-ish edits). regex.match? raises
218
+ # ArgumentError on such lines — scrub only the broken ones so the
219
+ # common valid path pays only the cheap valid_encoding? check.
220
+ def searchable_line(line)
221
+ line.valid_encoding? ? line : line.scrub("")
222
+ end
223
+
224
+ def sanitize_line_content(content)
225
+ return content if content.length <= MAX_LINE_CHARS
226
+
227
+ "#{content[0..MAX_LINE_CHARS]}... [line truncated, #{content.length} chars total]"
228
+ end
229
+
230
+ def collect_disk_matches(matches, file_key, regex, max_results)
231
+ full_p = File.expand_path(file_key, @root_dir)
232
+ return unless File.exist?(full_p)
233
+
234
+ line_no = 0
235
+ # broken-encoding guard: a file saved in a non-UTF-8 codepage must not
236
+ # raise out of regex matching; its valid bytes still get searched.
237
+ File.foreach(full_p, encoding: "UTF-8") do |line_content|
238
+ line_no += 1
239
+ if line_content.include?("\x00")
240
+ matches.delete_if { |m| m[:file] == file_key }
241
+ break
242
+ end
243
+ line_content = searchable_line(line_content)
244
+ next unless regex.match?(line_content)
245
+
246
+ matches << { file: file_key, line: line_no, content: sanitize_line_content(line_content.chomp) }
247
+ break if matches.size >= max_results
248
+ end
249
+ rescue SystemCallError, IOError, ArgumentError
250
+ nil
251
+ end
252
+
253
+ def pass_path?(file_key, path_filter)
254
+ return true if path_filter.empty?
255
+
256
+ file_key.start_with?(path_filter.sub(%r{^\./}, ""))
257
+ end
258
+
259
+ def pass_extension?(file_key, ext_filter)
260
+ return true if ext_filter.empty?
261
+
262
+ ext = ext_filter.start_with?(".") ? ext_filter : ".#{ext_filter}"
263
+ file_key.end_with?(ext)
264
+ end
265
+
266
+ def regex_for(query, case_sensitive)
267
+ flags = case_sensitive ? nil : Regexp::IGNORECASE
268
+ # Encoding hygiene: a query with invalid UTF-8 (model-transcription
269
+ # artifact) raises RegexpError from Regexp.new and again from the
270
+ # escape fallback. Scrub first so the fallback stays a true fallback,
271
+ # and cover the escape branch with the same rescue.
272
+ query = Rubik::Platform.safe_utf8(query)
273
+ begin
274
+ Regexp.new(query, flags)
275
+ rescue RegexpError
276
+ Regexp.new(Regexp.escape(query), flags)
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+ require_relative "../output"
5
+ require_relative "../../ui/dialog"
6
+ require_relative "../../coeffects"
7
+
8
+ module Rubik
9
+ module Tools
10
+ # Prompts the human operator with structured questions to resolve ambiguity,
11
+ # clarify design decisions, or choose between implementation paths (Plan 92).
12
+ # Subagent plane: when the SUBAGENT_QUESTION_ASKER coeffect is satisfied on
13
+ # the runtime context (supervisor#spawn provides the instance into its child
14
+ # context), the question routes to the maestro instead of a human dialog — the
15
+ # caller blocks until the maestro answers via send_message or the question times
16
+ # out. No REQUIRES constant on purpose: the root agent must keep the tool.
17
+ class AskQuestionTool < BaseTool
18
+ attr_reader :shell, :dialog
19
+
20
+ DECLARATION = Declaration.new(
21
+ name: "ask_question", aliases: ["ask", "question"],
22
+ description: "Ask a structured question with selectable options — either to the human operator, " \
23
+ "or, from a subagent, to the maestro supervisor (blocks until answered).",
24
+ risk: :read, archetypes: [:research, :coder],
25
+ params_schema: {
26
+ type: "object",
27
+ properties: {
28
+ "questions" => {
29
+ type: "array",
30
+ description: "List of questions to ask the operator.",
31
+ items: {
32
+ type: "object",
33
+ properties: {
34
+ "question" => { type: "string", description: "The clear question text to display." },
35
+ "options" => {
36
+ type: "array",
37
+ items: { type: "string" },
38
+ minItems: 2,
39
+ description: "List of selectable answer options (at least 2). Recommended option should be prefixed with '(Recommended)'."
40
+ },
41
+ "is_multi_select" => { type: "boolean", description: "True if multiple options can be chosen simultaneously." }
42
+ },
43
+ required: ["question", "options"]
44
+ }
45
+ }
46
+ },
47
+ required: ["questions"]
48
+ }
49
+ )
50
+ def initialize(shell: nil, dialog: nil, runtime_context: nil)
51
+ super(DECLARATION)
52
+ @shell = shell
53
+ @dialog = dialog || Rubik::UI::Dialog.new(shell: shell)
54
+ @runtime_context = runtime_context
55
+ end
56
+
57
+ def execute(args = {})
58
+ raw_questions = args["questions"] || args[:questions]
59
+ raw_questions = [args] if raw_questions.nil? && (args["question"] || args[:question])
60
+ return ToolOutput.error("Missing required parameter: questions (must be a non-empty array of question objects).") unless raw_questions.is_a?(Array) && raw_questions.any?
61
+
62
+ asker = init0_asker
63
+ return ask_init0(asker, raw_questions) if asker
64
+
65
+ results = []
66
+ raw_questions.each_with_index do |q_data, idx|
67
+ res = process_single_question(q_data, index: idx + 1)
68
+ return res if res.is_a?(ToolOutput) && res.error?
69
+
70
+ results << res[:text]
71
+ break if res[:cancelled]
72
+ end
73
+
74
+ ToolOutput.ok(results.join("\n\n"))
75
+ rescue StandardError => e
76
+ ToolOutput.error("ask_question failed: #{e.message}")
77
+ end
78
+
79
+ private
80
+
81
+ # The subagent plane: resolves ONLY when this tool instance was bound to
82
+ # a subagent child context (ScopedRegistry rebind); the root-context
83
+ # instance stays nil here and keeps the human-dialog behavior.
84
+ def init0_asker
85
+ return nil unless @runtime_context.respond_to?(:get)
86
+
87
+ Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::SUBAGENT_QUESTION_ASKER)
88
+ end
89
+
90
+ # One blocking round-trip for the whole batch: the maestro answers once and
91
+ # the reply covers every numbered question — N sequential blocks would
92
+ # burn the subagent's wall-clock budget for one interaction.
93
+ def ask_init0(asker, raw_questions)
94
+ validated = raw_questions.each_with_index.map do |q_data, idx|
95
+ res = validate_question(q_data, index: idx + 1)
96
+ return res if res.is_a?(ToolOutput)
97
+
98
+ res
99
+ end
100
+ combined = validated.each_with_index.map do |res, idx|
101
+ validated.size == 1 ? res[:question] : "#{idx + 1}. #{res[:question]}"
102
+ end.join("\n")
103
+
104
+ answer = asker.ask_init0(combined, options: validated.flat_map { |res| res[:options] }.uniq)
105
+ if answer == :unavailable
106
+ ToolOutput.ok("[Question routing unavailable (no supervisor channel) — proceed autonomously. Question: #{combined}]")
107
+ elsif answer.nil?
108
+ ToolOutput.ok("[No answer from the maestro within the question timeout — proceed with your best judgment. Question: #{combined}]")
109
+ else
110
+ ToolOutput.ok("Question: #{combined}\nMaestro answered: #{answer}")
111
+ end
112
+ end
113
+
114
+ def process_single_question(q_data, index:)
115
+ parsed = validate_question(q_data, index: index)
116
+ return parsed if parsed.is_a?(ToolOutput)
117
+
118
+ if @dialog.interactive?
119
+ ask_interactively(parsed[:question], parsed[:options], parsed[:is_multi])
120
+ else
121
+ { text: auto_select_fallback(parsed[:question], parsed[:options], parsed[:is_multi]), cancelled: false }
122
+ end
123
+ end
124
+
125
+ # Shared validation for both planes; returns a question Hash or a
126
+ # ToolOutput error.
127
+ def validate_question(q_data, index:)
128
+ return ToolOutput.error("Invalid question #{index}: item must be an object with 'question' and 'options' properties.") unless q_data.is_a?(Hash)
129
+
130
+ q_text = (q_data["question"] || q_data[:question]).to_s.strip
131
+ raw_options = q_data["options"] || q_data[:options]
132
+ is_multi = q_data["is_multi_select"] || q_data[:is_multi_select] || false
133
+
134
+ return ToolOutput.error("Invalid question #{index}: question text cannot be blank.") if q_text.empty?
135
+ return ToolOutput.error("Invalid question #{index}: must provide an array of selectable options.") unless raw_options.is_a?(Array)
136
+
137
+ options = raw_options.map(&:to_s).map(&:strip).reject(&:empty?).uniq
138
+ return ToolOutput.error("Invalid question #{index}: must provide at least 2 distinct non-empty selectable options.") if options.size < 2
139
+
140
+ { question: q_text, options: options, is_multi: is_multi }
141
+ end
142
+
143
+ def ask_interactively(q_text, options, is_multi)
144
+ if is_multi
145
+ ans = @dialog.ask_multi(q_text, options)
146
+ if ans == :cancelled
147
+ { text: "User cancelled question prompt without selection: \"#{q_text}\"", cancelled: true }
148
+ else
149
+ { text: "Question: \"#{q_text}\"\nUser response: #{Array(ans).inspect}", cancelled: false }
150
+ end
151
+ else
152
+ ans = @dialog.ask_single(q_text, options)
153
+ if ans == :cancelled
154
+ { text: "User cancelled question prompt without selection: \"#{q_text}\"", cancelled: true }
155
+ elsif ans.to_s.start_with?("Other (write-in): ")
156
+ clean_ans = ans.to_s.sub(/\AOther \(write-in\):\s*/, "")
157
+ { text: "Question: \"#{q_text}\"\nUser response (custom write-in): #{clean_ans}", cancelled: false }
158
+ elsif ans.to_s.start_with?("Other (")
159
+ { text: "Question: \"#{q_text}\"\nUser response (custom write-in): #{ans}", cancelled: false }
160
+ else
161
+ { text: "Question: \"#{q_text}\"\nUser response: #{ans}", cancelled: false }
162
+ end
163
+ end
164
+ end
165
+
166
+ def auto_select_fallback(q_text, options, is_multi)
167
+ recommended = options.find { |opt| opt =~ /\A\s*\(recommended\)/i } || options.first
168
+ selection = is_multi ? [recommended] : recommended
169
+ selection_label = is_multi ? selection.inspect : selection
170
+
171
+ "[Non-interactive session: automatically selected '#{selection_label}' for question: \"#{q_text}\"]"
172
+ end
173
+ end
174
+ end
175
+ end