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,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../storage/layout"
4
+
5
+ module Rubik
6
+ module Tools
7
+ # Generates empathetic, actionable Elm-compiler-style error diagnostics (Plan 56 D-2).
8
+ # Combines clear problem statements, context inspection, "Did you mean?" suggestions,
9
+ # and numbered next steps to optimize LLM self-healing on first attempt.
10
+ class DiagnosticReporter
11
+ HEADER_WIDTH = 70
12
+
13
+ def self.box_header(title)
14
+ dashes_len = [HEADER_WIDTH - title.length - 4, 4].max
15
+ "-- #{title.upcase} #{'-' * dashes_len}"
16
+ end
17
+
18
+ # Elm-style diagnostic when a file path does not exist in workspace.
19
+ def self.render_file_not_found(path, candidates: [], existing_dir_files: [])
20
+ lines = [box_header("File Not Found")]
21
+ lines << "I cannot find the file `#{path}` in the project workspace."
22
+ lines << ""
23
+
24
+ if candidates && !candidates.empty?
25
+ lines << "Did you mean this?"
26
+ candidates.each { |c| lines << " • #{c}" }
27
+ lines << ""
28
+ end
29
+
30
+ if existing_dir_files && !existing_dir_files.empty?
31
+ dir_name = File.dirname(path)
32
+ display_dir = dir_name == "." ? "project root" : "`#{dir_name}/`"
33
+ lines << "Existing files in #{display_dir}:"
34
+ existing_dir_files.first(8).each { |f| lines << " • #{f}" }
35
+ lines << ""
36
+ end
37
+
38
+ lines << "Actionable Next Steps:"
39
+ lines << if candidates && !candidates.empty?
40
+ " 1. If you meant an existing file, call `read_file(path: '#{candidates.first}')`."
41
+ else
42
+ " 1. Verify the exact path or explore directory structure using `list_files(path: '#{File.dirname(path)}')`."
43
+ end
44
+ lines << " 2. If you want to create this new file, call `write_file(path: '#{path}', content: '...')`."
45
+ lines << " 3. To search for code containing this filename, call `grep_search(query: '#{File.basename(path)}')`."
46
+
47
+ lines.join("\n")
48
+ end
49
+
50
+ # Elm-style diagnostic when target_content is not found in the file or range.
51
+ def self.render_target_not_found(path, target_content, closest_snippet: nil, closest_range: nil, similarity: nil, start_line: nil, end_line: nil)
52
+ range_str = start_line || end_line ? " within lines #{start_line || 1}..#{end_line || 'end'}" : ""
53
+ lines = [box_header("Target Content Not Found")]
54
+ lines << "I could not find the exact `target_content` in `#{path}`#{range_str}."
55
+ lines << ""
56
+ lines << "Target content requested:"
57
+ lines << format_snippet_box(target_content)
58
+ lines << ""
59
+
60
+ if closest_snippet && !closest_snippet.empty?
61
+ sim_str = similarity ? " (#{similarity}% similarity)" : ""
62
+ range_label = closest_range ? "lines #{closest_range.begin}..#{closest_range.end}" : "nearby lines"
63
+ lines << "Closest matching snippet found at #{range_label}#{sim_str}:"
64
+ lines << format_snippet_box(closest_snippet)
65
+ lines << ""
66
+ end
67
+
68
+ suggested_offset = closest_range ? [closest_range.begin - 2, 1].max : [start_line.to_i - 5, 1].max
69
+ lines << "Actionable Next Steps:"
70
+ lines << " 1. Call `read_file(path: '#{path}', offset: #{suggested_offset}, limit: 25)` to inspect exact indentation, whitespace, and line content."
71
+ lines << " 2. Re-run `replace_file_content` with the exact verbatim lines from the file."
72
+ lines << " 3. Or omit `start_line`/`end_line` if the target content moved elsewhere in the file." if start_line || end_line
73
+
74
+ lines.join("\n")
75
+ end
76
+
77
+ # Elm-style diagnostic when target_content matches multiple locations.
78
+ def self.render_target_not_unique(path, target_content, match_count, line_occurrences = [])
79
+ lines = [box_header("Target Content Not Unique")]
80
+ occ_str = line_occurrences.empty? ? "" : " at lines: #{line_occurrences.join(', ')}"
81
+ lines << "The specified `target_content` was found #{match_count} times in `#{path}`#{occ_str}."
82
+ lines << ""
83
+ lines << "Target content:"
84
+ lines << format_snippet_box(target_content)
85
+ lines << ""
86
+ lines << "Actionable Next Steps:"
87
+ lines << " 1. Provide more surrounding context lines in `target_content` to make the match unique."
88
+ if line_occurrences && line_occurrences.size >= 2
89
+ first_occ = line_occurrences.first
90
+ start_hint = [first_occ - 2, 1].max
91
+ end_hint = first_occ + target_content.to_s.lines.size + 2
92
+ lines << " 2. Or restrict the search range with `start_line` and `end_line` (e.g. `start_line: #{start_hint}, end_line: #{end_hint}`)."
93
+ else
94
+ lines << " 2. Or specify `start_line` and `end_line` to target the exact occurrence."
95
+ end
96
+ lines << " 3. If you intentionally want to replace ALL #{match_count} occurrences across the entire file, pass `allow_multiple: true`."
97
+
98
+ lines.join("\n")
99
+ end
100
+
101
+ # Elm-style diagnostic when a tool name is unrecognized.
102
+ def self.render_unknown_tool(tool_name, candidate_tools: [], all_tools: [])
103
+ lines = [box_header("Unknown Tool")]
104
+ lines << "I cannot find an active tool named `#{tool_name}`."
105
+ lines << ""
106
+
107
+ if candidate_tools && !candidate_tools.empty?
108
+ lines << "Did you mean one of these?"
109
+ candidate_tools.each { |c| lines << " • #{c}" }
110
+ lines << ""
111
+ end
112
+
113
+ if all_tools && !all_tools.empty?
114
+ formatted_tools = all_tools.map { |t| "`#{t}`" }.join(", ")
115
+ lines << "Available direct tools in toolbelt:"
116
+ lines << " #{formatted_tools}"
117
+ lines << ""
118
+ end
119
+
120
+ lines << "💡 Need a specialized capability?"
121
+ lines << " You can dynamically create and register your own custom tool at runtime using:"
122
+ lines << " `synthesize_tool(name: '#{tool_name.to_s.downcase}', description: '...', code: '->(args) { ... }')`"
123
+
124
+ lines.join("\n")
125
+ end
126
+
127
+ # Elm-style diagnostic when a symbol definition is not found in AST.
128
+ def self.render_symbol_not_found(sym_name, candidate_symbols: [])
129
+ lines = [box_header("Symbol Not Found")]
130
+ lines << "I could not find the symbol `#{sym_name}` in the AST semantic graph."
131
+ lines << ""
132
+
133
+ if candidate_symbols && !candidate_symbols.empty?
134
+ lines << "Did you mean one of these?"
135
+ candidate_symbols.each { |s| lines << " • #{s}" }
136
+ lines << ""
137
+ end
138
+
139
+ lines << "Actionable Next Steps:"
140
+ lines << " 1. Search for partial matches using `search_symbols(query: '#{sym_name.to_s[0..4]}')`."
141
+ lines << " 2. Search for code occurrences across the workspace with `grep_search(query: '#{sym_name}')`."
142
+ lines << " 3. Inspect a specific file outline with `get_outline(file_path: '...')`."
143
+
144
+ lines.join("\n")
145
+ end
146
+
147
+ # Elm-style diagnostic when a shell/sandbox command fails.
148
+ def self.render_command_failed(cmd, exit_code, stderr, suggestions: [])
149
+ lines = [box_header("Command Execution Failed")]
150
+ lines << "Command `#{cmd}` failed with exit code #{exit_code}."
151
+ lines << ""
152
+ if stderr && !stderr.to_s.strip.empty?
153
+ lines << "Output / Error:"
154
+ lines << format_snippet_box(stderr.to_s.strip)
155
+ lines << ""
156
+ end
157
+
158
+ if suggestions && !suggestions.empty?
159
+ lines << "Actionable Next Steps:"
160
+ suggestions.each_with_index { |s, idx| lines << " #{idx + 1}. #{s}" }
161
+ end
162
+
163
+ lines.join("\n")
164
+ end
165
+
166
+ # Elm-style diagnostic when operating system permissions prevent file/directory access (Errno::EACCES).
167
+ def self.render_permission_denied(path, operation: "read", reason: nil)
168
+ lines = [box_header("Permission Denied")]
169
+ reason_str = reason ? " (#{reason})" : " (Errno::EACCES)"
170
+ op_text = operation.to_s == "read" ? "access" : operation.to_s
171
+ lines << "I cannot #{op_text} `#{path}` due to operating system permission restrictions#{reason_str}."
172
+ lines << ""
173
+ lines << "Actionable Next Steps:"
174
+ lines << " 1. If this is a scratch or temporary directory, allow it via REPL: `!allow #{path}`"
175
+ lines << " 2. Ensure the process has proper OS #{operation} permissions for this directory."
176
+ lines << " 3. Target a specific accessible subfolder using `list_files(path: 'accessible_dir')`."
177
+
178
+ lines.join("\n")
179
+ end
180
+
181
+ # Elm-style diagnostic when path traversal is blocked outside allowed workspace roots.
182
+ def self.render_traversal_blocked(path, allowed_roots: [], allowed_exts: []) # rubocop:disable Lint/UnusedMethodArgument -- arayüz kwarg'i korunuyor
183
+ lines = [box_header("Path Access Restricted (Sandbox Gate)")]
184
+ lines << "Access to `#{path}` was blocked because it is outside the project workspace."
185
+ lines << ""
186
+
187
+ roots = Array(allowed_roots)
188
+ unless roots.empty?
189
+ lines << "Configured Allowed Roots:"
190
+ roots.each do |root_info|
191
+ if root_info.is_a?(Hash)
192
+ tag = root_info[:primary] ? "[PRIMARY]" : "[ALLOWED]"
193
+ note = root_info[:zero_ast] ? " (Zero-AST)" : ""
194
+ lines << " • #{tag} #{root_info[:path]}#{note}"
195
+ else
196
+ lines << " • #{root_info}"
197
+ end
198
+ end
199
+ lines << ""
200
+ end
201
+
202
+ lines << "Actionable Next Steps:"
203
+ lines << " 1. Use a path relative to the project root (e.g. 'lib', 'src') or explore with `list_files`."
204
+ lines << " 2. To allow transient I/O and sandbox access to this folder without AST bloat, run:"
205
+ lines << " `!allow #{path}`"
206
+ lines << " 3. To add it as a full workspace root with AST indexing, run:"
207
+ lines << " `!workspace add #{path} [--rw]`"
208
+
209
+ # Canonical-route hint (incident #1): a blocked path inside the
210
+ # central Rubik storage can be read through its rubik:// route —
211
+ # no sandbox widening needed. Best effort; empty outside storage.
212
+ hints = Rubik::Storage::Layout.storage_route_hints(path)
213
+ unless hints.empty?
214
+ lines << " 4. This path is inside the Rubik central storage; read the same content through its canonical route (no sandbox change needed):"
215
+ hints.each { |hint| lines << " `read_file #{hint}`" }
216
+ end
217
+
218
+ lines.join("\n")
219
+ end
220
+
221
+ # Elm-style diagnostic when malformed or invalid UTF-8 byte sequences are detected.
222
+ def self.render_encoding_warning(source, sample_snippet: nil)
223
+ lines = [box_header("Encoding Warning (Malformed UTF-8)")]
224
+ lines << "Malformed or invalid UTF-8 byte sequences detected in `#{source}`."
225
+ lines << "The input has been automatically sanitized with lossless replacement characters."
226
+ lines << ""
227
+
228
+ if sample_snippet && !sample_snippet.to_s.empty?
229
+ lines << "Sanitized Sample Preview:"
230
+ lines << format_snippet_box(sample_snippet)
231
+ lines << ""
232
+ end
233
+
234
+ lines << "Actionable Next Steps:"
235
+ lines << " 1. Ensure source files and command inputs are encoded in standard UTF-8."
236
+ lines << " 2. For binary or non-text files, use dedicated binary inspection or base64 decoding."
237
+
238
+ lines.join("\n")
239
+ end
240
+
241
+ # Elm-style diagnostic guidance for LLM context window utilization and token management.
242
+ def self.render_context_status_guidance(stats, token_breakdown: {})
243
+ lines = [box_header("Context Window & Token Guidance")]
244
+ stats_hash = stats.is_a?(Hash) ? stats : {}
245
+ model_name = stats_hash[:model] || stats_hash["model"] || "active model"
246
+ used_tokens = stats_hash[:consumed_tokens] || stats_hash[:estimated_tokens] || stats_hash["consumed_tokens"] || 0
247
+ limit_tokens = stats_hash[:context_limit] || stats_hash[:usable_capacity] || stats_hash["context_limit"] || 32_768
248
+ pct = limit_tokens.positive? ? ((used_tokens.to_f / limit_tokens) * 100).round(1) : 0.0
249
+
250
+ lines << "Current Context Utilization: #{used_tokens} / #{limit_tokens} tokens (#{pct}%) for `#{model_name}`"
251
+ lines << ""
252
+
253
+ breakdown_hash = token_breakdown.is_a?(Hash) ? token_breakdown : {}
254
+ unless breakdown_hash.empty?
255
+ lines << "Token Breakdown:"
256
+ breakdown_hash.each do |k, v|
257
+ lines << " • #{k}: #{v} tokens"
258
+ end
259
+ lines << ""
260
+ end
261
+
262
+ lines << "Actionable Next Steps:"
263
+ if pct >= 80.0
264
+ lines << " 1. Context window is nearly full (>=80%). Run `!context compact` to prune older conversation turns."
265
+ lines << " 2. Switch to a higher-capacity model using `!model default <provider>/<model>`."
266
+ lines << " 3. Drop unnecessary indexed files using `!context drop <path>`."
267
+ elsif pct >= 50.0
268
+ lines << " 1. Context utilization is moderate (>=50%). Use `!context stats` to monitor token growth."
269
+ lines << " 2. Consider running `!context compact` before lengthy multi-step goal loops."
270
+ else
271
+ lines << " 1. Context window is healthy with ample headroom (<50%)."
272
+ lines << " 2. Continue normal interactions or add context with `!context add <path>`."
273
+ end
274
+
275
+ lines.join("\n")
276
+ end
277
+
278
+ def self.format_snippet_box(text)
279
+ text.to_s.lines.map { |l| " #{l.chomp}" }.join("\n")
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+ begin
5
+ require "diff/lcs"
6
+ rescue LoadError
7
+ # fallback if not yet installed in some minimal environments
8
+ end
9
+
10
+ module Rubik
11
+ module Tools
12
+ # Provides fast, stdlib-powered spelling suggestions and closest-subsequence
13
+ # matching for Elm-style actionable diagnostics (Plan 56 D-1).
14
+ class FuzzyMatcher
15
+ # Returns the top candidates closest to `query` using DidYouMean::SpellChecker
16
+ # and Levenshtein similarity ranking.
17
+ #
18
+ # @param query [String] Search query or misspelled token
19
+ # @param candidates [Array<String>] Dictionary of valid identifiers
20
+ # @param limit [Integer] Maximum number of suggestions to return
21
+ # @return [Array<String>] Sorted list of closest matches
22
+ # Single source of truth for the candidate-score cutoff in best_matches
23
+ # and the length-bound prune inside similarity_ratio - keep them tied.
24
+ MATCH_THRESHOLD = 0.35
25
+
26
+ def self.best_matches(query, candidates, limit: 3)
27
+ return [] if query.to_s.strip.empty? || candidates.nil? || candidates.empty?
28
+
29
+ str_query = query.to_s.downcase.strip
30
+ str_candidates = candidates.map { |c| c.to_s.downcase.strip }.reject(&:empty?).uniq
31
+
32
+ # Direct spell check via stdlib if available
33
+ dym_matches = []
34
+ if defined?(DidYouMean::SpellChecker)
35
+ checker = DidYouMean::SpellChecker.new(dictionary: str_candidates)
36
+ dym_matches = checker.correct(str_query)
37
+ end
38
+
39
+ # Substring inclusions get prioritized
40
+ substring_matches = str_candidates.select { |c| c.include?(str_query) || str_query.include?(c) }
41
+
42
+ # Rank all candidates by Levenshtein similarity ratio
43
+ # PERF: pass min_threshold to prune candidates whose length difference
44
+ # prevents them from ever reaching MATCH_THRESHOLD
45
+ scored_pairs = str_candidates.map do |cand|
46
+ score = similarity_ratio(str_query, cand, min_threshold: MATCH_THRESHOLD)
47
+ [cand, score]
48
+ end
49
+ scored = scored_pairs.select { |_c, score| score >= MATCH_THRESHOLD }
50
+ .sort_by { |_c, score| -score }
51
+ .map(&:first)
52
+
53
+ (dym_matches + substring_matches + scored).uniq.first(limit)
54
+ end
55
+
56
+ # Computes normalized string similarity ratio in [0.0, 1.0].
57
+ #
58
+ # @param str1 [String] First string
59
+ # @param str2 [String] Second string
60
+ # @param min_threshold [Float, nil] Optional minimum acceptable score for early upper-bound pruning
61
+ # @return [Float] Similarity score (1.0 = exact match)
62
+ def self.similarity_ratio(str1, str2, min_threshold: nil)
63
+ s1 = str1.to_s.downcase
64
+ s2 = str2.to_s.downcase
65
+ return 1.0 if s1 == s2
66
+ return 0.0 if s1.empty? || s2.empty?
67
+
68
+ l1 = s1.length
69
+ l2 = s2.length
70
+ max_len = l1 > l2 ? l1.to_f : l2.to_f
71
+
72
+ if min_threshold
73
+ len_diff = (l1 - l2).abs
74
+ # PERF: Upper-bound check on string length difference; if max achievable ratio < min_threshold, prune edit distance calculation
75
+ return 0.0 if (1.0 - (len_diff / max_len)) < min_threshold
76
+ end
77
+
78
+ dist = levenshtein_distance(s1, s2)
79
+ [1.0 - (dist / max_len), 0.0].max
80
+ end
81
+
82
+ # Computes Levenshtein edit distance between two strings using standard dynamic programming.
83
+ def self.levenshtein_distance(str1, str2)
84
+ return DidYouMean::Levenshtein.distance(str1.to_s, str2.to_s) if defined?(DidYouMean::Levenshtein) && DidYouMean::Levenshtein.respond_to?(:distance)
85
+
86
+ s1 = str1.to_s.chars
87
+ s2 = str2.to_s.chars
88
+ return s2.length if s1.empty?
89
+ return s1.length if s2.empty?
90
+
91
+ d = Array.new(s1.length + 1) { |i| i }
92
+
93
+ s2.each_with_index do |char2, j|
94
+ new_d = [j + 1]
95
+ s1.each_with_index do |char1, i|
96
+ cost = char1 == char2 ? 0 : 1
97
+ new_d << [
98
+ d[i + 1] + 1, # deletion
99
+ new_d[i] + 1, # insertion
100
+ d[i] + cost # substitution
101
+ ].min
102
+ end
103
+ d = new_d
104
+ end
105
+
106
+ d.last
107
+ end
108
+
109
+ # Finds the closest matching line window in `file_content` for `target_content`
110
+ # using Longest Common Subsequence (LCS) analysis (Plan 56 D-1/D-3).
111
+ #
112
+ # @param target_content [String] The snippet being searched
113
+ # @param file_content [String] Full file content
114
+ # @return [Hash, nil] { snippet:, range:, similarity: } or nil if no good match
115
+ def self.find_closest_subsequence(target_content, file_content)
116
+ t_raw = target_content.to_s.gsub("\r\n", "\n")
117
+ f_raw = file_content.to_s.gsub("\r\n", "\n")
118
+ t_lines = t_raw.lines.map(&:rstrip)
119
+ f_lines = f_raw.lines.map(&:rstrip)
120
+ return nil if t_lines.empty? || f_lines.empty?
121
+
122
+ k = t_lines.size
123
+ best_score = 0.0
124
+ best_range = 1..1
125
+ best_i = nil
126
+ best_window_len = nil
127
+
128
+ # PERF: memoize raw file lines once before loop to avoid re-splitting inside loop on score updates
129
+ f_raw_lines = f_raw.lines
130
+
131
+ (0...(f_lines.size)).each do |i|
132
+ window_len = [k, f_lines.size - i].min
133
+ # PERF: Array#[start, len] slice avoids Range object allocation inside hot loop
134
+ window = f_lines[i, window_len]
135
+
136
+ matches, total =
137
+ if defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
138
+ [Diff::LCS.LCS(t_lines, window).size, [t_lines.size, window.size].max]
139
+ else
140
+ # Fallback line matching
141
+ [(0...[t_lines.size, window.size].min).count { |idx| t_lines[idx] == window[idx] },
142
+ [t_lines.size, window.size].max]
143
+ end
144
+ score = total.positive? ? (matches.to_f / total) : 0.0
145
+
146
+ if k == 1 && window_len == 1
147
+ char_score = similarity_ratio(t_lines.first, window.first)
148
+ score = [score, char_score].max
149
+ end
150
+
151
+ next unless score > best_score
152
+
153
+ best_score = score
154
+ best_range = (i + 1)..(i + window_len)
155
+ best_i = i
156
+ best_window_len = window_len
157
+ end
158
+
159
+ return nil if best_score < 0.25 || best_i.nil?
160
+
161
+ # PERF: defer snippet string construction until after best match selection
162
+ best_snippet = f_raw_lines[best_i, best_window_len].join
163
+
164
+ {
165
+ snippet: best_snippet.strip,
166
+ range: best_range,
167
+ similarity: (best_score * 100).round
168
+ }
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ begin
6
+ require "tty/which"
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ module Rubik
12
+ module Tools
13
+ # ripgrep shellout for GrepSearchTool's native fast path. The module is
14
+ # side-effect free: every spawn is call-scoped (stdin closed immediately,
15
+ # streams closed, process reaped — killed first when the result cap cuts
16
+ # the stream short, else rg would deadlock on a full stdout pipe) — no
17
+ # persistent process, thread, or listener outlives a search, so no
18
+ # dispose inverse is owed.
19
+ #
20
+ # Windows stdin trap (measured, Plan 52 §2.4): rg spawned with a pipe
21
+ # stdin decides from a heuristic to search STDIN instead of "./" and
22
+ # exits with zero results. The path argument is therefore ALWAYS explicit
23
+ # and stdin is closed right after spawn; both are regression-tested.
24
+ #
25
+ # rg runs parallel: measured on a 5.000-file tree, `-j 1` triples the
26
+ # scan time (247 → 864 ms) while pruning globs are free. Consequence:
27
+ # when the result cap cuts the stream, WHICH subset of matches survives
28
+ # follows rg's arrival order — the merged result is still sorted
29
+ # (file, line) and correctly capped, and byte-identical reproducibility
30
+ # remains available via tools.native: off.
31
+ module NativeSearch
32
+ BASE_FLAGS = ["--no-messages", "--no-heading", "-n", "--hidden"].freeze
33
+
34
+ @mutex = Mutex.new
35
+ @rg_path = nil
36
+
37
+ class << self
38
+ # Rubik prunes these subtrees itself; the target repo's .gitignore
39
+ # may not, so the rg-side and Ruby-side file universes stay
40
+ # identical. Lazily built: Rubik::Context::Scanner may not be loaded
41
+ # at require time (tools load before context in some entry points).
42
+ def prune_globs
43
+ @prune_globs ||= Rubik::Context::Scanner::DEFAULT_IGNORES.flat_map { |p| ["-g", "!#{p}"] }.freeze
44
+ end
45
+
46
+ # Resolved rg executable, or nil. RUBIK_RG_PATH beats PATH lookup
47
+ # (blank treated as unset); memoized (reset_memoization! for tests).
48
+ def rg_path(env: ENV)
49
+ @mutex.synchronize do
50
+ @rg_path ||= begin
51
+ override = env["RUBIK_RG_PATH"]
52
+ override.nil? || override.strip.empty? ? resolve_via_which : override
53
+ end
54
+ end
55
+ end
56
+
57
+ def available?(env: ENV)
58
+ !rg_path(env: env).nil?
59
+ end
60
+
61
+ # ENV beats config; anything other than "off" (auto/on/unknown)
62
+ # attempts the native path first.
63
+ def enabled?(config_value, env: ENV)
64
+ mode = env["RUBIK_NATIVE"] || config_value || "auto"
65
+ mode.to_s.strip.downcase != "off"
66
+ end
67
+
68
+ # Runs rg and parses "path:line:content" rows into GrepSearchTool's
69
+ # match hashes. Returns nil when rg could not be trusted (missing
70
+ # binary, spawn failure, non-0/1 exit such as a regex-usage error) —
71
+ # the caller must fall back to its Ruby path rather than render a
72
+ # possibly-wrong result.
73
+ #
74
+ # The argument count is inherent to a search request: one pattern,
75
+ # three scope knobs, a cap, and two injection hooks (env/runner for
76
+ # test isolation). Grouping them into a bag-hash would hide the
77
+ # contract without reducing real complexity.
78
+ def search(pattern, root_dir:, path: ".", glob: nil, case_sensitive: false, max_results: 50, env: ENV, runner: nil)
79
+ rg = rg_path(env: env)
80
+ return nil if rg.nil? && runner.nil?
81
+
82
+ argv = [rg, *BASE_FLAGS]
83
+ argv << "-i" unless case_sensitive
84
+ argv += ["-g", glob] if glob && !glob.empty?
85
+ argv += prune_globs
86
+ argv += ["-e", pattern.to_s, path]
87
+
88
+ matches = []
89
+ trusted = run_spawn(argv, root_dir: root_dir, runner: runner) do |raw|
90
+ m = parse_line(raw, root_dir: root_dir)
91
+ next false unless m
92
+
93
+ matches << m
94
+ next true if matches.size >= max_results
95
+
96
+ false
97
+ end
98
+ trusted == true ? matches : nil
99
+ end
100
+
101
+ def reset_memoization!
102
+ @mutex.synchronize { @rg_path = nil }
103
+ end
104
+
105
+ # "path:12:content". The SPLIT anchors on the FIRST :digits: boundary
106
+ # and the candidate path is existence-checked against root_dir: a
107
+ # matched content line like `ts = 12:34:56` must not hijack the line
108
+ # number (a greedy split would attribute 34 as the line). If no
109
+ # boundary yields a real file — a path that itself contains :digits:
110
+ # — fall back to the greedy split. Both the path and the content are
111
+ # scrubbed to valid UTF-8: rg emits raw bytes, and a broken-encoding
112
+ # file must not poison JSON payloads downstream.
113
+ def parse_line(raw, root_dir: nil)
114
+ match = raw.match(/\A(.+?):(\d+):(.*)\z/m)
115
+ return nil unless match
116
+
117
+ file = scrub_utf8(match[1])
118
+ if root_dir && !File.exist?(File.join(root_dir, file.tr("\\", "/")))
119
+ match = raw.match(/\A(.+):(\d+):(.*)\z/m)
120
+ return nil unless match
121
+
122
+ file = scrub_utf8(match[1])
123
+ end
124
+
125
+ content = scrub_utf8(match[3])
126
+ content = "#{content[0..500]}... [line truncated, #{content.length} chars total]" if content.length > 500
127
+
128
+ {
129
+ file: file.tr("\\", "/").sub(%r{\A\./}, ""),
130
+ line: match[2].to_i,
131
+ content: content
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def resolve_via_which
138
+ defined?(TTY::Which) ? TTY::Which.which("rg") : nil
139
+ end
140
+
141
+ # Yields each stdout line; the block's truthy return marks an early
142
+ # cap. On cap the rg process is killed BEFORE waiter.value, else a
143
+ # capped rg keeps filling its stdout pipe with no reader and the wait
144
+ # would deadlock. Without a cap, rg exits 0 (matches) / 1 (no
145
+ # matches) — trusted. Exit 2 (usage/regex error) and spawn failures
146
+ # are untrusted → nil → caller falls back.
147
+ def run_spawn(argv, root_dir:, runner:, &)
148
+ if runner
149
+ runner.call(argv, root_dir).each(&)
150
+ return true
151
+ end
152
+
153
+ cap_reached = false
154
+ Open3.popen3(*argv, chdir: root_dir, err: File::NULL) do |stdin, stdout, _stderr, waiter|
155
+ stdin.close
156
+ stdout.each_line do |raw|
157
+ cap_reached = yield raw.chomp
158
+ break if cap_reached
159
+ end
160
+ begin
161
+ Process.kill("KILL", waiter.pid) if cap_reached
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
166
+ status = waiter.value
167
+ cap_reached || (status.exited? && [0, 1].include?(status.exitstatus))
168
+ end
169
+ rescue StandardError
170
+ false
171
+ end
172
+
173
+ def scrub_utf8(str)
174
+ str = str.dup.force_encoding(Encoding::UTF_8) if str.encoding == Encoding::ASCII_8BIT
175
+ str.valid_encoding? ? str : str.scrub
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Tools
5
+ # A String tool output that carries an explicit error classification.
6
+ #
7
+ # Why a String subclass: the historical builtin-tool contract returns
8
+ # plain Strings, and StepExecutor inferred success/failure by sniffing
9
+ # ERROR_PREFIXES — real failures ("AST graph is not available", "LSP
10
+ # manager is not connected", argument-validation messages) were silently
11
+ # labeled status="ok" in the tool_result envelope. ToolOutput keeps every
12
+ # existing string consumer (tests, Truncator, formatters) working while
13
+ # letting StepExecutor's existing `respond_to?(:error?)` branch classify
14
+ # the result correctly.
15
+ class ToolOutput < String
16
+ def self.ok(text)
17
+ new(text.to_s, is_error: false)
18
+ end
19
+
20
+ def self.error(text)
21
+ new(text.to_s, is_error: true)
22
+ end
23
+
24
+ # Display-output contract: the full text is meant for the USER'S
25
+ # terminal, not for the model's context. StepExecutor emits it as a
26
+ # :user_output event and feeds only the short stub back into history.
27
+ def self.display(text, stub:)
28
+ new(text.to_s, is_error: false, display_stub: stub)
29
+ end
30
+
31
+ def initialize(text = "", is_error: false, display_stub: nil)
32
+ super(text)
33
+ @is_error = is_error ? true : false
34
+ @display_stub = display_stub
35
+ end
36
+
37
+ def error?
38
+ @is_error
39
+ end
40
+
41
+ def ok?
42
+ !@is_error
43
+ end
44
+
45
+ def user_display?
46
+ !@display_stub.nil?
47
+ end
48
+
49
+ def display_stub
50
+ @display_stub.to_s
51
+ end
52
+ end
53
+ end
54
+ end