bone-agent 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +17 -0
  2. package/config.yaml.example +5 -2
  3. package/package.json +1 -1
  4. package/prompts/main/communication_style.md +1 -1
  5. package/prompts/main/dream.md +23 -9
  6. package/prompts/main/skills.md +3 -0
  7. package/prompts/micro/communication_style.md +1 -1
  8. package/prompts/micro/skills.md +1 -0
  9. package/src/core/agentic.py +138 -38
  10. package/src/core/chat_manager.py +19 -6
  11. package/src/core/config_manager.py +8 -1
  12. package/src/core/cron.py +0 -4
  13. package/src/core/metadata.py +75 -0
  14. package/src/core/skills.py +463 -0
  15. package/src/core/sub_agent.py +93 -43
  16. package/src/core/tool_feedback.py +87 -76
  17. package/src/llm/client.py +7 -2
  18. package/src/llm/codex_provider.py +350 -0
  19. package/src/llm/config.py +46 -2
  20. package/src/llm/prompts.py +12 -7
  21. package/src/llm/providers.py +3 -1
  22. package/src/llm/token_tracker.py +15 -0
  23. package/src/tools/__init__.py +24 -85
  24. package/src/tools/create_file.py +1 -1
  25. package/src/tools/directory.py +1 -1
  26. package/src/tools/edit.py +5 -1
  27. package/src/tools/file_reader.py +1 -1
  28. package/src/tools/helpers/__init__.py +1 -7
  29. package/src/tools/helpers/base.py +65 -16
  30. package/src/tools/helpers/loader.py +2 -88
  31. package/src/tools/helpers/path_resolver.py +54 -3
  32. package/src/tools/helpers/plugin_manifest.py +99 -70
  33. package/src/tools/review_sub_agent.py +2 -1
  34. package/src/tools/rg_search.py +24 -7
  35. package/src/tools/search_plugins.py +140 -72
  36. package/src/tools/shell.py +3 -3
  37. package/src/ui/commands.py +355 -33
  38. package/src/ui/displays.py +26 -1
  39. package/src/ui/main.py +0 -4
  40. package/src/ui/tool_confirmation.py +16 -5
  41. package/src/utils/editor.py +88 -39
  42. package/src/utils/settings.py +6 -2
  43. package/src/utils/validation.py +10 -0
@@ -1,4 +1,4 @@
1
- """Plugin manifest for on-demand tool discovery.
1
+ """Capability manifest for on-demand plugin and skill discovery.
2
2
 
3
3
  Plugin-tier tools are registered here instead of ToolRegistry at import time.
4
4
  This keeps plugin schemas out of the LLM context window until explicitly
@@ -6,19 +6,41 @@ activated via the search_plugins core tool.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Dict, List, Optional
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Iterable, List, Optional
11
+
12
+ from core.skills import (
13
+ SearchCandidate,
14
+ iter_skill_summaries,
15
+ search_candidates,
16
+ )
17
+ from utils.settings import tool_settings
10
18
 
11
19
  from .base import ToolDefinition
12
20
 
13
21
  logger = logging.getLogger(__name__)
14
22
 
15
23
 
24
+ @dataclass
25
+ class CapabilityMatch:
26
+ kind: str
27
+ name: str
28
+ description: str
29
+ category: str | None = None
30
+ tags: list[str] | None = None
31
+ tool_def: ToolDefinition | None = None
32
+ preview: str | None = None
33
+ activated: bool = False
34
+ already_active: bool = False
35
+
36
+
16
37
  class PluginManifest:
17
- """Index of plugin-tier tools available for on-demand activation.
38
+ """Index of plugin-tier tools and stored skills for discovery surfaces.
18
39
 
19
- Tools are registered here when modules with @tool(tier="plugin") are
20
- imported. The search_plugins core tool queries this index to find and
21
- activate matching plugins.
40
+ Plugin tools are registered here when modules with @tool(tier="plugin")
41
+ are imported. Stored skills are discovered from disk on demand. The
42
+ search_plugins core tool queries this manifest to find and activate or
43
+ load capabilities.
22
44
  """
23
45
 
24
46
  def __init__(self):
@@ -58,70 +80,77 @@ class PluginManifest:
58
80
  """
59
81
  return list(self._plugins.values())
60
82
 
