From b6a1132a1d5f526d0478103a56cbb170aa989f56 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 19 Sep 2026 05:49:54 +0530 Subject: [PATCH 1/5] fix: stop pointing agents at CLAUDE.md; AGENTS.md is the source of truth rho's own guidance advertised CLAUDE.md alongside AGENTS.md. Since AGENTS.md is now the single source of truth, drop it from: - the adaptive system prompt ("Follow project-specific conventions documented in AGENTS.md and CLAUDE.md") - the auto-init context-file gate and its doc comments - `rho context export` Also fixes a pre-existing bug in cmd/context_export.go: the instruction file list was {"AGENTS.md", "AGENTS.md", "CLAUDE.md", ".rho.md"} -- AGENTS.md appeared twice, so it was read and emitted two times. internal/rules and config.loadCrossAgentInstructions are deliberately untouched: those are interop, letting users migrating from Claude Code, Cursor, Copilot and Gemini import their existing rules into rho. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/autoinit.go | 2 +- cmd/context_export.go | 2 +- internal/autoinit/autoinit.go | 4 ++-- internal/autoinit/autoinit_test.go | 4 ++-- internal/engine/adaptive_system_prompt.go | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/autoinit.go b/cmd/autoinit.go index 385f32cf..46a3dfb2 100644 --- a/cmd/autoinit.go +++ b/cmd/autoinit.go @@ -23,7 +23,7 @@ const autoInitContextFile = "AGENTS.md" // // - It runs in a background goroutine so it never blocks chat startup. // - It is a no-op unless the project has NO context file (AGENTS.md / RHO.md -// / CLAUDE.md / CONTEXT.md), no auto-init marker exists yet, and the +// / CONTEXT.md), no auto-init marker exists yet, and the // RHO_DISABLE_AUTO_INIT kill switch is unset — all enforced by // autoinit.MaybeRun. // - Any failure (analysis error, write error) is swallowed; startup proceeds. diff --git a/cmd/context_export.go b/cmd/context_export.go index e46b901c..466edcee 100644 --- a/cmd/context_export.go +++ b/cmd/context_export.go @@ -62,7 +62,7 @@ func ExportContext(dir string, focus string) (string, error) { } // AGENTS.md / project instructions - for _, instrFile := range []string{"AGENTS.md", "AGENTS.md", "CLAUDE.md", ".rho.md"} { + for _, instrFile := range []string{"AGENTS.md", ".rho.md"} { data, err := os.ReadFile(filepath.Join(dir, instrFile)) // #nosec G304 -- instrFile is one of a fixed set of well-known project instruction filenames if err == nil && len(data) > 0 { b.WriteString(fmt.Sprintf("## Project Instructions (%s)\n\n%s\n\n", instrFile, strings.TrimSpace(string(data)))) diff --git a/internal/autoinit/autoinit.go b/internal/autoinit/autoinit.go index 6f9ccf31..7462398b 100644 --- a/internal/autoinit/autoinit.go +++ b/internal/autoinit/autoinit.go @@ -1,6 +1,6 @@ // Package autoinit performs a one-time, automatic codebase-analysis pass the // first time rho runs in a project that has no context files (AGENTS.md / -// RHO.md / CLAUDE.md). It mirrors the behaviour of the `init-deep` skill but +// RHO.md / CONTEXT.md). It mirrors the behaviour of the `init-deep` skill but // is gated so it runs at most once per project and can be disabled entirely. // // The package is intentionally additive and self-contained: it only inspects @@ -35,7 +35,7 @@ const disableEnv = "RHO_DISABLE_AUTO_INIT" // contextFiles are the project-level context files whose presence means the // project already has context and auto-init should be skipped. This matches // the convention files recognized elsewhere in rho. -var contextFiles = []string{"AGENTS.md", "RHO.md", "CLAUDE.md", "CONTEXT.md"} +var contextFiles = []string{"AGENTS.md", "RHO.md", "CONTEXT.md"} // Runner performs the actual codebase analysis for a project rooted at root. // It is supplied by the caller so this package carries no dependency on the diff --git a/internal/autoinit/autoinit_test.go b/internal/autoinit/autoinit_test.go index efd2feaf..3f799298 100644 --- a/internal/autoinit/autoinit_test.go +++ b/internal/autoinit/autoinit_test.go @@ -174,11 +174,11 @@ func TestHasContext(t *testing.T) { if HasContext(root) { t.Error("empty dir should not have context") } - if err := os.WriteFile(filepath.Join(root, "CLAUDE.md"), []byte("x"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, "CONTEXT.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if !HasContext(root) { - t.Error("dir with CLAUDE.md should have context") + t.Error("dir with CONTEXT.md should have context") } } diff --git a/internal/engine/adaptive_system_prompt.go b/internal/engine/adaptive_system_prompt.go index 294fd26d..74273a6c 100644 --- a/internal/engine/adaptive_system_prompt.go +++ b/internal/engine/adaptive_system_prompt.go @@ -286,7 +286,7 @@ func DefaultSections(ctx PromptBuildContext) []PromptSection { }, { Name: "project", - Content: "Follow project-specific conventions documented in AGENTS.md and CLAUDE.md. Respect existing code style, patterns, and architecture.", + Content: "Follow project-specific conventions documented in AGENTS.md. Respect existing code style, patterns, and architecture.", Priority: 2, Conditional: func() bool { return ctx.ProjectType != "" From dd2bed43a727a6b526ab16e4360c040e28534103 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 20 Sep 2026 13:43:49 +0530 Subject: [PATCH 2/5] refactor: harden permissions and simplify CLI runtime --- .dockerignore | 22 - .editorconfig | 4 - .gitattributes | 2 - .github/CODEOWNERS | 1 - .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/workflows/daemon-image.yml | 122 --- .github/workflows/docker.yml | 234 ----- .trivyignore | 10 - Dockerfile | 65 -- Dockerfile.daemon | 57 -- Makefile | 7 +- SECURITY.md | 2 +- cmd/agent.go | 13 +- cmd/autocomplete.go | 1 - cmd/autonomy_picker.go | 59 +- cmd/autonomy_tiers.go | 26 +- cmd/block_conversation.go | 28 +- cmd/chat.go | 56 +- cmd/chat_commands.go | 460 ++------- cmd/chat_commands_config.go | 77 +- cmd/chat_commands_session.go | 299 ++---- cmd/chat_commands_tools.go | 110 +- cmd/chat_commands_util.go | 64 +- cmd/chat_export.go | 197 +--- cmd/chat_history_search.go | 112 +- cmd/chat_model.go | 116 ++- cmd/chat_model_test.go | 50 +- cmd/chat_mouse_scroll_test.go | 6 +- cmd/chat_permission_keys_test.go | 376 ++++++- cmd/chat_print.go | 18 +- cmd/chat_prompt_timeout_test.go | 264 ++++- cmd/chat_scrollbar.go | 56 +- cmd/chat_select.go | 47 +- cmd/chat_session_picker.go | 120 +-- cmd/chat_sidebar.go | 161 +++ cmd/chat_stream.go | 96 +- cmd/chat_subcommand.go | 126 +-- cmd/chat_subcommand_branch.go | 9 +- cmd/chat_subcommand_help.go | 20 +- cmd/chat_subcommand_mode.go | 2 +- cmd/chat_subcommand_simple.go | 13 +- cmd/chat_subcommand_test.go | 3 + cmd/chat_submit.go | 6 +- cmd/chat_turn.go | 51 +- cmd/chat_update.go | 553 ++-------- cmd/chat_update_overlays.go | 109 ++ cmd/chat_update_overlays_test.go | 46 + cmd/chat_update_prompts.go | 635 ++++++++++++ cmd/chat_update_stream.go | 195 ++++ cmd/chat_view.go | 92 +- cmd/chat_viewport_render.go | 7 +- cmd/chat_viewport_render_test.go | 14 + cmd/chat_welcome.go | 19 +- cmd/chat_yolo_confirm_test.go | 10 +- cmd/checkpoint.go | 11 +- cmd/command_palette.go | 174 ++-- cmd/command_palette_test.go | 5 + cmd/compact_ui.go | 2 +- cmd/completions.go | 30 +- cmd/completions_test.go | 9 + cmd/confirm_test.go | 38 +- cmd/context_viz.go | 10 +- cmd/credentials.go | 11 +- cmd/eval.go | 4 +- cmd/eval_tools.go | 2 +- cmd/exec.go | 15 +- cmd/features.go | 2 +- cmd/fingerprint.go | 2 +- cmd/harness.go | 2 +- cmd/history.go | 79 +- cmd/input_indicator.go | 39 +- cmd/input_indicator_test.go | 2 +- cmd/learn_cmd.go | 12 +- cmd/mission.go | 2 +- cmd/options.go | 30 +- cmd/overlay_styles.go | 42 + cmd/overlay_zero_value_test.go | 33 + cmd/permission_view_test.go | 87 ++ cmd/permissions.go | 231 ++++- cmd/permissions_center.go | 430 ++++---- cmd/permissions_center_test.go | 164 ++- cmd/permissions_inspect_test.go | 34 + cmd/progressive_disclosure.go | 3 +- cmd/prompt_input.go | 40 +- cmd/prompt_input_test.go | 93 ++ cmd/review_pipeline.go | 264 +---- cmd/review_tui.go | 4 +- cmd/rho/main.go | 4 +- cmd/rho_mascot.go | 20 + cmd/root.go | 8 +- cmd/sandbox.go | 106 -- cmd/security_verify_governance_cli_test.go | 3 +- cmd/skills_cmd.go | 11 +- cmd/slash_commands_test.go | 12 + cmd/spec_picker.go | 57 +- cmd/statusbar.go | 148 +-- cmd/statusbar_test.go | 13 +- cmd/taste.go | 15 +- cmd/testfirst_workflow.go | 120 +-- cmd/theme.go | 101 +- cmd/theme_chrome_test.go | 21 + cmd/theme_picker.go | 54 +- cmd/theme_picker_test.go | 41 + cmd/tool_policy_catalog_test.go | 20 + cmd/trust.go | 11 +- cmd/ui.go | 8 +- cmd/vibe.go | 142 --- cmd/vibe_test.go | 67 -- cmd/visual_diff.go | 8 +- cmd/welcome_banner.go | 23 +- cmd/welcome_inline_test.go | 12 +- deploy/docker/docker-compose.yml | 27 - docs/COMPETITIVE.md | 11 +- docs/ECOSYSTEM-ROADMAP.md | 11 +- docs/PERMISSION-MODEL-IMPROVEMENTS.md | 371 ++----- docs/RESEARCH.md | 14 +- docs/architecture.md | 3 +- .../architecture/rho-architecture-baseline.md | 5 +- docs/architecture/rho-feature-modules.md | 100 ++ docs/architecture/rho-harness.md | 2 +- docs/architecture/spec.md | 3 +- docs/design/RHO-CLOUD-SAAS.md | 35 +- docs/intelligent-cli.md | 2 +- docs/monitoring-guide.md | 16 - docs/operations-checklist.md | 6 +- .../FULL-GROK-ECO-TO-RHO-ECO-PORT-PLAN.md | 2 +- docs/plans/codex-adoption-plan.md | 33 +- docs/plans/commandcodeai-adoption-plan.md | 2 +- .../competitive-gap-01-docker-onboarding.md | 39 - ...competitive-gap-04-published-benchmarks.md | 2 +- docs/plans/dsh-harness-rfc-port-plan.md | 27 +- docs/plans/fix-critical-and-high-review.md | 13 +- docs/plans/fx-adoption-plan.md | 2 +- docs/plans/goose-adoption-plan.md | 12 +- docs/plans/pi-adoption-plan.md | 35 +- docs/plans/pi-renderer-and-eval-reporting.md | 7 +- docs/troubleshooting-guide.md | 43 - examples/github/rho-ci-exec.yml | 2 +- flake.nix | 2 +- internal/acp/server.go | 6 +- internal/acp/server_test.go | 6 + internal/config/developer_path.go | 2 +- internal/config/settings.go | 2 +- internal/config/settings_test.go | 2 +- internal/contracts/policy/policy.go | 10 +- internal/daemon/daemon.go | 8 +- internal/daemon/e2e_test.go | 2 +- internal/daemon/gateway_test.go | 2 +- internal/daemon/middleware.go | 2 +- internal/daemon/middleware_test.go | 2 +- internal/daemon/routes_review_test.go | 2 +- internal/daemon/telegram_test.go | 2 +- internal/diffsandbox/diff.go | 215 ---- internal/diffsandbox/sandbox.go | 500 --------- internal/diffsandbox/sandbox_security_test.go | 139 --- internal/diffsandbox/sandbox_test.go | 501 --------- internal/engine/agent/subagent_budget.go | 4 +- internal/engine/compact/files.go | 26 +- internal/engine/developer_features_test.go | 37 - internal/engine/diff/aliases.go | 2 +- internal/engine/diff/diff_helpers.go | 49 + internal/engine/diff/diffsandbox.go | 413 -------- internal/engine/diff/diffsandbox_test.go | 239 ----- internal/engine/engine.go | 2 +- internal/engine/engine_test.go | 14 +- internal/engine/errs/error_context.go | 30 +- internal/engine/errs/error_context_test.go | 11 - internal/engine/goose_extras_test.go | 28 - internal/engine/integration_test.go | 4 +- internal/engine/permission_exactrules_test.go | 28 +- internal/engine/permission_service.go | 799 +++++++++++---- internal/engine/permission_service_test.go | 406 +++++++- internal/engine/permission_session_methods.go | 59 +- internal/engine/project/project_analyzer.go | 60 +- internal/engine/safety/autonomy.go | 85 -- internal/engine/safety/autonomy_test.go | 141 +-- internal/engine/safety/capabilities.go | 90 +- internal/engine/safety/capabilities_test.go | 12 + internal/engine/safety/never_test.go | 16 +- internal/engine/safety/permission.go | 394 ++++--- .../engine/safety/permission_display_test.go | 51 + internal/engine/safety/permission_engine.go | 360 ++++--- .../permission_engine_governance_test.go | 5 +- .../safety/permission_engine_hy6_test.go | 34 +- .../engine/safety/permission_engine_test.go | 222 +++- internal/engine/safety/profile.go | 56 +- internal/engine/safety/profile_test.go | 13 + internal/engine/session.go | 28 +- internal/engine/session_mock_test.go | 4 +- internal/engine/stream.go | 8 - internal/engine/stream_tool_exec.go | 4 +- internal/engine/sub_service_wiring_test.go | 7 +- internal/engine/tool_confirmation.go | 111 -- internal/engine/tool_inspector.go | 70 -- internal/engine/tool_service.go | 44 +- internal/eventlog/event.go | 5 +- internal/eventlog/invariants.go | 9 +- internal/eventlog/lifecycle.go | 49 - internal/eventlog/lifecycle_test.go | 70 +- internal/eventlog/plan_mode_test.go | 10 - internal/eventlog/projection.go | 9 +- internal/eventlog/wire.go | 9 - internal/features/README.md | 18 + internal/features/chat/history.go | 79 ++ internal/features/chat/history_search.go | 79 ++ internal/features/chat/history_search_test.go | 24 + internal/features/chat/history_test.go | 32 + internal/features/chat/runner.go | 59 ++ internal/features/chat/runner_test.go | 104 ++ internal/features/chat/stream.go | 95 ++ internal/features/chat/stream_test.go | 46 + internal/features/chat/transcript.go | 111 ++ internal/features/chat/transcript_buffer.go | 31 + internal/features/chat/transcript_test.go | 76 ++ internal/features/chat/turn.go | 29 + internal/features/chat/turn_test.go | 21 + internal/features/commands/catalog.go | 106 ++ internal/features/commands/catalog_test.go | 45 + internal/features/commands/history.go | 68 ++ internal/features/commands/parser.go | 62 ++ internal/features/commands/parser_test.go | 17 + internal/features/commands/registry.go | 119 +++ internal/features/commands/registry_test.go | 50 + internal/features/commands/resolution_test.go | 24 + internal/features/commands/suggestions.go | 129 +++ .../features/commands/suggestions_test.go | 29 + internal/features/config/command.go | 91 ++ internal/features/config/command_test.go | 57 ++ .../{feature => features}/daemon_flags.go | 6 - .../{feature => features}/eval/benchmark.go | 0 .../eval/benchmark_test.go | 0 internal/{feature => features}/eval/cache.go | 0 .../{feature => features}/eval/coverage.go | 0 .../eval/coverage_test.go | 0 internal/{feature => features}/eval/eval.go | 0 .../{feature => features}/eval/eval_test.go | 0 .../{feature => features}/eval/filters.go | 0 internal/{feature => features}/eval/groups.go | 0 internal/{feature => features}/eval/hash.go | 0 .../{feature => features}/eval/lmeval_test.go | 0 .../eval/parallel_runner.go | 0 .../eval/parallel_runner_test.go | 0 internal/{feature => features}/eval/report.go | 0 .../eval/skills/run_eval.sh | 0 .../eval/skills/scenarios.md | 0 internal/{feature => features}/eval/store.go | 0 .../{feature => features}/eval/tasks_go.go | 0 .../eval/tasks_go_more.go | 0 .../{feature => features}/eval/toolmatrix.go | 0 .../eval/toolmatrix_test.go | 0 .../{feature => features}/eval/yaml_tasks.go | 0 .../{feature => features}/evalloop/compare.go | 0 .../evalloop/compare_test.go | 0 .../evalloop/evalloop_test.go | 0 .../{feature => features}/evalloop/runtime.go | 0 .../{feature => features}/evalloop/session.go | 0 internal/features/execution/testfirst.go | 94 ++ internal/features/execution/testfirst_test.go | 28 + internal/features/explain/explain.go | 68 ++ internal/features/explain/explain_test.go | 58 ++ internal/{feature => features}/feature.go | 7 +- .../{feature => features}/feature_test.go | 4 - .../fingerprint/detect.go | 0 .../fingerprint/detect_deps_test.go | 0 .../fingerprint/fingerprint.go | 0 .../fingerprint/fingerprint_test.go | 2 +- .../fingerprint/format_test.go | 0 .../fingerprint/project.go | 0 .../fingerprint/project_conventions.go | 0 .../fingerprint/project_conventions_test.go | 0 .../fingerprint/project_detect.go | 0 .../fingerprint/project_detect_test.go | 0 .../fingerprint/project_test.go | 0 internal/features/parallel/request.go | 56 + internal/features/parallel/request_test.go | 31 + internal/features/review/pipeline.go | 237 +++++ internal/features/review/pipeline_test.go | 31 + internal/features/session/cleanup.go | 30 + internal/features/session/cleanup_test.go | 19 + internal/features/session/command_args.go | 57 ++ .../features/session/command_args_test.go | 34 + internal/features/session/commands.go | 91 ++ internal/features/session/commands_test.go | 25 + internal/features/session/export.go | 133 +++ internal/features/session/export_test.go | 69 ++ internal/features/session/filter.go | 81 ++ internal/features/session/filter_test.go | 27 + internal/features/session/hydrate.go | 27 + internal/features/session/hydrate_test.go | 27 + internal/features/session/lifecycle.go | 25 + internal/features/session/metadata.go | 49 + internal/features/session/metadata_test.go | 44 + internal/features/session/mutation.go | 31 + internal/features/session/mutation_test.go | 28 + internal/features/session/presentation.go | 23 + .../features/session/presentation_test.go | 21 + internal/features/session/service.go | 69 ++ internal/features/session/service_test.go | 39 + .../shellmode/classify.go | 0 .../shellmode/classify_test.go | 0 internal/features/shellmode/display.go | 31 + internal/features/shellmode/display_test.go | 29 + .../{feature => features}/shellmode/modes.go | 0 .../shellmode/modes_test.go | 0 .../shellmode/reroute.go | 0 .../shellmode/reroute_test.go | 0 .../shellmode/shellmode.go | 24 +- .../shellmode/shellmode_test.go | 0 internal/features/skills/activation.go | 51 + internal/features/skills/activation_test.go | 38 + .../{feature => features}/taste/collector.go | 0 .../taste/collector_test.go | 0 .../{feature => features}/taste/detector.go | 0 .../taste/detector_test.go | 0 internal/{feature => features}/taste/hooks.go | 0 .../{feature => features}/taste/hooks_test.go | 0 .../{feature => features}/taste/profile.go | 0 .../taste/profile_test.go | 0 internal/{feature => features}/taste/store.go | 0 .../{feature => features}/taste/store_test.go | 0 .../welcome/assets/rho-mascot-display.png | Bin 0 -> 92871 bytes internal/features/welcome/welcome.go | 40 + internal/features/welcome/welcome_test.go | 12 + internal/features/workspace/context.go | 37 + internal/features/workspace/diff.go | 49 + internal/features/workspace/diff_test.go | 60 ++ internal/features/workspace/git.go | 51 + internal/features/workspace/workspace_test.go | 33 + internal/governance/governance_test.go | 9 + internal/intelligence/repomap/summary.go | 2 +- .../metrics/permission_metrics.go | 10 + internal/permissions/advanced.go | 392 +------ internal/permissions/advanced_bench_test.go | 10 - internal/permissions/advanced_test.go | 93 +- internal/permissions/approval_workflow.go | 418 -------- .../permissions/approval_workflow_test.go | 568 ----------- internal/permissions/bypass_test.go | 35 +- internal/permissions/egress.go | 456 --------- internal/permissions/egress_test.go | 575 ----------- internal/permissions/grants.go | 113 +- internal/permissions/grants_test.go | 6 +- internal/permissions/guardian.go | 376 ------- .../permissions/guardian_injection_test.go | 92 -- internal/permissions/guardian_json_test.go | 367 ------- internal/permissions/guardian_test.go | 453 --------- internal/permissions/injection_scanner.go | 608 ----------- .../permissions/injection_scanner_test.go | 601 ----------- internal/permissions/osv_checker.go | 962 ------------------ internal/permissions/osv_checker_test.go | 517 ---------- internal/permissions/persist.go | 58 -- internal/permissions/rules.go | 286 ------ internal/permissions/rules_test.go | 418 -------- internal/permissions/sanitizer.go | 754 -------------- internal/permissions/sanitizer_script_test.go | 360 ------- internal/permissions/sanitizer_test.go | 505 --------- internal/permissions/semantic_match_test.go | 72 -- internal/permissions/stableid/state.go | 7 + internal/permissions/stableid/state_test.go | 11 + internal/permissions/stablerules.go | 153 ++- internal/permissions/stablerules_test.go | 92 ++ internal/permissions/tool_names.go | 107 ++ internal/permissions/tool_names_test.go | 35 + internal/permissions/verdict.go | 44 - internal/permissions/verdict_test.go | 152 --- internal/plugin/dynamic_test.go | 3 + internal/plugin/plugin_tool.go | 5 + internal/plugin/runtime.go | 40 +- internal/plugin/runtime_test.go | 62 ++ internal/plugin/wasm.go | 2 +- internal/prompt/prompt.go | 2 +- internal/prompts/templates/examples.md | 2 +- internal/prompts/templates/role.md | 4 +- internal/terminal/store.go | 4 +- internal/testaudit/audit_test.go | 7 +- internal/theme/auto_detect.go | 1 + internal/theme/theme.go | 141 +-- internal/theme/theme_palettes.go | 51 +- internal/theme/theme_test.go | 77 +- internal/theme/tint.go | 4 + internal/tool/credential_gate.go | 27 +- internal/tool/mcp_tool.go | 5 + internal/tool/patch.go | 2 +- internal/tool/path_guard_root_test.go | 6 +- internal/tool/powershell.go | 23 +- internal/tool/powershell_test.go | 16 + internal/tool/sandbox_escape_test.go | 46 +- internal/tool/task_tools.go | 5 +- internal/tool/tool.go | 7 + internal/tool/tool_health.go | 3 +- internal/tool/validate_input_test.go | 2 +- internal/tool/zz_testhelpers_test.go | 2 +- scripts/check-feature-boundaries.sh | 28 + scripts/e2e-macos.sh | 8 +- testdata/golden/help_root.txt | 2 - 394 files changed, 11225 insertions(+), 17246 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/daemon-image.yml delete mode 100644 .github/workflows/docker.yml delete mode 100644 .trivyignore delete mode 100644 Dockerfile delete mode 100644 Dockerfile.daemon create mode 100644 cmd/chat_sidebar.go create mode 100644 cmd/chat_update_overlays.go create mode 100644 cmd/chat_update_overlays_test.go create mode 100644 cmd/chat_update_prompts.go create mode 100644 cmd/chat_update_stream.go create mode 100644 cmd/overlay_styles.go create mode 100644 cmd/overlay_zero_value_test.go create mode 100644 cmd/permission_view_test.go create mode 100644 cmd/permissions_inspect_test.go create mode 100644 cmd/prompt_input_test.go create mode 100644 cmd/rho_mascot.go delete mode 100644 cmd/sandbox.go create mode 100644 cmd/theme_picker_test.go create mode 100644 cmd/tool_policy_catalog_test.go delete mode 100644 cmd/vibe.go delete mode 100644 cmd/vibe_test.go delete mode 100644 deploy/docker/docker-compose.yml create mode 100644 docs/architecture/rho-feature-modules.md delete mode 100644 docs/plans/competitive-gap-01-docker-onboarding.md delete mode 100644 internal/diffsandbox/diff.go delete mode 100644 internal/diffsandbox/sandbox.go delete mode 100644 internal/diffsandbox/sandbox_security_test.go delete mode 100644 internal/diffsandbox/sandbox_test.go create mode 100644 internal/engine/diff/diff_helpers.go delete mode 100644 internal/engine/diff/diffsandbox.go delete mode 100644 internal/engine/diff/diffsandbox_test.go delete mode 100644 internal/engine/tool_confirmation.go delete mode 100644 internal/engine/tool_inspector.go create mode 100644 internal/features/README.md create mode 100644 internal/features/chat/history.go create mode 100644 internal/features/chat/history_search.go create mode 100644 internal/features/chat/history_search_test.go create mode 100644 internal/features/chat/history_test.go create mode 100644 internal/features/chat/runner.go create mode 100644 internal/features/chat/runner_test.go create mode 100644 internal/features/chat/stream.go create mode 100644 internal/features/chat/stream_test.go create mode 100644 internal/features/chat/transcript.go create mode 100644 internal/features/chat/transcript_buffer.go create mode 100644 internal/features/chat/transcript_test.go create mode 100644 internal/features/chat/turn.go create mode 100644 internal/features/chat/turn_test.go create mode 100644 internal/features/commands/catalog.go create mode 100644 internal/features/commands/catalog_test.go create mode 100644 internal/features/commands/history.go create mode 100644 internal/features/commands/parser.go create mode 100644 internal/features/commands/parser_test.go create mode 100644 internal/features/commands/registry.go create mode 100644 internal/features/commands/registry_test.go create mode 100644 internal/features/commands/resolution_test.go create mode 100644 internal/features/commands/suggestions.go create mode 100644 internal/features/commands/suggestions_test.go create mode 100644 internal/features/config/command.go create mode 100644 internal/features/config/command_test.go rename internal/{feature => features}/daemon_flags.go (88%) rename internal/{feature => features}/eval/benchmark.go (100%) rename internal/{feature => features}/eval/benchmark_test.go (100%) rename internal/{feature => features}/eval/cache.go (100%) rename internal/{feature => features}/eval/coverage.go (100%) rename internal/{feature => features}/eval/coverage_test.go (100%) rename internal/{feature => features}/eval/eval.go (100%) rename internal/{feature => features}/eval/eval_test.go (100%) rename internal/{feature => features}/eval/filters.go (100%) rename internal/{feature => features}/eval/groups.go (100%) rename internal/{feature => features}/eval/hash.go (100%) rename internal/{feature => features}/eval/lmeval_test.go (100%) rename internal/{feature => features}/eval/parallel_runner.go (100%) rename internal/{feature => features}/eval/parallel_runner_test.go (100%) rename internal/{feature => features}/eval/report.go (100%) rename internal/{feature => features}/eval/skills/run_eval.sh (100%) rename internal/{feature => features}/eval/skills/scenarios.md (100%) rename internal/{feature => features}/eval/store.go (100%) rename internal/{feature => features}/eval/tasks_go.go (100%) rename internal/{feature => features}/eval/tasks_go_more.go (100%) rename internal/{feature => features}/eval/toolmatrix.go (100%) rename internal/{feature => features}/eval/toolmatrix_test.go (100%) rename internal/{feature => features}/eval/yaml_tasks.go (100%) rename internal/{feature => features}/evalloop/compare.go (100%) rename internal/{feature => features}/evalloop/compare_test.go (100%) rename internal/{feature => features}/evalloop/evalloop_test.go (100%) rename internal/{feature => features}/evalloop/runtime.go (100%) rename internal/{feature => features}/evalloop/session.go (100%) create mode 100644 internal/features/execution/testfirst.go create mode 100644 internal/features/execution/testfirst_test.go create mode 100644 internal/features/explain/explain.go create mode 100644 internal/features/explain/explain_test.go rename internal/{feature => features}/feature.go (96%) rename internal/{feature => features}/feature_test.go (96%) rename internal/{feature => features}/fingerprint/detect.go (100%) rename internal/{feature => features}/fingerprint/detect_deps_test.go (100%) rename internal/{feature => features}/fingerprint/fingerprint.go (100%) rename internal/{feature => features}/fingerprint/fingerprint_test.go (99%) rename internal/{feature => features}/fingerprint/format_test.go (100%) rename internal/{feature => features}/fingerprint/project.go (100%) rename internal/{feature => features}/fingerprint/project_conventions.go (100%) rename internal/{feature => features}/fingerprint/project_conventions_test.go (100%) rename internal/{feature => features}/fingerprint/project_detect.go (100%) rename internal/{feature => features}/fingerprint/project_detect_test.go (100%) rename internal/{feature => features}/fingerprint/project_test.go (100%) create mode 100644 internal/features/parallel/request.go create mode 100644 internal/features/parallel/request_test.go create mode 100644 internal/features/review/pipeline.go create mode 100644 internal/features/review/pipeline_test.go create mode 100644 internal/features/session/cleanup.go create mode 100644 internal/features/session/cleanup_test.go create mode 100644 internal/features/session/command_args.go create mode 100644 internal/features/session/command_args_test.go create mode 100644 internal/features/session/commands.go create mode 100644 internal/features/session/commands_test.go create mode 100644 internal/features/session/export.go create mode 100644 internal/features/session/export_test.go create mode 100644 internal/features/session/filter.go create mode 100644 internal/features/session/filter_test.go create mode 100644 internal/features/session/hydrate.go create mode 100644 internal/features/session/hydrate_test.go create mode 100644 internal/features/session/lifecycle.go create mode 100644 internal/features/session/metadata.go create mode 100644 internal/features/session/metadata_test.go create mode 100644 internal/features/session/mutation.go create mode 100644 internal/features/session/mutation_test.go create mode 100644 internal/features/session/presentation.go create mode 100644 internal/features/session/presentation_test.go create mode 100644 internal/features/session/service.go create mode 100644 internal/features/session/service_test.go rename internal/{feature => features}/shellmode/classify.go (100%) rename internal/{feature => features}/shellmode/classify_test.go (100%) create mode 100644 internal/features/shellmode/display.go create mode 100644 internal/features/shellmode/display_test.go rename internal/{feature => features}/shellmode/modes.go (100%) rename internal/{feature => features}/shellmode/modes_test.go (100%) rename internal/{feature => features}/shellmode/reroute.go (100%) rename internal/{feature => features}/shellmode/reroute_test.go (100%) rename internal/{feature => features}/shellmode/shellmode.go (91%) rename internal/{feature => features}/shellmode/shellmode_test.go (100%) create mode 100644 internal/features/skills/activation.go create mode 100644 internal/features/skills/activation_test.go rename internal/{feature => features}/taste/collector.go (100%) rename internal/{feature => features}/taste/collector_test.go (100%) rename internal/{feature => features}/taste/detector.go (100%) rename internal/{feature => features}/taste/detector_test.go (100%) rename internal/{feature => features}/taste/hooks.go (100%) rename internal/{feature => features}/taste/hooks_test.go (100%) rename internal/{feature => features}/taste/profile.go (100%) rename internal/{feature => features}/taste/profile_test.go (100%) rename internal/{feature => features}/taste/store.go (100%) rename internal/{feature => features}/taste/store_test.go (100%) create mode 100644 internal/features/welcome/assets/rho-mascot-display.png create mode 100644 internal/features/welcome/welcome.go create mode 100644 internal/features/welcome/welcome_test.go create mode 100644 internal/features/workspace/context.go create mode 100644 internal/features/workspace/diff.go create mode 100644 internal/features/workspace/diff_test.go create mode 100644 internal/features/workspace/git.go create mode 100644 internal/features/workspace/workspace_test.go delete mode 100644 internal/permissions/approval_workflow.go delete mode 100644 internal/permissions/approval_workflow_test.go delete mode 100644 internal/permissions/egress.go delete mode 100644 internal/permissions/egress_test.go delete mode 100644 internal/permissions/guardian.go delete mode 100644 internal/permissions/guardian_injection_test.go delete mode 100644 internal/permissions/guardian_json_test.go delete mode 100644 internal/permissions/guardian_test.go delete mode 100644 internal/permissions/injection_scanner.go delete mode 100644 internal/permissions/injection_scanner_test.go delete mode 100644 internal/permissions/osv_checker.go delete mode 100644 internal/permissions/osv_checker_test.go delete mode 100644 internal/permissions/persist.go delete mode 100644 internal/permissions/rules.go delete mode 100644 internal/permissions/rules_test.go delete mode 100644 internal/permissions/sanitizer.go delete mode 100644 internal/permissions/sanitizer_script_test.go delete mode 100644 internal/permissions/sanitizer_test.go delete mode 100644 internal/permissions/semantic_match_test.go create mode 100644 internal/permissions/tool_names.go create mode 100644 internal/permissions/tool_names_test.go delete mode 100644 internal/permissions/verdict.go delete mode 100644 internal/permissions/verdict_test.go create mode 100644 internal/plugin/runtime_test.go create mode 100755 scripts/check-feature-boundaries.sh diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index e6a3689d..00000000 --- a/.dockerignore +++ /dev/null @@ -1,22 +0,0 @@ -.git -.gitignore -*.md -.github -.factory -Dockerfile -.dockerignore -rho_bin -rho_test_bin -coverage.out -coverage.html -go.work -go.work.sum -.env -.envrc -*.pem -*.key -# Go build/test caches — huge and never needed in the image. -.gocache -.gocache-public -.golangci-cache -.golangci-cache-public diff --git a/.editorconfig b/.editorconfig index 39f1a419..43e58696 100644 --- a/.editorconfig +++ b/.editorconfig @@ -54,10 +54,6 @@ indent_size = 4 [{Makefile,*.mk}] indent_style = tab -# Dockerfiles. -[Dockerfile*] -indent_size = 4 - # GitHub Actions workflows — 2 spaces. [.github/**/*.{yml,yaml}] indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 18cfb2ab..a166dbb3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,8 +31,6 @@ # --- Build / packaging ---------------------------------------------------- Makefile text eol=lf *.mk text eol=lf -Dockerfile* text eol=lf -docker-compose*.yml text eol=lf .github/**/*.yml text eol=lf .github/**/*.yaml text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ec945010..ddef946b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,7 +24,6 @@ /.github/ @GrayCodeAI/devops-team /Makefile @GrayCodeAI/devops-team /.goreleaser.yml @GrayCodeAI/devops-team -/Dockerfile @GrayCodeAI/devops-team # Versioning + release artefacts /VERSION @GrayCodeAI/maintainers diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2b0087a2..b1f3a57c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,8 +24,8 @@ ```text diff --git a/.github/workflows/daemon-image.yml b/.github/workflows/daemon-image.yml deleted file mode 100644 index 54198863..00000000 --- a/.github/workflows/daemon-image.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: Daemon image - -on: - push: - branches: [main] - tags: ["v*"] - pull_request: - branches: [main] - paths: - - "Dockerfile.daemon" - - "packaging/systemd/rho-daemon.service" - - "internal/**" - - "cmd/**" - - "go.mod" - - "go.sum" - -permissions: - contents: read - packages: write - security-events: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: graycodeai/rho-daemon - -jobs: - build: - name: build + scan + publish (daemon) - runs-on: ubuntu-latest - steps: - - name: Check out source - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build daemon image for scan - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: Dockerfile.daemon - platforms: linux/amd64 - push: false - load: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - cache-from: type=gha,scope=rho-daemon - cache-to: type=gha,mode=max,scope=rho-daemon - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - - name: Scan daemon image with Trivy - uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 - with: - version: v0.70.0 - cache: true - - name: Run Trivy daemon scan (sarif) - shell: bash - run: | - # Go reachability is enforced separately by govulncheck in CI. The - # binary also carries the full workspace module graph, including - # non-reachable packages that Trivy reports as binary findings. - # CVE-2026-14456 (OpenSSL) is ignored via .trivyignore — the fixed - # libcrypto 3.5.8-r0 is not yet published in Alpine 3.23. - trivy image \ - --severity CRITICAL,HIGH \ - --ignore-unfixed \ - --ignorefile "${GITHUB_WORKSPACE}/.trivyignore" \ - --vuln-type os \ - --format sarif \ - --output trivy-daemon-image.sarif \ - --exit-code 1 \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - - - name: Generate image metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha-,format=long - - # The PR already exercised the daemon Dockerfile in the scan build above. - # Skip the redundant multi-arch publish build on pull requests so CI can - # finish as soon as the security gate passes. - - name: Build and publish daemon image - if: github.event_name != 'pull_request' - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - file: Dockerfile.daemon - platforms: linux/amd64,linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=rho-daemon - cache-to: type=gha,mode=max,scope=rho-daemon - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - # Publish the daemon scan to GitHub code scanning. The PR still runs the - # scan, but it skips the redundant publish build and release artifacts. - - name: Upload daemon image scan results - if: always() - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: trivy-daemon-image.sarif diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 6beb49fa..00000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,234 +0,0 @@ -name: Docker - -on: - push: - branches: [main] - tags: ["v*"] - pull_request: - branches: [main] - paths: - - "Dockerfile" - - "**.go" - - "go.mod" - - "go.sum" - -permissions: - contents: read - packages: write - security-events: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: graycodeai/rho - -jobs: - # Build each platform natively on its own runner (arm64 natively via the - # ubuntu-24.04-arm runner instead of QEMU emulation, which took ~28 min for - # the multi-arch push build). The two jobs run in parallel; merge-manifest - # then combines the per-platform images into the shared multi-arch tags. - # A cache mount in the Dockerfile (persisted through cache-to: gha, mode=max) - # keeps per-commit rebuilds to a relink instead of a cold Go compile. - build-amd64: - name: build + scan (amd64) - runs-on: ubuntu-latest - outputs: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Build a single-platform image locally first so Trivy can gate the push: - # CRITICAL/HIGH findings fail this job before anything reaches GHCR. - - name: Build image for scan - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - platforms: linux/amd64 - push: false - load: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - cache-from: type=gha,scope=rho-amd64 - cache-to: type=gha,mode=max,scope=rho-amd64 - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - - name: Scan image with Trivy - uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 - with: - version: v0.70.0 - cache: true - - name: Run Trivy scan (sarif) - shell: bash - run: | - # Go reachability is enforced separately by govulncheck in CI. The - # binary also carries the full workspace module graph, including - # non-reachable packages that Trivy reports as binary findings. - # CVE-2026-14456 (OpenSSL) is ignored via .trivyignore — the fixed - # libcrypto 3.5.8-r0 is not yet published in Alpine 3.23. - trivy image \ - --severity CRITICAL,HIGH \ - --ignore-unfixed \ - --ignorefile "${GITHUB_WORKSPACE}/.trivyignore" \ - --vuln-type os \ - --format sarif \ - --output trivy-image.sarif \ - --exit-code 1 \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - - # Second build is a cache hit (layers exported by the scan build), so it - # only re-links and pushes the platform image. - - name: Build and push (amd64) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:amd64-${{ github.sha }} - cache-from: type=gha,scope=rho-amd64 - cache-to: type=gha,mode=max,scope=rho-amd64 - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - # Publish the scan results to GitHub code scanning. The scan itself runs - # on PRs; only the release-side publish path stays off PRs. - - name: Upload Trivy image scan results - if: always() - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: trivy-image.sarif - - build-arm64: - name: build + scan (arm64) - runs-on: ubuntu-24.04-arm - outputs: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Build a single-platform image locally first so Trivy can gate the push: - # CRITICAL/HIGH findings fail this job before anything reaches GHCR. - - name: Build image for scan - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - platforms: linux/arm64 - push: false - load: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - cache-from: type=gha,scope=rho-arm64 - cache-to: type=gha,mode=max,scope=rho-arm64 - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - - name: Scan image with Trivy - uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 - with: - version: v0.70.0 - cache: true - - name: Run Trivy scan (sarif) - shell: bash - run: | - # Go reachability is enforced separately by govulncheck in CI. The - # binary also carries the full workspace module graph, including - # non-reachable packages that Trivy reports as binary findings. - # CVE-2026-14456 (OpenSSL) is ignored via .trivyignore — the fixed - # libcrypto 3.5.8-r0 is not yet published in Alpine 3.23. - trivy image \ - --severity CRITICAL,HIGH \ - --ignore-unfixed \ - --ignorefile "${GITHUB_WORKSPACE}/.trivyignore" \ - --vuln-type os \ - --format sarif \ - --output trivy-image.sarif \ - --exit-code 1 \ - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:scan - - # Second build is a cache hit (layers exported by the scan build), so it - # only re-links and pushes the platform image. - - name: Build and push (arm64) - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 - with: - context: . - platforms: linux/arm64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:arm64-${{ github.sha }} - cache-from: type=gha,scope=rho-arm64 - cache-to: type=gha,mode=max,scope=rho-arm64 - build-args: | - VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - BUILD_DATE=${{ github.event.head_commit.timestamp }} - - # Publish the scan results to GitHub code scanning. The scan itself runs - # on PRs; only the release-side publish path stays off PRs. - - name: Upload Trivy image scan results - if: always() - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: trivy-image.sarif - - merge-manifest: - name: merge multi-arch manifest (release only) - if: github.event_name != 'pull_request' - needs: [build-amd64, build-arm64] - runs-on: ubuntu-latest - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha-,format=long - - # Point every shared tag (main, sha-, semver) at a manifest list - # covering both platform images pushed by the native jobs. - - name: Merge per-platform images into multi-arch tags - run: | - set -euo pipefail - src="${{ needs.build-amd64.outputs.image }} ${{ needs.build-arm64.outputs.image }}" - printf '%s\n' "${{ steps.meta.outputs.tags }}" | while IFS= read -r tag; do - if [ -n "$tag" ]; then - docker buildx imagetools create -t "$tag" $src - fi - done diff --git a/.trivyignore b/.trivyignore deleted file mode 100644 index 8eb51b57..00000000 --- a/.trivyignore +++ /dev/null @@ -1,10 +0,0 @@ -# Trivy OS-package ignore list for Rho Docker images. -# -# CVE-2026-14456 — OpenSSL DoS via unbounded memory (libcrypto3/libssl3). -# Fixed upstream in OpenSSL 3.5.8-r0, but that package is NOT yet published in -# the Alpine 3.23 repository (the latest alpine:3.23 still ships 3.5.7-r0, as -# of 2026-08-27). Rho is a Go binary and does not link libcrypto; this affects -# only the base OS TLS stack and is not reachable from Rho's runtime. Re-add a -# base-image bump to remove this entry once Alpine 3.23 publishes OpenSSL -# 3.5.8-r0. -CVE-2026-14456 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7c66f9dd..00000000 --- a/Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -# Build stage -# Supply-chain hardening: both stages are pinned by digest so a mutable tag -# cannot silently change the build. -FROM golang:1.26.6-alpine@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder - -RUN apk upgrade --no-cache && \ - apk add --no-cache git ca-certificates tzdata - -WORKDIR /build - -# GrayCodeAI engine modules are published and pinned in go.mod at tagged or -# commit-pseudo versions. Pins whose repositories no longer exist resolve from -# the committed third_party/modproxy (copied in via COPY . . below); everything -# else resolves from the public proxy with direct-VCS fallback. The committed -# go.work (which references sibling checkouts ../) is excluded from the -# build context, so build in module mode (no go.work) against those pins. -ENV GOPROXY=file:///build/third_party/modproxy,https://proxy.golang.org,direct - -# Build-time provenance (passed by .github/workflows/docker.yml or `docker build -# --build-arg VERSION=... --build-arg COMMIT=... --build-arg BUILD_DATE=...`). -# Default to "dev"/"none"/"unknown" so plain `docker build .` still produces a -# runnable image — matching the cmd/rho/main.go ldflags fallbacks. -ARG VERSION=dev -ARG COMMIT=none -ARG BUILD_DATE=unknown - -COPY . . - -# Build against the engine versions pinned in go.mod via the module proxy. The -# committed go.work/go.work.sum (sibling-checkout based) are excluded by -# .dockerignore and must not be present for a module-mode build. -# -# main.Version / main.Commit / main.BuildDate are baked in from the ARGs above; -# this is the only correct source — `git describe` would always return empty -# because `.dockerignore` excludes `.git/` from the build context. -# -# The cache mounts persist the Go module and compile caches between CI runs -# (exported via cache-to: type=gha,mode=max), so the per-commit ldflags change -# only re-links the binary instead of cold-compiling the whole dependency tree. -# Requires BuildKit (default for Docker Desktop 23+ and every CI builder). -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - rm -f go.work go.work.sum && \ - CGO_ENABLED=0 GOOS=linux go build -trimpath \ - -ldflags="-s -w \ - -X main.Version=${VERSION} \ - -X main.Commit=${COMMIT} \ - -X main.BuildDate=${BUILD_DATE}" \ - -o rho ./cmd/rho - -# Runtime stage — Alpine (rho requires git + bash for workspace operations; distroless excluded) -FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b - -RUN apk upgrade --no-cache && \ - apk add --no-cache ca-certificates git bash tini && \ - adduser -D -u 1000 -h /home/rho rho - -COPY --from=builder /build/rho /usr/local/bin/rho -COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo - -USER rho -WORKDIR /workspace - -ENTRYPOINT ["tini", "--", "rho"] -CMD ["--help"] diff --git a/Dockerfile.daemon b/Dockerfile.daemon deleted file mode 100644 index 087f5ad4..00000000 --- a/Dockerfile.daemon +++ /dev/null @@ -1,57 +0,0 @@ -# Dockerfile for the Rho daemon (background HTTP server). -# The binary is identical to the CLI image — this Dockerfile just sets the -# daemon as the default entrypoint and exposes the daemon port. -# -# Build: docker build -f Dockerfile.daemon -t rho-daemon . -# Run: docker run -p 4590:4590 -e RHO_DAEMON_API_KEY=... rho-daemon -FROM golang:1.26.6-alpine@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder - -RUN apk upgrade --no-cache && \ - apk add --no-cache git ca-certificates tzdata - -WORKDIR /build - -# Module resolution is file-proxy-first (see Dockerfile); no GOPRIVATE override. -ENV GOPROXY=file:///build/third_party/modproxy,https://proxy.golang.org,direct - -ARG VERSION=dev -ARG COMMIT=none -ARG BUILD_DATE=unknown - -COPY . . - -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - rm -f go.work go.work.sum && \ - CGO_ENABLED=0 GOOS=linux go build -trimpath \ - -ldflags="-s -w \ - -X main.Version=${VERSION} \ - -X main.Commit=${COMMIT} \ - -X main.BuildDate=${BUILD_DATE}" \ - -o rho ./cmd/rho - -FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b - -RUN apk upgrade --no-cache && \ - apk add --no-cache ca-certificates git bash curl tini && \ - adduser -D -u 1000 -h /home/rho rho - -# Create state directory for daemon logs, API key, and audit log. -RUN mkdir -p /home/rho/.rho/state && \ - chown -R rho:rho /home/rho/.rho - -COPY --from=builder /build/rho /usr/local/bin/rho -COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo -COPY packaging/systemd/rho-daemon.service /etc/systemd/system/rho-daemon.service - -USER rho -WORKDIR /workspace - -EXPOSE 4590 - -# Health check probes the daemon's health endpoint. -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -ksf https://127.0.0.1:4590/v1/health || curl -sf http://127.0.0.1:4590/v1/health || exit 1 - -ENTRYPOINT ["tini", "--", "rho", "daemon", "start"] -CMD ["--host", "0.0.0.0", "--port", "4590"] diff --git a/Makefile b/Makefile index 74d4faba..2fd95598 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench boundaries build check-replace ci clean ecosystem-guard flux-client-guard flux-engine-guard manifest-guard peer-guard internal-layers-guard package-boundaries-guard release-parity cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench boundaries build check-replace ci clean ecosystem-guard feature-boundaries-guard flux-client-guard flux-engine-guard manifest-guard peer-guard internal-layers-guard package-boundaries-guard release-parity cover cover-new fmt help install lint lint-fix \ release security setup smoke path sync test test-10x test-live test-new test-race tidy version vet api-docs api-validate workspace check-replace: ## Fail if go.mod has local replace directives (run before tagging) @@ -139,7 +139,10 @@ internal-layers-guard: ## Enforce one-way dependencies across stable Rho interna package-boundaries-guard: ## Enforce AST/package-graph boundaries with file/line diagnostics. bash ./scripts/check-package-boundaries.sh -boundaries: manifest-guard check-replace ecosystem-guard flux-client-guard flux-engine-guard peer-guard internal-layers-guard package-boundaries-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). +feature-boundaries-guard: ## Prevent feature packages from importing delivery or sibling features. + bash ./scripts/check-feature-boundaries.sh + +boundaries: manifest-guard check-replace ecosystem-guard feature-boundaries-guard flux-client-guard flux-engine-guard peer-guard internal-layers-guard package-boundaries-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). release-parity: ## Verify every go.mod ecosystem version resolves to a reachable remote commit. bash ./scripts/check-module-release-parity.sh diff --git a/SECURITY.md b/SECURITY.md index 166bdc1a..207af0b8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -96,7 +96,7 @@ The following settings **cannot** be set by project-level config (stripped by - API keys (never stored in settings.json; use OS secret store via `/config`) The following settings **can** be set by project config (anything not stripped): -- `theme`, `autonomy`, `sandbox`, `max_budget_usd`, and other +- `theme`, `autonomy`, `max_budget_usd`, and other repository-local behavior ### Config merge precedence diff --git a/cmd/agent.go b/cmd/agent.go index 6bc58a37..aa32f471 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -58,6 +58,7 @@ func init() { agentCreateCmd.Flags().StringVarP(&agentCreateModel, "model", "m", "", "Model to use (empty = inherit)") agentListCmd.Flags().BoolVar(&agentListJSON, "json", false, "output agents as JSON") + agentRemoveCmd.Flags().Bool("yes", false, "confirm removing the agent") agentCmd.AddCommand(agentListCmd) agentCmd.AddCommand(agentCreateCmd) agentCmd.AddCommand(agentShowCmd) @@ -168,15 +169,19 @@ func runAgentShow(_ *cobra.Command, args []string) error { return nil } -func runAgentRemove(_ *cobra.Command, args []string) error { +func runAgentRemove(cmd *cobra.Command, args []string) error { a, err := agents.Get(args[0]) if err != nil { return err } - ok, err := confirmDestructive(fmt.Sprintf("Remove agent %q (%s)?", a.Name, a.FilePath)) - if err != nil { - return err + ok, _ := cmd.Flags().GetBool("yes") + if !ok { + var err error + ok, err = confirmDestructive(fmt.Sprintf("Remove agent %q (%s)?", a.Name, a.FilePath)) + if err != nil { + return err + } } if !ok { fmt.Printf("%s\n", auditTint("Cancelled.", textMuted)) diff --git a/cmd/autocomplete.go b/cmd/autocomplete.go index e73552c4..22ec4acd 100644 --- a/cmd/autocomplete.go +++ b/cmd/autocomplete.go @@ -434,7 +434,6 @@ func (ac *Autocompleter) completeFlags(prefix string) []Suggestion { {"--output-format", "Output format"}, {"--auto-commit", "Auto-commit changes"}, {"--watch", "Watch for file changes"}, - {"--vibe", "Vibe coding mode"}, {"--power", "Power level 1-10"}, {"--timeout", "Time budget"}, {"--session-id", "Session ID"}, diff --git a/cmd/autonomy_picker.go b/cmd/autonomy_picker.go index bf2a2598..8b69d22c 100644 --- a/cmd/autonomy_picker.go +++ b/cmd/autonomy_picker.go @@ -20,7 +20,7 @@ type autonomyPickerEntry struct { // allAutonomyTiers lists every tier in strictness order (most to least // cautious), for the picker only. This is intentionally separate from -// containerAutonomyTiers (the Ctrl+L cycle), which deliberately excludes +// autonomyTiers (the Ctrl+L cycle), which deliberately excludes // Supervised so repeated key-presses can't land you in max-friction mode by // accident — the picker is a deliberate selection, so Supervised is fine here. var allAutonomyTiers = []safety.AutonomyLevel{ @@ -35,22 +35,29 @@ var allAutonomyTiers = []safety.AutonomyLevel{ // trust tier directly, modeled on CommandPalette's interaction pattern // (arrow keys to navigate, Enter to select, Esc to dismiss, type to filter). type AutonomyPicker struct { - open bool - input textinput.Model - entries []autonomyPickerEntry - filtered []autonomyPickerEntry - sel int - width int + open bool + input textinput.Model + inputReady bool + entries []autonomyPickerEntry + filtered []autonomyPickerEntry + sel int + width int } -// NewAutonomyPicker creates a new autonomy tier picker. -func NewAutonomyPicker(width int) *AutonomyPicker { +func (ap *AutonomyPicker) ensureInput() { + if ap.inputReady { + return + } ti := textinput.New() ti.Placeholder = "Type to filter…" - ti.Focus() ti.CharLimit = 40 ti.SetWidth(40) + ap.input = ti + ap.inputReady = true +} +// NewAutonomyPicker creates a new autonomy tier picker. +func NewAutonomyPicker(width int) *AutonomyPicker { entries := make([]autonomyPickerEntry, 0, len(allAutonomyTiers)) for _, level := range allAutonomyTiers { entries = append(entries, autonomyPickerEntry{ @@ -60,16 +67,22 @@ func NewAutonomyPicker(width int) *AutonomyPicker { }) } - return &AutonomyPicker{ - input: ti, - width: width, - entries: entries, - filtered: entries, - } + ap := &AutonomyPicker{width: width, entries: entries, filtered: entries} + ap.ensureInput() + ap.input.Focus() + return ap } // Open opens the picker, pre-selecting the currently active tier. func (ap *AutonomyPicker) Open(current safety.AutonomyLevel) { + ap.ensureInput() + if len(ap.entries) == 0 { + for _, level := range allAutonomyTiers { + ap.entries = append(ap.entries, autonomyPickerEntry{ + Level: level, Name: autonomyTierName(level), Description: autonomyTierDescription(level), + }) + } + } ap.open = true ap.input.SetValue("") ap.input.Focus() @@ -171,16 +184,16 @@ func (ap *AutonomyPicker) Render(viewWidth int) string { } var b strings.Builder - b.WriteString(paletteTitleStyle.Render(" Autonomy")) - b.WriteString(paletteDimStyle.Render(" (↑↓ navigate · Enter select · Esc dismiss)")) + b.WriteString(paletteTitleStyle().Render(" Autonomy")) + b.WriteString(paletteDimStyle().Render(" (↑↓ navigate · Enter select · Esc dismiss)")) b.WriteString("\n\n") ap.input.SetWidth(boxWidth - 4) - b.WriteString(paletteInputStyle.Width(boxWidth - 2).Render(ap.input.View())) + b.WriteString(paletteInputStyle().Width(boxWidth - 2).Render(ap.input.View())) b.WriteString("\n\n") if len(ap.filtered) == 0 { - b.WriteString(paletteDimStyle.Render(" No matching tiers")) + b.WriteString(paletteDimStyle().Render(" No matching tiers")) } else { nameWidth := 0 for _, e := range ap.filtered { @@ -192,15 +205,15 @@ func (ap *AutonomyPicker) Render(viewWidth int) string { name := lipgloss.NewStyle().Bold(true).Foreground(autonomyTierColor(e.Level)).Render(padRight(e.Name, nameWidth)) line := " " + name + " " + e.Description if i == ap.sel { - b.WriteString(paletteSelStyle.Width(boxWidth).Render(" " + padRight(e.Name, nameWidth) + " " + e.Description)) + b.WriteString(paletteSelStyle().Width(boxWidth).Render(" " + padRight(e.Name, nameWidth) + " " + e.Description)) } else { - b.WriteString(paletteItemStyle.Width(boxWidth).Render(line)) + b.WriteString(paletteItemStyle().Width(boxWidth).Render(line)) } b.WriteString("\n") } } - return paletteBoxStyle.Width(boxWidth).Render(strings.TrimRight(b.String(), "\n")) + return paletteBoxStyle().Width(boxWidth).Render(strings.TrimRight(b.String(), "\n")) } func padRight(s string, width int) string { diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index 4e5f3c65..de7bea81 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -9,11 +9,11 @@ import ( lipgloss "charm.land/lipgloss/v2" ) -// Five container autonomy tiers (Scout → Builder → Operator → Autonomous → Always Ask). +// Five host-execution autonomy tiers (Scout → Builder → Operator → Autonomous → Always Ask). // Supervised ("Always Ask") is included in the Ctrl+L cycle but requires a // deliberate double-press to land on (see chat_update.go ctrl+l handling) so // repeated key-presses can't accidentally drop the user into max-friction mode. -var containerAutonomyTiers = []safety.AutonomyLevel{ +var autonomyTiers = []safety.AutonomyLevel{ safety.AutonomyBasic, safety.AutonomySemi, safety.AutonomyFull, @@ -21,7 +21,7 @@ var containerAutonomyTiers = []safety.AutonomyLevel{ safety.AutonomySupervised, } -var containerAutonomyTierNames = []string{ +var autonomyTierNames = []string{ "Scout", "Builder", "Operator", @@ -29,8 +29,8 @@ var containerAutonomyTierNames = []string{ "Always Ask", } -// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready. -const DefaultContainerAutonomy = safety.AutonomySemi +// DefaultAutonomy is the default host-execution permission tier. +const DefaultAutonomy = safety.AutonomySemi // yoloConfirmToken is the exact string a user must type (case-insensitive) to // confirm entry into YOLO ("Autonomous") unattended mode via the picker. @@ -40,16 +40,16 @@ func autonomyTierName(level safety.AutonomyLevel) string { if level == safety.AutonomySupervised { return "Always Ask" } - for i, l := range containerAutonomyTiers { + for i, l := range autonomyTiers { if l == level { - return containerAutonomyTierNames[i] + return autonomyTierNames[i] } } return "Builder" } func autonomyTierIndex(level safety.AutonomyLevel) int { - for i, l := range containerAutonomyTiers { + for i, l := range autonomyTiers { if l == level { return i } @@ -64,9 +64,9 @@ func autonomyTierIndex(level safety.AutonomyLevel) int { func nextAutonomyTier(level safety.AutonomyLevel) safety.AutonomyLevel { idx := autonomyTierIndex(level) for { - idx = (idx + 1) % len(containerAutonomyTiers) - if containerAutonomyTiers[idx] != safety.AutonomySupervised { - return containerAutonomyTiers[idx] + idx = (idx + 1) % len(autonomyTiers) + if autonomyTiers[idx] != safety.AutonomySupervised { + return autonomyTiers[idx] } } } @@ -74,7 +74,7 @@ func nextAutonomyTier(level safety.AutonomyLevel) safety.AutonomyLevel { // nextAutonomyTierIncludingSupervised returns the next tier with Supervised // included in the cycle (used after the user confirms via double-press). func nextAutonomyTierIncludingSupervised(level safety.AutonomyLevel) safety.AutonomyLevel { - return containerAutonomyTiers[(autonomyTierIndex(level)+1)%len(containerAutonomyTiers)] + return autonomyTiers[(autonomyTierIndex(level)+1)%len(autonomyTiers)] } // isSupervisedPending reports whether the next regular cycle step would land @@ -105,7 +105,7 @@ func autonomyTierDescription(level safety.AutonomyLevel) string { func autonomyTierColor(level safety.AutonomyLevel) color.Color { switch level { case safety.AutonomySupervised: - return lipgloss.Color("#9E9E9E") // matches textMuted's dark value + return textDisabled case safety.AutonomyBasic: return tierInspect case safety.AutonomySemi: diff --git a/cmd/block_conversation.go b/cmd/block_conversation.go index 82bbdba3..7aae84e8 100644 --- a/cmd/block_conversation.go +++ b/cmd/block_conversation.go @@ -31,26 +31,26 @@ const ( func BlockStyle(kind BlockKind) (titleStyle, contentStyle lipgloss.Style) { switch kind { case BlockToolUse: - return lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + return lipgloss.NewStyle().Foreground(infoSky).Bold(true), + lipgloss.NewStyle().Foreground(textMuted) case BlockThinking: - return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("248")).Italic(true) + return lipgloss.NewStyle().Foreground(costViolet).Bold(true), + lipgloss.NewStyle().Foreground(textMuted).Italic(true) case BlockDiff: - return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + return lipgloss.NewStyle().Foreground(successTeal).Bold(true), + lipgloss.NewStyle().Foreground(textPrimary) case BlockTest: - return lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + return lipgloss.NewStyle().Foreground(doneGreen).Bold(true), + lipgloss.NewStyle().Foreground(textPrimary) case BlockReview: - return lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + return lipgloss.NewStyle().Foreground(warnAmber).Bold(true), + lipgloss.NewStyle().Foreground(textPrimary) case BlockPlan: - return lipgloss.NewStyle().Foreground(lipgloss.Color("99")).Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + return lipgloss.NewStyle().Foreground(hudBorderPurple).Bold(true), + lipgloss.NewStyle().Foreground(textPrimary) default: return lipgloss.NewStyle().Bold(true), - lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + lipgloss.NewStyle().Foreground(textPrimary) } } @@ -89,7 +89,7 @@ func RenderBlockSection(block BlockSection, width int) string { borderStyle := lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, true, false). - BorderForeground(lipgloss.Color("240")). + BorderForeground(borderDim). Width(boxWidth) return borderStyle.Render(strings.TrimRight(b.String(), "\n")) diff --git a/cmd/chat.go b/cmd/chat.go index e1889524..a84810e5 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -31,8 +31,10 @@ import ( "github.com/GrayCodeAI/rho/internal/codegraph" rhoconfig "github.com/GrayCodeAI/rho/internal/config" "github.com/GrayCodeAI/rho/internal/engine" - "github.com/GrayCodeAI/rho/internal/feature/shellmode" - "github.com/GrayCodeAI/rho/internal/feature/taste" + chatfeature "github.com/GrayCodeAI/rho/internal/features/chat" + sessionfeature "github.com/GrayCodeAI/rho/internal/features/session" + "github.com/GrayCodeAI/rho/internal/features/shellmode" + "github.com/GrayCodeAI/rho/internal/features/taste" "github.com/GrayCodeAI/rho/internal/intelligence/repomap" "github.com/GrayCodeAI/rho/internal/plugin" "github.com/GrayCodeAI/rho/internal/session" @@ -41,8 +43,6 @@ import ( "github.com/GrayCodeAI/rho/internal/system/staleness" "github.com/GrayCodeAI/rho/internal/tool" "github.com/GrayCodeAI/rho/internal/ui/icons" - - "github.com/GrayCodeAI/rho/internal/conversationarc" ) // Types, styles, and model struct are in chat_model.go @@ -93,7 +93,7 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { if err != nil { return "", nil, err } - sess.LoadMessages(session.ToRuntimeMessages(saved.Messages)) + sess.LoadMessages(sessionfeature.Hydrate(saved).Runtime) if forkSessionFlag { if sessionIDFlag != "" { if err := session.ValidateID(sessionIDFlag); err != nil { @@ -176,9 +176,9 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon startup.EndPhase("newChatModel:prepareSession") // Conversation arc: durable per-session sidecar of goals/decisions/milestones. - arc, _ := conversationarc.Load(sessionArcDir(sid)) + arc, _ := sessionfeature.LoadArc(sid) if arc == nil { - arc = conversationarc.New() + arc = sessionfeature.NewArc() } sess.SetArc(arc) @@ -203,11 +203,11 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon now := time.Now() // Create a cancel function for background goroutines that gets called on quit. _, bgCancel := context.WithCancel(context.Background()) - m := chatModel{input: ta, configInput: ci, spinner: sp, viewport: vp, session: sess, registry: registry, settings: settings, ref: ref, sessionID: sid, partial: &strings.Builder{}, spinnerVerb: spinnerVerbs[rand.Intn(len(spinnerVerbs))], width: initWidth, height: initHeight, historyIdx: 0, autoScroll: true, streamFollow: true, uiFocus: focusPrompt, startedAt: now, sessionStartedAt: now, activeSkills: make(map[string]plugin.SmartSkill), toolResultExpanded: make(map[int]bool), bgCancel: bgCancel} // #nosec G404 -- non-cryptographic use (random spinner verb selection) + m := chatModel{input: ta, configInput: ci, spinner: sp, viewport: vp, session: sess, registry: registry, settings: settings, ref: ref, sessionID: sid, partial: &strings.Builder{}, spinnerVerb: spinnerVerbs[rand.Intn(len(spinnerVerbs))], width: initWidth, height: initHeight, history: chatfeature.NewHistory(nil, chatfeature.DefaultHistoryLimit), autoScroll: true, streamFollow: true, uiFocus: focusPrompt, startedAt: now, sessionStartedAt: now, activeSkills: make(map[string]plugin.SmartSkill), toolResultExpanded: make(map[int]bool), bgCancel: bgCancel, mascotEnabled: rhoMascotEnabled()} // #nosec G404 -- non-cryptographic use: non-cryptographic spinner selection applyLiveModelMetadata(sess, effectiveProvider, effectiveModel) startup.MarkPhase("newChatModel:commandPalette") - m.commandPalette = NewCommandPalette(initWidth) + m.commandPalette = NewCommandPaletteWithRuntime(initWidth, m.pluginRuntime) startup.EndPhase("newChatModel:commandPalette") // Give the footer an immediate cwd value without paying for a git probe @@ -311,7 +311,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon quickSnapshot := welcomeStatusSnapshot{} m.welcomeSetupState = quickSnapshot.setup m.welcomeAgentsOK = quickSnapshot.agentsOK - m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), 0, initWidth, initHeight, quickSnapshot, "") + m.welcomeCache = buildWelcomeMessageWithSnapshotAndMascot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), 0, initWidth, initHeight, quickSnapshot, "", m.mascotEnabled) m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) // First-session control-plane tip (skip when resuming history or when quiet env var is set). if saved == nil && os.Getenv("RHO_QUIET_START") == "" && os.Getenv("RHO_SUPPRESS_HINTS") == "" && os.Getenv("RHO_QUIET") == "" { @@ -329,6 +329,16 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon sess.SetApproval(&engine.ApprovalGate{ Enabled: true, MaxAutoApprove: safety.AutonomySemi, + ConfirmFn: func(req engine.ApprovalRequest) engine.ApprovalResponse { + response := make(chan engine.ApprovalResponse, 1) + ref.Send(approvalAskMsg{req: req, response: response}) + select { + case answer := <-response: + return answer + case <-time.After(5 * time.Minute): + return engine.ApprovalReject + } + }, }) // Wire ask_user tool (5-minute timeout matches permission prompts). @@ -357,16 +367,13 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings rhocon }) if saved != nil { - for _, sm := range saved.Messages { - if sm.Role == "user" || sm.Role == "assistant" { - m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) - } + for _, message := range sessionfeature.Hydrate(saved).Display { + m.messages = append(m.messages, displayMsg{role: message.Role, content: message.Content}) } } startup.MarkPhase("newChatModel:history") - m.history = loadInputHistory() - m.historyIdx = len(m.history) + m.history.SetEntries(loadInputHistory()) startup.EndPhase("newChatModel:history") startup.MarkPhase("newChatModel:first-paint") @@ -529,11 +536,11 @@ func (m *chatModel) refreshInputPlaceholder() { } } -// stopContainer is retained as a no-op: container execution has been removed. -func (m *chatModel) stopContainer() {} - func (m chatModel) Init() tea.Cmd { cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd(), eyeBlinkTickCmd()} + if m.mascotEnabled { + cmds = append(cmds, emitRhoMascotCmd()) + } if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" { cmds = append(cmds, fetchModelsAsync(gw)) if isXiaomiMimoProvider(gw) { @@ -591,7 +598,6 @@ func runChat() error { if active.session != nil && active.sessionID != "" { active.saveSession() } - active.stopContainer() } defer func() { panicSaveFn = nil }() @@ -669,9 +675,7 @@ func runChat() error { } m.messages = append(m.messages, displayMsg{role: "user", content: promptFlag}) m.session.AddUser(promptFlag) - m.turnSawThinking = false - m.turnHadAssistantOutput = false - m.turnHadToolActivity = false + m.turn.Reset() m.waiting = true } @@ -711,12 +715,12 @@ func runChat() error { ctx, cancel := context.WithCancel(context.Background()) m.cancel = cancel go func() { - ch, streamErr := sess.Stream(ctx) + streamErr := chatfeature.RunStream(ctx, sess, func(event engine.StreamEvent) { + dispatchStreamEvent(ref, event) + }) if streamErr != nil { p.Send(streamErrMsg{err: streamErr}) - return } - pumpStreamEvents(ref, ch) }() } diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 695f3c48..db33636f 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "sort" @@ -11,7 +12,10 @@ import ( tea "charm.land/bubbletea/v2" + commandfeature "github.com/GrayCodeAI/rho/internal/features/commands" + parallelfeature "github.com/GrayCodeAI/rho/internal/features/parallel" "github.com/GrayCodeAI/rho/internal/multiagent/parallel" + "github.com/GrayCodeAI/rho/internal/plugin" "github.com/GrayCodeAI/rho/internal/ui/icons" ) @@ -22,16 +26,59 @@ var ( slashCmdMutex sync.Mutex ) -// slashCommands returns the list of all slash commands, built once and cached. +// slashCommands returns the static slash-command list. Runtime plugin commands +// are added by slashCommandsFor so the cache cannot become stale when plugins +// load or reload during a session. func slashCommands() []string { + return slashCommandsFor(nil) +} + +func slashCommandsFor(runtime *plugin.Runtime) []string { slashCmdMutex.Lock() - defer slashCmdMutex.Unlock() - if slashCmdCacheBuilt { - return slashCmdCache + if !slashCmdCacheBuilt { + builtIns := commandfeature.BuiltInNames() + seen := make(map[string]bool, len(builtIns)+subcommandRegistry.Size()) + out := make([]string, 0, len(builtIns)+subcommandRegistry.Size()) + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + if !strings.HasPrefix(name, "/") { + name = "/" + name + } + if seen[name] { + return + } + seen[name] = true + out = append(out, name) + } + for _, name := range builtIns { + add(name) + } + for alias := range commandfeature.BuiltInAliases() { + add(alias) + } + for _, cmd := range subcommandRegistry.All() { + add(cmd.Name()) + for _, alias := range cmd.Aliases() { + add(alias) + } + } + sort.Strings(out) + slashCmdCache = out + slashCmdCacheBuilt = true } + static := append([]string(nil), slashCmdCache...) + slashCmdMutex.Unlock() - seen := make(map[string]bool, len(allSlashCommands)+subcommandRegistry.Size()) - out := make([]string, 0, len(allSlashCommands)+subcommandRegistry.Size()) + if runtime == nil { + return static + } + seen := make(map[string]struct{}, len(static)) + for _, name := range static { + seen[name] = struct{}{} + } add := func(name string) { name = strings.TrimSpace(name) if name == "" { @@ -40,48 +87,37 @@ func slashCommands() []string { if !strings.HasPrefix(name, "/") { name = "/" + name } - if seen[name] { + if _, exists := seen[name]; exists { return } - seen[name] = true - out = append(out, name) + seen[name] = struct{}{} + static = append(static, name) } - for _, name := range allSlashCommands { - add(name) + for _, cmd := range runtime.CommandList() { + add(cmd.Name) } - for _, cmd := range subcommandRegistry.All() { - add(cmd.Name()) - for _, alias := range cmd.Aliases() { - add(alias) - } - } - sort.Strings(out) - slashCmdCache = out - slashCmdCacheBuilt = true - return out + sort.Strings(static) + return static } -// ResetSlashCache resets the cached slash commands (useful for testing) -func ResetSlashCache() { - slashCmdMutex.Lock() - defer slashCmdMutex.Unlock() - slashCmdCacheBuilt = false - slashCmdCache = nil -} - -var allSlashCommands = []string{ - "/add", "/add-dir", "/agents", "/agents-init", "/audit", "/auto-commit", "/autonomy", "/branch", "/branch-agent", "/branches", "/bughunter", "/clean", "/clear", - "/check", "/color", "/commit", "/compact", "/compress", "/config", "/context", "/council", "/design", - "/copy", "/cost", "/cron", "/ctx", "/diff", "/doctor", "/drop", "/effort", "/env", "/exit", "/explain", - "/export", "/fast", "/feedback", "/files", "/focus", "/follow", "/fork", "/help", "/history", "/home", "/hooks", "/init", - "/integrity", "/keybindings", "/learn", "/lint", "/login", "/loop", "/mcp", "/memory", "/metrics", "/model", "/new", - "/hunt", "/insights", "/mode", "/output-style", "/party", "/pin", "/plugin", "/plugins", - "/power", "/pr-comments", "/provider-status", "/quit", "/recipe", "/recover", "/reflect", "/refresh-model-catalog", "/release-notes", - "/image", "/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind", - "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", - "/mouse", "/select", "/start", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/trust", "/ultrareview", "/undo", "/upgrade", "/usage", - "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", - "/scroll-speed", "/scroll-invert", "/scroll-mode", "/terminal-setup", "/pager-config", "/prompt-queue", +func slashDescriptionsFor(runtime *plugin.Runtime) map[string]string { + descriptions := make(map[string]string, len(slashDescriptions)) + for name, description := range slashDescriptions { + descriptions[name] = description + } + if runtime != nil { + for _, cmd := range runtime.CommandList() { + name := strings.TrimSpace(cmd.Name) + if name == "" { + continue + } + if !strings.HasPrefix(name, "/") { + name = "/" + name + } + descriptions[name] = cmd.Description + } + } + return descriptions } func (m *chatModel) slashSuggestionsFor(input string) []string { @@ -90,7 +126,7 @@ func (m *chatModel) slashSuggestionsFor(input string) []string { } m.slashSugInput = input m.slashSugCachedGen = m.slashSugGen - m.slashSugCache = slashSuggestions(input) + m.slashSugCache = slashSuggestionsFor(input, m.pluginRuntime) return m.slashSugCache } @@ -160,225 +196,32 @@ func (m *chatModel) syncInputLayout() bool { } func slashAliases() map[string]string { - return map[string]string{ - "/themes": "/theme", - } + return commandfeature.BuiltInAliases() } -// #nosec G101 -- command descriptions are static UI strings, not credentials. -var slashDescriptions = map[string]string{ - "/add": "Add files to conversation context", - "/add-dir": "Add a directory to context", - "/agents": "List active agents", - "/agents-init": "Generate AGENTS.md from project template", - "/audit": "Show tool audit summary", - "/autonomy": "Autonomy Center for trust tier and rules", - "/branch": "Show git branch info", - "/btw": "Side note without triggering a response", - "/bughunter": "Hunt for bugs in the codebase", - "/check": "Review diff, find issues, auto-fix safe ones, verify before ship", - "/design": "Build or improve UI — use /design screenshot|system|component|regress for advanced modes", - "/hunt": "Diagnose root cause of errors before fixing (Waza method)", - "/think": "Turn rough idea into approved plan before coding (Waza method)", - "/clean": "Delete old sessions", - "/clear": "Clear conversation", - "/color": "Change agent color", - "/commit": "Auto-commit changes with AI message", - "/compact": "Compress conversation to save tokens", - "/compress": "Compress old sessions", - "/config": "Open settings panel", - "/context": "Show current context", - "/copy": "Copy chat or input to clipboard (/copy all|input|last|assistant)", - "/cost": "Show token usage and cost", - "/council": "Run LLM Council (multi-model consensus)", - "/diff": "Show git diff (preview changes)", - "/doctor": "Run diagnostics (build, test, lint)", - "/drop": "Remove file from context", - "/effort": "Set reasoning effort level", - "/env": "Show environment info", - "/exit": "Save and exit", - "/explain": "Swift code back to the commit that created it", - "/export": "Export session", - "/follow": "Toggle stream follow (auto-scroll)", - "/home": "Jump to top of chat and welcome header", - "/feedback": "Submit feedback about rho", - "/fast": "Toggle fast mode", - "/files": "Show modified files", - "/focus": "Narrow agent attention to specific files/dirs", - "/fork": "Fork conversation to try a different approach", - "/branches": "List or switch conversation branches", - "/help": "Show all commands", - "/history": "List saved sessions", - "/hooks": "Show configured hooks", - "/init": "Analyze project structure", - "/integrity": "Validate session integrity", - "/lint": "Run linter, add issues to context", - "/login": "Authenticate a provider (opens the config panel)", - "/loop": "Schedule recurring command", - "/mcp": "Show MCP server status", - "/memory": "Show AGENTS.md project instructions", - "/metrics": "Show session metrics", - "/model": "Browse/switch models; press t to toggle Think", - "/new": "Start a fresh session", - "/pin": "Pin last N messages to protect from compaction", - "/parallel": "Run N agents in parallel on independent tasks", - "/plugins": "List installed plugins", - "/power": "Set power level (1-10)", - "/quit": "Save and exit", - "/recover": "Scan for interrupted sessions and resume", - "/refactor": "Agent-driven refactoring: dedup, dead code, lint fixes", - "/resume": "Resume a saved session", - "/retry": "Redo last message", - "/review": "Code review for bugs and issues", - "/rewind": "Undo last exchange", - "/run": "Run command, add output to context", - "/search": "Search across sessions", - "/select": "Pause TUI for native text selection", - "/mouse": "Toggle TUI mouse capture for native click-drag copy", - "/snapshot": "Manage file snapshots: list, restore , diff ", - "/stale": "Show stale rules that may need updating or removal", - "/security-review": "Security audit", - "/skills": "List skills or manage: search, install, trending, info, remove, update, feedback, publish, audit", - "/learn": "LLM-powered skill advisor (/learn deep for source analysis)", - "/stats": "Show analytics stats", - "/status": "Show session info (mode, trust, cost)", - "/start": "Guided setup: trust, mode, branch, first tasks", - "/trust": "Folder trust status / add / remove", - "/branch-agent": "Create rho/agent-* branch if on main/master", - "/auto-commit": "Toggle git auto-commit after Write/Edit (on|off)", - "/summary": "Summarize the session", - "/tasks": "Show task list", - "/test": "Run tests, add failures to context", - "/tokens": "Show token estimate", - "/tools": "List enabled tools", - "/undo": "Undo the most recent file change", - "/usage": "Show cost summary", - "/version": "Show rho version", - "/vim": "Toggle vim mode", - "/welcome": "Re-print the welcome header", - "/ecosystem": "Show flux and token-engine integration status", - "/path": "Developer path readiness (setup, security)", - "/cron": "Show scheduled jobs", - "/keybindings": "Show keyboard shortcuts", - "/output-style": "Change output style", - "/plugin": "Manage plugins", - "/pr-comments": "Address PR comments", - "/provider-status": "Show provider info", - "/release-notes": "Draft release notes", - "/reload-plugins": "Reload all plugins", - "/remote-env": "Show remote environment", - "/rename": "Rename current session", - "/render": "Export repo as CXML to clipboard", - "/research": "Start autonomous research loop", - "/session": "Show session info", - "/share": "Share session", - "/statusline": "Show status line info", - "/tag": "Tag current session", - "/taste": "Show learned taste preferences", - "/theme": "Change visual theme (opens picker)", - "/themes": "List all available themes", - "/think-back": "Review reasoning decisions", - "/thinkback": "Review reasoning decisions", - "/thinkback-play": "Replay reasoning path", - "/upgrade": "Check for updates", - "/vibe": "Start vibe coding loop", - "/voice": "Toggle voice input", - "/ctx": "Show conversation context visualization", - "/insights": "Generate session patterns and improvements report", - "/spec": "Start the spec-driven workflow (gates Write/Edit/Bash until approved)", - "/ultrareview": "Deep adversarial code review", - "/scroll-speed": "Set scroll speed (1-100)", - "/scroll-invert": "Toggle scroll direction inversion", - "/scroll-mode": "Switch scroll behavior mode", - "/terminal-setup": "Configure terminal capabilities", - "/pager-config": "Configure pager for long output", - "/prompt-queue": "Manage queued prompts", - "/brainstorm": "Brainstorm ideas with multi-model council", - "/checkpoint": "Create a named checkpoint of current state", - "/dream": "Enter dream/imagining mode for creative tasks", - "/away": "Set away status with auto-reply message", - "/investigate": "Deep-dive investigation of an issue", - "/refresh-model-catalog": "Refresh the model catalog from providers", - "/image": "Generate or process images", - "/recipe": "Run a saved recipe (command template)", - "/soul": "Show or update rho's personality/soul", - "/mode": "Switch interaction mode", - "/party": "Start a multi-agent party session", -} +var slashDescriptions = commandfeature.BuiltInDescriptions() func slashSuggestions(input string) []string { - v := strings.TrimSpace(input) - if !strings.HasPrefix(v, "/") || strings.Contains(v, " ") { - return nil - } - v = strings.ToLower(v) - var out []string - seen := map[string]bool{} - for _, c := range slashCommands() { - c = strings.ToLower(c) - if strings.HasPrefix(c, v) { - seen[c] = true - desc := slashDescriptions[c] - if desc != "" { - out = append(out, c+" "+desc) - } else { - out = append(out, c) - } - } - } - aliases := slashAliases() - aliasNames := make([]string, 0, len(aliases)) - for alias := range aliases { - aliasNames = append(aliasNames, alias) - } - sort.Strings(aliasNames) - for _, alias := range aliasNames { - target := aliases[alias] - alias = strings.ToLower(alias) - if strings.HasPrefix(alias, v) && !seen[target] { - seen[alias] = true - out = append(out, alias+" → "+target) - } - } - if len(out) == 1 && strings.HasPrefix(out[0], v+" ") && strings.Fields(out[0])[0] == v { - return nil - } - return out + return slashSuggestionsFor(input, nil) +} + +func slashSuggestionsFor(input string, runtime *plugin.Runtime) []string { + return commandfeature.Suggestions(input, slashCommandsFor(runtime), slashDescriptionsFor(runtime), slashAliases()) } func applySlashSuggestion(input string) string { - choice := strings.TrimSpace(input) - if before, _, ok := strings.Cut(choice, " → "); ok { - choice = before - } - parts := strings.Fields(choice) - if len(parts) > 0 { - choice = parts[0] - } - if target, ok := slashAliases()[choice]; ok { - choice = target - } - return choice + " " + return commandfeature.ApplySuggestion(input, slashAliases()) } func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { - trimmed := strings.TrimSpace(text) - lower := strings.ToLower(trimmed) - if lower == "?" || lower == "? help" || lower == "?help" || lower == "help" { - text = "/help" - } else if strings.HasPrefix(lower, "? ") { - text = "/help " + strings.TrimPrefix(trimmed, "? ") - } - - parts := strings.Fields(text) + parsed := commandfeature.Parse(text) + resolved := commandfeature.Resolve(parsed, slashAliases()) + text = resolved.Parsed.Text + parts := resolved.Parsed.Parts if len(parts) == 0 { return m, nil } - rawCmd := parts[0] - cmd := rawCmd - if strings.HasPrefix(cmd, "/") { - cmd = strings.ToLower(cmd) - } + cmd := resolved.Command // Track the last command for context-aware tips and recent-command history. if strings.HasPrefix(cmd, "/") { @@ -387,7 +230,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { } // Namespaced skill invocation: /vendor:skill-name [args...] - if strings.Contains(cmd, ":") && strings.HasPrefix(cmd, "/") { + if resolved.Namespaced { return m.handleNamespacedSkill(cmd, text) } @@ -395,12 +238,8 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { // chat_subcommand_.go files. Each registers itself in // init(); we look up by the slash name minus the leading "/". // If the registry has a handler, dispatch and return. - if strings.HasPrefix(cmd, "/") { - if aliasTarget, ok := slashAliases()[cmd]; ok { - cmd = aliasTarget - } - name := strings.TrimPrefix(cmd, "/") - if sub, ok := subcommandRegistry.Lookup(name); ok { + if resolved.IsSlash { + if sub, ok := subcommandRegistry.Lookup(resolved.Name); ok { args := parts[1:] return sub.Handle(m, args, text) } @@ -418,7 +257,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { } // "Did you mean?" — fuzzy-match against known slash commands so a typo // like /commmit suggests /commit instead of just saying "unknown". - suggestion := suggestCommand(cmd) + suggestion := suggestCommandFor(cmd, m.pluginRuntime) if suggestion != "" { m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unknown command: %s — did you mean %s?\nType /help for all commands.", cmd, suggestion)}) } else { @@ -427,108 +266,25 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { return m, nil } -// suggestCommand finds the closest known slash command to a mistyped one -// using edit distance (Levenshtein). Returns the best match if it is within -// a plausible typo threshold, or "" if nothing is close enough to recommend. -func suggestCommand(typo string) string { - if len(typo) < 2 { - return "" - } - clean := strings.ToLower(strings.TrimPrefix(typo, "/")) - if clean == "" { - return "" - } - best := "" - bestDist := 999 - for _, cmd := range slashCommands() { - target := strings.ToLower(strings.TrimPrefix(cmd, "/")) - d := levenshtein(clean, target) - if d < bestDist { - bestDist = d - best = cmd - } - } - if best == "" { - return "" - } - // Threshold: distance must be small relative to the command length. - // Allows 1 edit for short commands (<=5 chars), 2 for longer ones. - target := strings.ToLower(strings.TrimPrefix(best, "/")) - maxDist := 1 - if len(target) > 5 { - maxDist = 2 - } - // Never suggest when the input is longer than the target by more than - // maxDist — that's not a typo, it's a different word. - if len(clean) > len(target)+maxDist { - return "" - } - if bestDist <= maxDist && bestDist > 0 { - return best - } - return "" -} - -// levenshtein computes the edit distance between two strings using the -// classic Wagner–Fischer algorithm with O(min(m,n)) space. -func levenshtein(a, b string) int { - if a == b { - return 0 - } - if len(a) == 0 { - return len(b) - } - if len(b) == 0 { - return len(a) - } - // Ensure b is the shorter string for O(min(m,n)) space. - if len(b) > len(a) { - a, b = b, a - } - prev := make([]int, len(b)+1) - curr := make([]int, len(b)+1) - for j := 0; j <= len(b); j++ { - prev[j] = j - } - for i := 1; i <= len(a); i++ { - curr[0] = i - for j := 1; j <= len(b); j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min(prev[j]+1, min(curr[j-1]+1, prev[j-1]+cost)) - } - prev, curr = curr, prev - } - return prev[len(b)] +func suggestCommandFor(typo string, runtime *plugin.Runtime) string { + return commandfeature.SuggestTypo(typo, slashCommandsFor(runtime)) } // handleParallelCommand spawns multiple agents in parallel on independent tasks. // Usage: /parallel | | ... func (m *chatModel) handleParallelCommand(parts []string, text string) (tea.Model, tea.Cmd) { - if len(parts) < 3 { - m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /parallel | | ...\nExample: /parallel 3 Fix auth bug | Add logging | Update tests"}) - return m, nil - } - - // Parse worker count - var workers int - if _, err := fmt.Sscanf(parts[1], "%d", &workers); err != nil || workers < 1 || workers > 8 { - m.messages = append(m.messages, displayMsg{role: "error", content: "Worker count must be 1-8"}) - return m, nil - } - - // Parse tasks (separated by |) - taskStr := strings.Join(parts[2:], " ") - taskDescs := strings.Split(taskStr, "|") - for i := range taskDescs { - taskDescs[i] = strings.TrimSpace(taskDescs[i]) - } - if len(taskDescs) < 2 { - m.messages = append(m.messages, displayMsg{role: "error", content: "Need at least 2 tasks separated by |"}) + request, err := parallelfeature.ParseRequest(parts) + if err != nil { + role := "error" + var parseErr *parallelfeature.ParseError + if errors.As(err, &parseErr) && parseErr.Usage { + role = "system" + } + m.messages = append(m.messages, displayMsg{role: role, content: err.Error()}) return m, nil } + workers := request.Workers + taskDescs := request.Tasks // Get repo root for worktree pool cwd, _ := os.Getwd() diff --git a/cmd/chat_commands_config.go b/cmd/chat_commands_config.go index ae47a2aa..4db437b8 100644 --- a/cmd/chat_commands_config.go +++ b/cmd/chat_commands_config.go @@ -7,18 +7,25 @@ import ( tea "charm.land/bubbletea/v2" rhoconfig "github.com/GrayCodeAI/rho/internal/config" + configfeature "github.com/GrayCodeAI/rho/internal/features/config" ) -// handleConfigCommand handles the /config command and all its subcommands. +// handleConfigCommand handles /config policy through the feature parser and +// keeps only persistence, session synchronization, and TUI transitions here. func (m *chatModel) handleConfigCommand(parts []string, text string) (tea.Model, tea.Cmd) { - if len(parts) >= 3 && parts[1] == "provider" { - value := strings.TrimSpace(strings.Join(parts[2:], " ")) - if err := rhoconfig.SetGlobalSetting("provider", value); err != nil { + command, err := configfeature.ParseCommand(parts) + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) + return m, nil + } + + switch command.Action { + case configfeature.ActionProvider: + if err := rhoconfig.SetGlobalSetting("provider", command.Value); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } m.syncSessionSelection() - // Use cached model or set first from cache modelCacheMu.RLock() cached, cacheHit := modelCache[m.session.Provider()] modelCacheMu.RUnlock() @@ -26,24 +33,23 @@ func (m *chatModel) handleConfigCommand(parts []string, text string) (tea.Model, m.session.SetModel(cached[0].ID) _ = rhoconfig.SetGlobalSetting("model", cached[0].ID) } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Provider set to: %s\nModel: %s\nSaved in flux (provider.json).", value, m.session.Model())}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Provider set to: %s\nModel: %s\nSaved in flux (provider.json).", command.Value, m.session.Model())}) return m, nil - } - if len(parts) >= 3 && parts[1] == "model" { - value := strings.TrimSpace(strings.Join(parts[2:], " ")) + + case configfeature.ActionModel: + value := command.Value known := configModelChoices(m.configModelOptions, false) if len(known) > 0 { found := false - for i, k := range known { - if strings.EqualFold(k, value) || strings.EqualFold(m.configModelOptions[i].ID, value) { + for i, name := range known { + if strings.EqualFold(name, value) || strings.EqualFold(m.configModelOptions[i].ID, value) { value = m.configModelOptions[i].ID found = true break } } if !found { - hint := "Unknown model: " + value + "\nUse /model to browse available models." - m.messages = append(m.messages, displayMsg{role: "error", content: hint}) + m.messages = append(m.messages, displayMsg{role: "error", content: "Unknown model: " + value + "\nUse /model to browse available models."}) return m, nil } } @@ -54,60 +60,51 @@ func (m *chatModel) handleConfigCommand(parts []string, text string) (tea.Model, m.syncSessionSelection() m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model switched to: %s\nSaved in flux (provider.json).", m.session.Model())}) return m, nil - } - if len(parts) >= 2 && parts[1] == "keys" { + + case configfeature.ActionKeys: m.messages = append(m.messages, displayMsg{role: "system", content: apiKeyConfigSummary()}) return m, nil - } - if len(parts) >= 3 && parts[1] == "key" && parts[2] == "remove" { - if len(parts) > 3 { - m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /config key remove"}) - return m, nil - } + + case configfeature.ActionRemoveKey: return m.openConfigRemoveKeyPanel() - } - if len(parts) >= 3 && parts[1] == "get" { + + case configfeature.ActionGet: settings, err := loadEffectiveSettings() if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } - value, ok := rhoconfig.SettingValue(settings, parts[2]) + value, ok := rhoconfig.SettingValue(settings, command.Key) if !ok { - m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unsupported setting key %q", parts[2])}) + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Unsupported setting key %q", command.Key)}) return m, nil } if strings.TrimSpace(value) == "" { value = "(empty)" } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s = %s", parts[2], value)}) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s = %s", command.Key, value)}) return m, nil - } - if len(parts) >= 4 && parts[1] == "set" { - key := parts[2] - value := strings.TrimSpace(strings.Join(parts[3:], " ")) - if err := rhoconfig.SetGlobalSetting(key, value); err != nil { + + case configfeature.ActionSet: + if err := rhoconfig.SetGlobalSetting(command.Key, command.Value); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } - // Apply common runtime keys immediately. - normalizedKey := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", ""), "_", "")) - switch normalizedKey { - case "model": - m.syncSessionSelection() - case "provider": + switch configfeature.NormalizeKey(command.Key) { + case "model", "provider": m.syncSessionSelection() } - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Updated %s = %s", key, value)}) + m.messages = append(m.messages, displayMsg{role: "system", content: configfeature.UpdatedMessage(command.Key, command.Value)}) return m, nil } + settings, err := loadEffectiveSettings() if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } m.settings = settings - next, cmd := m.openConfigPanel() + next, teaCmd := m.openConfigPanel() *m = next - return m, cmd + return m, teaCmd } diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index fbc33d83..bd8ebac4 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -3,16 +3,13 @@ package cmd import ( "fmt" "math/rand" - "os" - "path/filepath" - "strconv" - "strings" "time" tea "charm.land/bubbletea/v2" + sessionfeature "github.com/GrayCodeAI/rho/internal/features/session" + workspacefeature "github.com/GrayCodeAI/rho/internal/features/workspace" "github.com/GrayCodeAI/rho/internal/session" - "github.com/GrayCodeAI/rho/internal/storage" ) type sessionSaveResultMsg struct { @@ -21,21 +18,15 @@ type sessionSaveResultMsg struct { err error } -// sessionArcDir returns the per-session directory holding the conversation-arc -// sidecar (.arc.json). -func sessionArcDir(id string) string { - return filepath.Join(storage.SessionsDir(), id) -} - // saveSession persists the current session to disk. func (m *chatModel) saveSession() { raw := m.session.RawMessages() if len(raw) == 0 { return } - err := session.Save(&session.Session{ + err := sessionfeature.Save(sessionfeature.Snapshot{ ID: m.sessionID, Model: m.session.Model(), Provider: m.session.Provider(), - Messages: session.FromRuntimeMessages(raw), CreatedAt: time.Now(), + Messages: raw, Created: time.Now(), Arc: m.session.Arc(), }) // On successful save, WAL is no longer needed (session file has everything) if err == nil && m.wal != nil { @@ -47,10 +38,6 @@ func (m *chatModel) saveSession() { } else if err != nil { m.recordWALError(err) } - // Conversation arc sidecar (best-effort, only when it has content). - if arc := m.session.Arc(); arc != nil && !arc.IsEmpty() { - _ = arc.Save(sessionArcDir(m.sessionID)) - } } // saveSessionCmd returns a background tea.Cmd that persists the session. It @@ -67,57 +54,38 @@ func (m *chatModel) saveSessionCmd() tea.Cmd { return nil } id, modelName, provider := m.sessionID, m.session.Model(), m.session.Provider() - msgs := session.FromRuntimeMessages(raw) createdAt := time.Now() seq := m.walSeq arc := m.session.Arc() return func() tea.Msg { - err := session.Save(&session.Session{ + err := sessionfeature.Save(sessionfeature.Snapshot{ ID: id, Model: modelName, Provider: provider, - Messages: msgs, CreatedAt: createdAt, + Messages: raw, Created: createdAt, Arc: arc, }) - if arc != nil && !arc.IsEmpty() { - _ = arc.Save(sessionArcDir(id)) - } return sessionSaveResultMsg{id: id, seq: seq, err: err} } } func formatQuitResumeMessage(sessionID string) string { - if strings.TrimSpace(sessionID) == "" { - return "Thank you for using Rho!\n" - } - return fmt.Sprintf("Thank you for using Rho!\n\nTo resume this session, run: rho --resume %s\n", sessionID) + return sessionfeature.QuitResumeMessage(sessionID) } // handleSessionCommand dispatches session-management slash commands. func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string) (tea.Model, tea.Cmd) { switch cmd { case "/quit", "/exit": - // Re-enable system sleep if it was prevented, then use the canonical - // quit sequence (cancel stream → save → stop watcher/parallel/bg → - // stop container) rather than a hand-rolled duplicate that previously - // missed cancelling the in-flight stream. - if m.sleepCancel != nil { - m.sleepCancel() - m.sleepCancel = nil - } + // Re-enable system sleep before the canonical quit sequence. + sessionfeature.StopBackgroundWork(sessionfeature.CleanupHooks{SleepCancel: m.sleepCancel}) + m.sleepCancel = nil return m.quitModel() case "/clear": if m.manualCompacting { return m.cancelManualCompact("Compaction cancelled.") } - // Cancel any running /loop goroutine. - if m.loopCancel != nil { - m.loopCancel() - m.loopCancel = nil - } - // Cancel any running /parallel agents. - if m.parallelCancel != nil { - m.parallelCancel() - m.parallelCancel = nil - } + sessionfeature.StopBackgroundWork(sessionfeature.CleanupHooks{LoopCancel: m.loopCancel, ParallelCancel: m.parallelCancel}) + m.loopCancel = nil + m.parallelCancel = nil m.messages = []displayMsg{{role: "system", content: "Conversation cleared."}} m.invalidateViewportCache() m.viewDirty = true @@ -137,56 +105,36 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string return m.startManualCompact() case "/diff": - stat, statErr := gitOutput("diff", "--stat") - diff, diffErr := gitOutput("diff") - if strings.TrimSpace(diff) == "" { - stat, statErr = gitOutput("diff", "--cached", "--stat") - diff, diffErr = gitOutput("diff", "--cached") - } - // Report git errors instead of silently showing "No changes detected". - if statErr != nil || diffErr != nil { - errMsg := "git diff failed" - if statErr != nil { - errMsg = statErr.Error() - } else if diffErr != nil { - errMsg = diffErr.Error() - } - m.messages = append(m.messages, displayMsg{role: "error", content: errMsg}) + output, err := workspacefeature.DiffReport() + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } - if strings.TrimSpace(diff) == "" { + if output == "" { m.messages = append(m.messages, displayMsg{role: "system", content: "No changes detected."}) return m, nil } - output := stat + "\n\n" + diff - if len(output) > 10000 { - output = stat + "\n\n(diff too large, showing stat only)" - } m.messages = append(m.messages, displayMsg{role: "system", content: output}) return m, nil case "/history": - entries, err := session.List() - if err != nil || len(entries) == 0 { + report, found, err := sessionfeature.HistoryReport() + if err != nil || !found { m.messages = append(m.messages, displayMsg{role: "system", content: "No saved sessions."}) return m, nil } - var b strings.Builder - for _, e := range entries { - b.WriteString(fmt.Sprintf(" %s %s %s\n", e.ID, e.UpdatedAt.Format("Jan 02 15:04"), e.Preview)) - } - m.messages = append(m.messages, displayMsg{role: "system", content: b.String()}) + m.messages = append(m.messages, displayMsg{role: "system", content: report}) return m, nil case "/recover": - candidates := session.ScanForRecovery() + candidates := sessionfeature.RecoveryCandidates() if len(candidates) == 0 { m.messages = append(m.messages, displayMsg{role: "system", content: "No interrupted sessions found."}) return m, nil } if len(parts) >= 2 { // Resume specific session - s, note, err := session.ResumeSession(parts[1]) + s, note, err := sessionfeature.Resume(parts[1]) if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil @@ -194,31 +142,17 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = s.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - msgs := session.ToRuntimeMessages(s.Messages) - for _, sm := range s.Messages { - if sm.Role == "user" || sm.Role == "assistant" { - m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) - } + hydrated := sessionfeature.Hydrate(s) + for _, message := range hydrated.Display { + m.messages = append(m.messages, displayMsg{role: message.Role, content: message.Content}) } - m.session.LoadMessages(msgs) + m.session.LoadMessages(hydrated.Runtime) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Recovered: %s\nSession %s ready (%d msgs)", note, s.ID, len(s.Messages))}) m.viewDirty = true m.autoScroll = false return m, nil } - // List candidates - var b strings.Builder - b.WriteString(fmt.Sprintf("Found %d interrupted session(s):\n\n", len(candidates))) - for i, c := range candidates { - shortID := c.SessionID - if len(shortID) > 8 { - shortID = shortID[:8] - } - b.WriteString(fmt.Sprintf("%d. [%s] %s — %s (%d msgs, %s)\n", - i+1, shortID, c.Interruption, c.CWD, c.MessageCount, formatDuration(c.Age))) - } - b.WriteString("\nResume with: /recover ") - m.messages = append(m.messages, displayMsg{role: "system", content: b.String()}) + m.messages = append(m.messages, displayMsg{role: "system", content: sessionfeature.RecoveryReport(candidates)}) return m, nil case "/resume": @@ -226,7 +160,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /resume "}) return m, nil } - saved, err := session.Load(parts[1]) + saved, err := sessionfeature.Load(parts[1]) if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil @@ -234,13 +168,11 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = saved.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - msgs := session.ToRuntimeMessages(saved.Messages) - for _, sm := range saved.Messages { - if sm.Role == "user" || sm.Role == "assistant" { - m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) - } + hydrated := sessionfeature.Hydrate(saved) + for _, message := range hydrated.Display { + m.messages = append(m.messages, displayMsg{role: message.Role, content: message.Content}) } - m.session.LoadMessages(msgs) + m.session.LoadMessages(hydrated.Runtime) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Resumed session %s", saved.ID)}) m.viewDirty = true m.autoScroll = false @@ -272,16 +204,12 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string } // Fallback: legacy session fork atIndex := len(m.session.RawMessages()) - 1 - if len(parts) >= 2 { - if idx, err := strconv.Atoi(parts[1]); err == nil { - atIndex = idx - } - } + atIndex = sessionfeature.ParseForkIndex(parts[1:], atIndex) if atIndex < 0 { m.messages = append(m.messages, displayMsg{role: "error", content: "No messages to fork from."}) return m, nil } - forked, err := session.Fork(m.sessionID, atIndex) + forked, err := sessionfeature.ForkAtMessage(m.sessionID, atIndex) if err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil @@ -292,14 +220,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/export": format := "md" if len(parts) >= 2 { - switch strings.ToLower(parts[1]) { - case "md", "markdown": - format = "md" - case "json": - format = "json" - case "txt", "text": - format = "txt" - default: + parsedFormat, ok := sessionfeature.ParseExportFormat(parts[1]) + if !ok { m.messages = append(m.messages, displayMsg{ role: "system", content: "Usage: /export [format]\n" + @@ -310,6 +232,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string }) return m, nil } + format = parsedFormat } exportPath, err := exportSession(m, format) if err != nil { @@ -338,10 +261,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Invalid session name: %v", err)}) return m, nil } - sessDir := storage.SessionsDir() - oldPath := filepath.Join(sessDir, filepath.Base(m.sessionID)+".jsonl") - newPath := filepath.Join(sessDir, newName+".jsonl") - if err := os.Rename(oldPath, newPath); err != nil { + if err := sessionfeature.Rename(m.sessionID, newName); err != nil { m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) } else { m.sessionID = newName @@ -354,13 +274,9 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /tag