61
- def search(self, query: str, category: str = None, max_results: int = 5) -> List[ToolDefinition]:
62
- """Search the manifest for plugins matching a query.
63
-
64
- Args:
65
- query: Search query (matched against name, description, tags, category)
66
- category: Optional category filter
67
- max_results: Maximum number of results to return
68
-
69
- Returns:
70
- List of matching ToolDefinitions, sorted by relevance score
71
- """
72
- query_lower = query.lower()
73
- query_terms = query_lower.split()
74
-
75
- scored = []
76
- for tool_def in self._plugins.values():
77
- # Apply category filter if specified
78
- if category and tool_def.category != category:
79
- continue
80
-
81
- # Calculate relevance score
82
- score = 0.0
83
-
84
- # Exact name match (highest priority)
85
- if query_lower == tool_def.name.lower():
86
- score += 100.0
87
-
88
- # Name contains query
89
- if query_lower in tool_def.name.lower():
90
- score += 50.0
91
-
92
- # Name contains individual terms
93
- for term in query_terms:
94
- if term in tool_def.name.lower():
95
- score += 20.0
96
-
97
- # Description match
98
- if query_lower in tool_def.description.lower():
99
- score += 30.0
100
- for term in query_terms:
101
- if term in tool_def.description.lower():
102
- score += 10.0
103
-
104
- # Tag match
105
- for tag in tool_def.tags:
106
- if query_lower in tag.lower():
107
- score += 15.0
108
- for term in query_terms:
109
- if term in tag.lower():
110
- score += 5.0
111
-
112
- # Category match
113
- if category and tool_def.category == category:
114
- score += 10.0
115
- elif not category and query_lower in tool_def.category.lower():
116
- score += 15.0
117
-
118
- if score > 0:
119
- scored.append((score, tool_def))
120
-
121
- # Sort by score descending
122
- scored.sort(key=lambda x: x[0], reverse=True)
123
-
124
- return [tool_def for _, tool_def in scored[:max_results]]
83
+ def _iter_capabilities(self, category: str = None) -> Iterable[CapabilityMatch]:
84
+ """Yield available plugin and skill capabilities for discovery surfaces."""
85
+ include_plugins = category in (None, "plugin")
86
+ include_skills = category in (None, "skill")
87
+ disabled_tools = set(tool_settings.disabled_tools or [])
88
+ hidden_skills = set(tool_settings.hidden_skills or [])
89
+
90
+ if include_plugins:
91
+ for tool_def in self._plugins.values():
92
+ if tool_def.name in disabled_tools:
93
+ continue
94
+ if category not in (None, "plugin") and tool_def.category != category:
95
+ continue
96
+ yield CapabilityMatch(
97
+ kind="plugin",
98
+ name=tool_def.name,
99
+ description=tool_def.description,
100
+ category=tool_def.category,
101
+ tags=list(tool_def.tags or []),
102
+ tool_def=tool_def,
103
+ )
104
+
105
+ if include_skills:
106
+ for summary in iter_skill_summaries():
107
+ if summary.name in hidden_skills:
108
+ continue
109
+ yield CapabilityMatch(
110
+ kind="skill",
111
+ name=summary.name,
112
+ description=summary.description or summary.preview,
113
+ category="skill",
114
+ tags=summary.tags or ["skill"],
115
+ preview=summary.preview,
116
+ )
117
+
118
+ def search_capabilities(
119
+ self,
120
+ query: str,
121
+ category: str = None,
122
+ max_results: int = 5,
123
+ ) -> List[CapabilityMatch]:
124
+ """Search plugins and skills through one shared discovery path."""
125
+ combined_candidates = [
126
+ SearchCandidate(
127
+ item=capability,
128
+ text=" ".join(
129
+ part
130
+ for part in [
131
+ capability.name,
132
+ capability.description,
133
+ capability.category or "",
134
+ " ".join(capability.tags or []),
135
+ ]
136
+ if part
137
+ ),
138
+ compact_text="",
139
+ exact_text=capability.name,
140
+ )
141
+ for capability in self._iter_capabilities(category=category)
142
+ ]
143
+ combined_matches = search_candidates(
144
+ query,
145
+ combined_candidates,
146
+ max_results=max_results,
147
+ item_key=lambda capability: f"{capability.kind}:{capability.name}",
148
+ )
149
+ return [match.item for match in combined_matches]
150
+
151
+ def list_all_capabilities(self, category: str = None) -> List[CapabilityMatch]:
152
+ """Return all available capabilities without fuzzy scoring."""
153
+ return list(self._iter_capabilities(category=category))
125
154
 
126
155
  def get_categories(self) -> List[str]:
127
156
  """Get all unique categories in the manifest.
@@ -172,7 +172,8 @@ def review_changes(
172
172
  panel.set_complete({
173
173
  'prompt_tokens': usage.get('prompt_tokens', 0),
174
174
  'completion_tokens': usage.get('completion_tokens', 0),
175
- 'total_tokens': usage.get('total_tokens', 0)
175
+ 'total_tokens': usage.get('total_tokens', 0),
176
+ 'context_tokens': usage.get('context_tokens', 0),
176
177
  })
177
178
 
178
179
  raw_result = sub_agent_data.get('result', '')
@@ -5,7 +5,7 @@ import re
5
5
  import stat
6
6
  import subprocess
7
7
  from pathlib import Path
8
- from typing import Optional
8
+ from typing import Optional, Sequence
9
9
 
10
10
  from .helpers.base import tool
11
11
  from .helpers.formatters import format_tool_result
@@ -83,7 +83,7 @@ def _annotate_file_sizes(formatted_output: str, base_path: Path, output_mode: st
83
83
 
84
84
  @tool(
85
85
  name="rg",
86
- description="Search files using ripgrep. Use for ALL code searches (never shell commands). Supports regex, glob/type filtering, and output modes: content, files_with_matches, or count.",
86
+ description="Search files using ripgrep. Use for ALL code searches (never shell commands). Supports regex, glob/type filtering, and output modes: content, files_with_matches, or count. Use 'path' for one file/directory or 'paths' for multiple files/directories; do not pass space-separated paths in 'path'.",
87
87
  parameters={
88
88
  "type": "object",
89
89
  "properties": {
@@ -93,7 +93,12 @@ def _annotate_file_sizes(formatted_output: str, base_path: Path, output_mode: st
93
93
  },
94
94
  "path": {
95
95
  "type": "string",
96
- "description": "File or directory to search (default: current directory)"
96
+ "description": "Single file or directory to search (default: current directory). Do not pass multiple space-separated paths here; use 'paths' instead."
97
+ },
98
+ "paths": {
99
+ "type": "array",
100
+ "items": {"type": "string"},
101
+ "description": "Multiple files or directories to search. Use this instead of space-separated values in 'path'. If set, 'paths' overrides 'path'."
97
102
  },
98
103
  "glob": {
99
104
  "type": "string",
@@ -138,6 +143,7 @@ def rg(
138
143
  debug_mode: bool = False,
139
144
  gitignore_spec = None,
140
145
  path: Optional[str] = None,
146
+ paths: Optional[Sequence[str]] = None,
141
147
  glob: Optional[str] = None,
142
148
  output_mode: str = "files_with_matches",
143
149
  vault_root: Optional[str] = None,
@@ -153,7 +159,8 @@ def rg(
153
159
  chat_manager: ChatManager instance (injected by context)
154
160
  debug_mode: Whether debug mode is enabled (injected by context)
155
161
  gitignore_spec: PathSpec for .gitignore filtering (injected by context)
156
- path: File or directory to search in (default: current directory)
162
+ path: Single file or directory to search in (default: current directory)
163
+ paths: Multiple files or directories to search; overrides path when set
157
164
  glob: Glob pattern to filter files
158
165
  output_mode: Output mode (content/files_with_matches/count)
159
166
  vault_root: Obsidian vault root path (injected by context)
@@ -204,11 +211,21 @@ def rg(
204
211
  elif output_mode == "count":
205
212
  args.append("--count")
206
213
 
207
- # Pattern and search path — no quoting needed, subprocess list form bypasses shell
214
+ # Pattern and search paths — no quoting needed, subprocess list form bypasses shell
208
215
  args.append(pattern)
209
216
 
210
- search_path = path or "."
211
- args.append(search_path)
217
+ if paths is not None:
218
+ if not isinstance(paths, Sequence) or isinstance(paths, (str, bytes)):
219
+ return "exit_code=1\nrg 'paths' must be an array of path strings. Use 'path' for one path."
220
+ if not paths:
221
+ return "exit_code=1\nrg 'paths' must be a non-empty array. Omit 'paths' to search the current directory."
222
+ search_paths = [p for p in paths if isinstance(p, str) and p.strip()]
223
+ if len(search_paths) != len(paths):
224
+ return "exit_code=1\nrg 'paths' must contain only non-empty strings."
225
+ else:
226
+ search_paths = [path or "."]
227
+
228
+ args.extend(search_paths)
212
229
 
213
230
  # Get max_matches from kwargs (default: 100, set to 0 for no limit)
214
231
  raw = coerce_int(kwargs.get("max_matches"))[0] if kwargs.get("max_matches") is not None else None
@@ -1,38 +1,41 @@
1
- """search_plugins core tool for on-demand plugin discovery.
1
+ """search_plugins core tool for on-demand capability discovery.
2
2
 
3
3
  This tool lets the LLM agent search for available plugin tools and
4
- activate them. Plugin schemas are not sent by default to avoid context
5
- bloat they are only included after activation.
4
+ stored skills, then explicitly activate plugins or load skills through
5
+ the same entrypoint. Plugin schemas are not sent by default to avoid
6
+ context bloat — they are only included after activation.
6
7
  """
7
8
 
8
- from typing import List, Optional
9
- from pathlib import Path
10
-
11
9
  from tools.helpers.base import tool, ToolRegistry, TERMINAL_NONE
12
10
 
11
+ HEADER_MATCHES = "Capability matches for: "
12
+ HEADER_ALL = "All available capabilities"
13
+
13
14
 
14
15
  @tool(
15
16
  name="search_plugins",
16
17
  description=(
17
- "Search for available plugin tools that can help with your task. "
18
- "Plugin tools are NOT in your available tools by default — use this "
19
- "to discover and activate them. Returns matching plugin names and "
20
- "descriptions. Once activated, the plugin's full schema will be "
21
- "available in your next response."
18
+ "Search for available plugin tools and saved skills that can help "
19
+ "with your task. Plugins are NOT in your available tools by default "
20
+ "— use this to discover and activate them. Skills can also be loaded "
21
+ "through this same tool by passing explicit capability names in 'load'. "
22
+ "Once a plugin is activated, its full schema will be available in your "
23
+ "next response."
22
24
  ),
23
25
  parameters={
24
26
  "type": "object",
25
27
  "properties": {
26
28
  "query": {
27
29
  "type": "string",
28
- "description": "Search query describing what you need (e.g., 'send email', 'query database', 'http request')"
30
+ "description": "Search query describing what you need (e.g., 'send email', 'query database', 'http request'). Omit to list all available plugins and skills."
29
31
  },
30
- "category": {
31
- "type": "string",
32
- "description": "Optional category filter (e.g., 'email', 'database', 'analysis')"
32
+ "load": {
33
+ "type": "array",
34
+ "items": {"type": "string"},
35
+ "description": "Optional list of exact capability names from the current search results to activate or load. Plugins are activated; skills are injected into the current chat."
33
36
  }
34
37
  },
35
- "required": ["query"]
38
+ "required": []
36
39
  },
37
40
  requires_approval=False,
38
41
  terminal_policy=TERMINAL_NONE,
@@ -41,69 +44,134 @@ from tools.helpers.base import tool, ToolRegistry, TERMINAL_NONE
41
44
  category="core"
42
45
  )
43
46
  def search_plugins(
44
- query: str,
45
- category: str = None,
47
+ query: str = "",
48
+ load: list[str] | None = None,
49
+ chat_manager=None,
46
50
  ) -> str:
47
- """Search the plugin manifest and activate matching plugins.
48
-
49
- Args:
50
- query: Search query describing the needed capability
51
- category: Optional category filter
51
+ """Search discoverable capabilities and optionally activate/load selected matches.
52
52
 
53
- Returns:
54
- Formatted result with activated plugin names and descriptions
55
- """
53
+ When using `load`, `query` must be provided so selections come from the current search results."""
54
+ from core.skills import SkillError, activate_skill, validate_skill_name
56
55
  from tools.helpers.plugin_manifest import plugin_manifest
57
56
 
58
- # Check if any core tool matches the query — early return
59
57
  core_tools = ToolRegistry.get_all(include_plugins=False)
60
- query_lower = query.lower()
61
- for ct in core_tools:
62
- if query_lower == ct.name.lower() or query_lower in ct.name.lower():
63
- return (
64
- f"exit_code=0\n"
65
- f"'{query}' matches a core tool that is already available: **{ct.name}**.\n"
66
- f"Description: {ct.description}"
67
- )
68
-
69
- # Search the plugin manifest
70
- matches = plugin_manifest.search(query, category=category, max_results=5)
58
+ core_tool_note = None
59
+ query = query.strip()
60
+
61
+ if load and not query:
62
+ return "\n".join([
63
+ "exit_code=1",
64
+ "Loading capabilities requires a query so selections come from the current search results.",
65
+ ])
66
+
67
+ # No query list everything
68
+ if not query:
69
+ matches = plugin_manifest.list_all_capabilities()
70
+ else:
71
+ query_lower = query.lower()
72
+ for ct in core_tools:
73
+ if query_lower == ct.name.lower():
74
+ core_tool_note = (
75
+ f"Core tool already available: {ct.name}\n"
76
+ f" {ct.description}"
77
+ )
78
+ break
79
+
80
+ matches = plugin_manifest.search_capabilities(query, max_results=10)
71
81
 
72
82
  if not matches:
73
- # No matches — suggest available categories
74
- categories = plugin_manifest.get_categories()
75
- if categories:
76
- cat_list = ", ".join(f"'{c}'" for c in categories)
77
- return (
78
- f"exit_code=0\n"
79
- f"No plugins found matching '{query}'.\n"
80
- f"Available plugin categories: {cat_list}\n"
81
- f"Total plugins in manifest: {plugin_manifest.plugin_count()}"
82
- )
83
- return (
84
- f"exit_code=0\n"
85
- f"No plugins found matching '{query}'. "
86
- f"No plugins are currently registered in the manifest."
87
- )
88
-
89
- # Activate matched plugins in the registry
90
- activated = []
91
- already_active = []
92
- for tool_def in matches:
93
- if ToolRegistry.is_plugin_active(tool_def.name):
94
- already_active.append(tool_def.name)
83
+ lines = ["exit_code=0"]
84
+ if core_tool_note:
85
+ lines.extend([core_tool_note, ""])
86
+ if query:
87
+ lines.append(f"No matches for: {query}")
95
88
  else:
96
- ToolRegistry.activate_plugin(tool_def)
97
- activated.append(tool_def.name)
98
-
99
- # Build result
100
- lines = [f"exit_code=0\nFound {len(matches)} plugin(s) matching '{query}':\n"]
101
-
102
- for tool_def in matches:
103
- status = "activated" if tool_def.name in activated else "already active"
104
- cat_part = f" [{tool_def.category}]" if tool_def.category else ""
105
- lines.append(f"- **{tool_def.name}**{cat_part} ({status}): {tool_def.description}")
106
- if tool_def.tags:
107
- lines.append(f" Tags: {', '.join(tool_def.tags)}")
89
+ lines.append("No plugins or skills available.")
90
+ return "\n".join(lines)
91
+
92
+ requested = [name for name in (load or []) if isinstance(name, str) and name.strip()]
93
+ requested_normalized = {name.strip().lower(): name.strip() for name in requested}
94
+ matched_by_name = {match.name.lower(): match for match in matches}
95
+
96
+ plugin_count = 0
97
+ skill_count = 0
98
+ loaded_plugins = []
99
+ loaded_skills = []
100
+ load_errors = []
101
+
102
+ for match in matches:
103
+ if match.kind == "plugin" and match.tool_def:
104
+ plugin_count += 1
105
+ if ToolRegistry.is_plugin_active(match.tool_def.name):
106
+ match.already_active = True
107
+ if match.name.lower() in requested_normalized and not match.already_active:
108
+ if ToolRegistry.activate_plugin(match.tool_def):
109
+ match.activated = True
110
+ loaded_plugins.append(match.name)
111
+ else:
112
+ load_errors.append(f"Plugin '{match.name}' is disabled. Enable it before loading.")
113
+ continue
114
+ skill_count += 1
115
+ if match.name.lower() in requested_normalized:
116
+ if chat_manager is None:
117
+ load_errors.append(f"Skill '{match.name}' cannot be loaded without an active chat.")
118
+ continue
119
+ try:
120
+ skill_name = validate_skill_name(match.name)
121
+ activate_skill(chat_manager, skill_name)
122
+ loaded_skills.append(skill_name)
123
+ except SkillError as exc:
124
+ load_errors.append(str(exc))
125
+
126
+ missing_requested = [
127
+ original_name
128
+ for normalized_name, original_name in requested_normalized.items()
129
+ if normalized_name not in matched_by_name
130
+ ]
131
+ for missing in missing_requested:
132
+ load_errors.append(f"Capability '{missing}' was not found in the current search results.")
133
+
134
+ lines = ["exit_code=0"]
135
+ if core_tool_note:
136
+ lines.extend([core_tool_note, ""])
137
+
138
+ if query:
139
+ lines.extend([
140
+ f"{HEADER_MATCHES}{query}",
141
+ f"Results: {len(matches)} total ({plugin_count} plugin, {skill_count} skill)",
142
+ "",
143
+ ])
144
+ else:
145
+ lines.extend([
146
+ HEADER_ALL,
147
+ f"Total: {len(matches)} ({plugin_count} plugin, {skill_count} skill)",
148
+ "",
149
+ ])
150
+
151
+ for match in matches:
152
+ if match.kind == "plugin":
153
+ status = "disabled" if ToolRegistry.is_disabled(match.name) else "activated" if match.activated else "active" if match.already_active else "available"
154
+ lines.append(f"- {match.name}")
155
+ lines.append(" type: plugin")
156
+ lines.append(f" status: {status}")
157
+ lines.append(f" summary: {match.description}")
158
+ if match.tags:
159
+ lines.append(f" tags: {', '.join(match.tags)}")
160
+ continue
161
+
162
+ lines.append(f"- {match.name}")
163
+ lines.append(" type: skill")
164
+ lines.append(f" summary: {match.description}")
165
+ if match.tags:
166
+ lines.append(f" tags: {', '.join(match.tags)}")
167
+
168
+ if requested:
169
+ lines.append("")
170
+ if loaded_plugins:
171
+ lines.append(f"Activated plugins: {', '.join(loaded_plugins)}")
172
+ if loaded_skills:
173
+ lines.append(f"Loaded skills: {', '.join(loaded_skills)}")
174
+ if load_errors:
175
+ lines.append(f"Load issues: {'; '.join(load_errors)}")
108
176
 
109
177
  return "\n".join(lines)
@@ -231,13 +231,13 @@ def run_shell_command(command, repo_root, rg_exe_path, console, debug_mode, giti
231
231
 
232
232
  @tool(
233
233
  name="execute_command",
234
- description="Execute shell commands for git, system tasks, file ops, network, and package management. Runs from repo root. Use for git, ps, systemctl, rm, mv, cp, mkdir, ping, curl, wget, ssh, pacman, pip, npm, apt. Disallowed: rg, cat, ls, grep, find, head, tail, sed, awk, sort, uniq, wc, echo, touch, get-content, type, get-childitem, dir, new-item, set-content, add-content, tee. Use native tools instead.",
234
+ description="Execute shell commands for git, system tasks, file ops, network, and package management. Runs from repo root. Multi-line shell commands are supported and preserved exactly, including heredocs. Use for git, ps, systemctl, rm, mv, cp, mkdir, ping, curl, wget, ssh, pacman, pip, npm, apt. Disallowed: rg, cat, ls, grep, find, head, tail, sed, awk, sort, uniq, wc, echo, touch, get-content, type, get-childitem, dir, new-item, set-content, add-content, tee. Use native tools instead.",
235
235
  parameters={
236
236
  "type": "object",
237
237
  "properties": {
238
238
  "command": {
239
239
  "type": "string",
240
- "description": "Command to execute"
240
+ "description": "Shell command to execute from the repo root. May be a single-line command or a multi-line shell script/heredoc; newlines are preserved exactly."
241
241
  },
242
242
  "reason": {
243
243
  "type": "string",
@@ -262,7 +262,7 @@ def execute_command(
262
262
  """Execute a shell command.
263
263
 
264
264
  Args:
265
- command: Command string to execute
265
+ command: Command string to execute. May contain newlines/heredocs; preserved exactly for shell execution.
266
266
  repo_root: Repository root directory (injected by context)
267
267
  rg_exe_path: Path to rg executable (injected by context)
268
268
  console: Rich console for output (injected by context)