shotgun-sh 0.2.29.dev2__py3-none-any.whl → 0.6.1.dev1__py3-none-any.whl

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.

Potentially problematic release.


This version of shotgun-sh might be problematic. Click here for more details.

Files changed (161) hide show
  1. shotgun/agents/agent_manager.py +497 -30
  2. shotgun/agents/cancellation.py +103 -0
  3. shotgun/agents/common.py +90 -77
  4. shotgun/agents/config/README.md +0 -1
  5. shotgun/agents/config/manager.py +52 -8
  6. shotgun/agents/config/models.py +48 -45
  7. shotgun/agents/config/provider.py +44 -29
  8. shotgun/agents/conversation/history/file_content_deduplication.py +66 -43
  9. shotgun/agents/conversation/history/token_counting/base.py +51 -9
  10. shotgun/agents/export.py +12 -13
  11. shotgun/agents/file_read.py +176 -0
  12. shotgun/agents/messages.py +15 -3
  13. shotgun/agents/models.py +90 -2
  14. shotgun/agents/plan.py +12 -13
  15. shotgun/agents/research.py +13 -10
  16. shotgun/agents/router/__init__.py +47 -0
  17. shotgun/agents/router/models.py +384 -0
  18. shotgun/agents/router/router.py +185 -0
  19. shotgun/agents/router/tools/__init__.py +18 -0
  20. shotgun/agents/router/tools/delegation_tools.py +557 -0
  21. shotgun/agents/router/tools/plan_tools.py +403 -0
  22. shotgun/agents/runner.py +17 -2
  23. shotgun/agents/specify.py +12 -13
  24. shotgun/agents/tasks.py +12 -13
  25. shotgun/agents/tools/__init__.py +8 -0
  26. shotgun/agents/tools/codebase/directory_lister.py +27 -39
  27. shotgun/agents/tools/codebase/file_read.py +26 -35
  28. shotgun/agents/tools/codebase/query_graph.py +9 -0
  29. shotgun/agents/tools/codebase/retrieve_code.py +9 -0
  30. shotgun/agents/tools/file_management.py +81 -3
  31. shotgun/agents/tools/file_read_tools/__init__.py +7 -0
  32. shotgun/agents/tools/file_read_tools/multimodal_file_read.py +167 -0
  33. shotgun/agents/tools/markdown_tools/__init__.py +62 -0
  34. shotgun/agents/tools/markdown_tools/insert_section.py +148 -0
  35. shotgun/agents/tools/markdown_tools/models.py +86 -0
  36. shotgun/agents/tools/markdown_tools/remove_section.py +114 -0
  37. shotgun/agents/tools/markdown_tools/replace_section.py +119 -0
  38. shotgun/agents/tools/markdown_tools/utils.py +453 -0
  39. shotgun/agents/tools/registry.py +41 -0
  40. shotgun/agents/tools/web_search/__init__.py +1 -2
  41. shotgun/agents/tools/web_search/gemini.py +1 -3
  42. shotgun/agents/tools/web_search/openai.py +42 -23
  43. shotgun/attachments/__init__.py +41 -0
  44. shotgun/attachments/errors.py +60 -0
  45. shotgun/attachments/models.py +107 -0
  46. shotgun/attachments/parser.py +257 -0
  47. shotgun/attachments/processor.py +193 -0
  48. shotgun/cli/clear.py +2 -2
  49. shotgun/cli/codebase/commands.py +181 -65
  50. shotgun/cli/compact.py +2 -2
  51. shotgun/cli/context.py +2 -2
  52. shotgun/cli/run.py +90 -0
  53. shotgun/cli/spec/backup.py +2 -1
  54. shotgun/cli/spec/commands.py +2 -0
  55. shotgun/cli/spec/models.py +18 -0
  56. shotgun/cli/spec/pull_service.py +122 -68
  57. shotgun/codebase/__init__.py +2 -0
  58. shotgun/codebase/benchmarks/__init__.py +35 -0
  59. shotgun/codebase/benchmarks/benchmark_runner.py +309 -0
  60. shotgun/codebase/benchmarks/exporters.py +119 -0
  61. shotgun/codebase/benchmarks/formatters/__init__.py +49 -0
  62. shotgun/codebase/benchmarks/formatters/base.py +34 -0
  63. shotgun/codebase/benchmarks/formatters/json_formatter.py +106 -0
  64. shotgun/codebase/benchmarks/formatters/markdown.py +136 -0
  65. shotgun/codebase/benchmarks/models.py +129 -0
  66. shotgun/codebase/core/__init__.py +4 -0
  67. shotgun/codebase/core/call_resolution.py +91 -0
  68. shotgun/codebase/core/change_detector.py +11 -6
  69. shotgun/codebase/core/errors.py +159 -0
  70. shotgun/codebase/core/extractors/__init__.py +23 -0
  71. shotgun/codebase/core/extractors/base.py +138 -0
  72. shotgun/codebase/core/extractors/factory.py +63 -0
  73. shotgun/codebase/core/extractors/go/__init__.py +7 -0
  74. shotgun/codebase/core/extractors/go/extractor.py +122 -0
  75. shotgun/codebase/core/extractors/javascript/__init__.py +7 -0
  76. shotgun/codebase/core/extractors/javascript/extractor.py +132 -0
  77. shotgun/codebase/core/extractors/protocol.py +109 -0
  78. shotgun/codebase/core/extractors/python/__init__.py +7 -0
  79. shotgun/codebase/core/extractors/python/extractor.py +141 -0
  80. shotgun/codebase/core/extractors/rust/__init__.py +7 -0
  81. shotgun/codebase/core/extractors/rust/extractor.py +139 -0
  82. shotgun/codebase/core/extractors/types.py +15 -0
  83. shotgun/codebase/core/extractors/typescript/__init__.py +7 -0
  84. shotgun/codebase/core/extractors/typescript/extractor.py +92 -0
  85. shotgun/codebase/core/gitignore.py +252 -0
  86. shotgun/codebase/core/ingestor.py +644 -354
  87. shotgun/codebase/core/kuzu_compat.py +119 -0
  88. shotgun/codebase/core/language_config.py +239 -0
  89. shotgun/codebase/core/manager.py +256 -46
  90. shotgun/codebase/core/metrics_collector.py +310 -0
  91. shotgun/codebase/core/metrics_types.py +347 -0
  92. shotgun/codebase/core/parallel_executor.py +424 -0
  93. shotgun/codebase/core/work_distributor.py +254 -0
  94. shotgun/codebase/core/worker.py +768 -0
  95. shotgun/codebase/indexing_state.py +86 -0
  96. shotgun/codebase/models.py +94 -0
  97. shotgun/codebase/service.py +13 -0
  98. shotgun/exceptions.py +1 -1
  99. shotgun/main.py +2 -10
  100. shotgun/prompts/agents/export.j2 +2 -0
  101. shotgun/prompts/agents/file_read.j2 +48 -0
  102. shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +20 -28
  103. shotgun/prompts/agents/partials/content_formatting.j2 +12 -33
  104. shotgun/prompts/agents/partials/interactive_mode.j2 +9 -32
  105. shotgun/prompts/agents/partials/router_delegation_mode.j2 +35 -0
  106. shotgun/prompts/agents/plan.j2 +43 -1
  107. shotgun/prompts/agents/research.j2 +75 -20
  108. shotgun/prompts/agents/router.j2 +713 -0
  109. shotgun/prompts/agents/specify.j2 +94 -4
  110. shotgun/prompts/agents/state/codebase/codebase_graphs_available.j2 +14 -1
  111. shotgun/prompts/agents/state/system_state.j2 +24 -15
  112. shotgun/prompts/agents/tasks.j2 +77 -23
  113. shotgun/settings.py +44 -0
  114. shotgun/shotgun_web/shared_specs/upload_pipeline.py +38 -0
  115. shotgun/tui/app.py +90 -23
  116. shotgun/tui/commands/__init__.py +9 -1
  117. shotgun/tui/components/attachment_bar.py +87 -0
  118. shotgun/tui/components/mode_indicator.py +120 -25
  119. shotgun/tui/components/prompt_input.py +23 -28
  120. shotgun/tui/components/status_bar.py +5 -4
  121. shotgun/tui/dependencies.py +58 -8
  122. shotgun/tui/protocols.py +37 -0
  123. shotgun/tui/screens/chat/chat.tcss +24 -1
  124. shotgun/tui/screens/chat/chat_screen.py +1374 -211
  125. shotgun/tui/screens/chat/codebase_index_prompt_screen.py +8 -4
  126. shotgun/tui/screens/chat_screen/attachment_hint.py +40 -0
  127. shotgun/tui/screens/chat_screen/command_providers.py +0 -97
  128. shotgun/tui/screens/chat_screen/history/agent_response.py +7 -3
  129. shotgun/tui/screens/chat_screen/history/chat_history.py +49 -6
  130. shotgun/tui/screens/chat_screen/history/formatters.py +75 -15
  131. shotgun/tui/screens/chat_screen/history/partial_response.py +11 -1
  132. shotgun/tui/screens/chat_screen/history/user_question.py +25 -3
  133. shotgun/tui/screens/chat_screen/messages.py +219 -0
  134. shotgun/tui/screens/database_locked_dialog.py +219 -0
  135. shotgun/tui/screens/database_timeout_dialog.py +158 -0
  136. shotgun/tui/screens/kuzu_error_dialog.py +135 -0
  137. shotgun/tui/screens/model_picker.py +14 -9
  138. shotgun/tui/screens/models.py +11 -0
  139. shotgun/tui/screens/shotgun_auth.py +50 -0
  140. shotgun/tui/screens/spec_pull.py +2 -0
  141. shotgun/tui/state/processing_state.py +19 -0
  142. shotgun/tui/utils/mode_progress.py +20 -86
  143. shotgun/tui/widgets/__init__.py +2 -1
  144. shotgun/tui/widgets/approval_widget.py +152 -0
  145. shotgun/tui/widgets/cascade_confirmation_widget.py +203 -0
  146. shotgun/tui/widgets/plan_panel.py +129 -0
  147. shotgun/tui/widgets/step_checkpoint_widget.py +180 -0
  148. shotgun/tui/widgets/widget_coordinator.py +18 -0
  149. shotgun/utils/file_system_utils.py +4 -1
  150. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/METADATA +88 -34
  151. shotgun_sh-0.6.1.dev1.dist-info/RECORD +292 -0
  152. shotgun/cli/export.py +0 -81
  153. shotgun/cli/plan.py +0 -73
  154. shotgun/cli/research.py +0 -93
  155. shotgun/cli/specify.py +0 -70
  156. shotgun/cli/tasks.py +0 -78
  157. shotgun/tui/screens/onboarding.py +0 -580
  158. shotgun_sh-0.2.29.dev2.dist-info/RECORD +0 -229
  159. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/WHEEL +0 -0
  160. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/entry_points.txt +0 -0
  161. {shotgun_sh-0.2.29.dev2.dist-info → shotgun_sh-0.6.1.dev1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,148 @@
1
+ """Tool for inserting content into markdown sections."""
2
+
3
+ from pydantic_ai import RunContext
4
+
5
+ from shotgun.agents.models import AgentDeps, FileOperationType
6
+ from shotgun.agents.tools.file_management import _validate_agent_scoped_path
7
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
+ from shotgun.logging_config import get_logger
9
+
10
+ from .utils import (
11
+ find_and_validate_section,
12
+ get_heading_level,
13
+ load_markdown_file,
14
+ parse_section_number,
15
+ renumber_headings_after,
16
+ split_normalized_content,
17
+ write_markdown_file,
18
+ )
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ @register_tool(
24
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
25
+ display_text="Inserting content",
26
+ key_arg="filename",
27
+ secondary_key_arg="new_heading",
28
+ )
29
+ async def insert_markdown_section(
30
+ ctx: RunContext[AgentDeps],
31
+ filename: str,
32
+ after_heading: str,
33
+ content: str,
34
+ new_heading: str | None = None,
35
+ ) -> str:
36
+ """Insert content at the end of a Markdown section.
37
+
38
+ PREFER THIS TOOL over rewriting the entire file - it is faster, less costly,
39
+ and less error-prone. Use this to append content to an existing section.
40
+
41
+ Uses fuzzy matching on headings so minor typos are tolerated.
42
+ Inserts content just before the next heading at the same or higher level.
43
+
44
+ Note: If new_heading contains a section number (e.g., "### 4.4 New Section"),
45
+ subsequent numbered sections at the same level will be automatically incremented
46
+ to maintain proper numbering order.
47
+
48
+ Args:
49
+ ctx: Run context with agent dependencies
50
+ filename: Path to the Markdown file (relative to .shotgun directory)
51
+ after_heading: The heading to insert after (e.g., '## Requirements'). Fuzzy matched.
52
+ content: The content to insert at the end of the section
53
+ new_heading: Optional heading for the inserted content (creates a subsection)
54
+
55
+ Returns:
56
+ Success message or error message
57
+ """
58
+ logger.debug("Inserting content into section '%s' in: %s", after_heading, filename)
59
+
60
+ try:
61
+ # Validate path with agent scoping
62
+ file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
63
+
64
+ # Load and parse the markdown file
65
+ file_ctx = await load_markdown_file(file_path, filename)
66
+ if isinstance(file_ctx, str):
67
+ return file_ctx # Error message
68
+
69
+ # Find and validate the target section
70
+ match = find_and_validate_section(file_ctx, after_heading)
71
+ if not match.is_success:
72
+ return match.error # type: ignore[return-value]
73
+
74
+ # Build insert content
75
+ insert_content_lines = split_normalized_content(content)
76
+
77
+ # Build the insert lines
78
+ insert_lines: list[str] = [""] # Blank line separator before new content
79
+
80
+ if new_heading:
81
+ insert_lines.append(new_heading)
82
+ insert_lines.append("") # Blank line after heading
83
+
84
+ insert_lines.extend(insert_content_lines)
85
+
86
+ # Add trailing blank line if not at EOF
87
+ if match.end_line < len(file_ctx.lines):
88
+ insert_lines.append("")
89
+
90
+ # Insert before section end (before next heading or EOF)
91
+ new_lines = (
92
+ file_ctx.lines[: match.end_line]
93
+ + insert_lines
94
+ + file_ctx.lines[match.end_line :]
95
+ )
96
+
97
+ # If new_heading has a section number, renumber subsequent sections
98
+ if new_heading:
99
+ new_heading_level = get_heading_level(new_heading)
100
+ if new_heading_level:
101
+ section_num = parse_section_number(new_heading)
102
+ if section_num:
103
+ # Calculate the line number where subsequent sections start
104
+ # (after the inserted content)
105
+ renumber_start = match.end_line + len(insert_lines)
106
+ new_lines = renumber_headings_after(
107
+ new_lines,
108
+ start_line=renumber_start,
109
+ heading_level=new_heading_level,
110
+ increment=True,
111
+ )
112
+
113
+ # Write the modified file
114
+ await write_markdown_file(file_ctx, new_lines)
115
+
116
+ # Track the file operation
117
+ ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
118
+
119
+ logger.debug(
120
+ "Successfully inserted content into section '%s' in %s",
121
+ match.heading.text, # type: ignore[union-attr]
122
+ filename,
123
+ )
124
+
125
+ lines_added = len(insert_lines)
126
+ confidence_display = f"{int(match.confidence * 100)}%"
127
+
128
+ if new_heading:
129
+ return (
130
+ f"Successfully inserted '{new_heading}' into '{match.heading.text}' in {filename} " # type: ignore[union-attr]
131
+ f"(matched with {confidence_display} confidence, {lines_added} lines added)"
132
+ )
133
+ else:
134
+ return (
135
+ f"Successfully inserted content into '{match.heading.text}' in {filename} " # type: ignore[union-attr]
136
+ f"(matched with {confidence_display} confidence, {lines_added} lines added)"
137
+ )
138
+
139
+ except ValueError as e:
140
+ # Path validation errors
141
+ error_msg = f"Error inserting into '{filename}': {e}"
142
+ logger.error("Section insertion failed: %s", error_msg)
143
+ return error_msg
144
+
145
+ except Exception as e:
146
+ error_msg = f"Error inserting into '{filename}': {e}"
147
+ logger.error("Section insertion failed: %s", error_msg)
148
+ return error_msg
@@ -0,0 +1,86 @@
1
+ """Pydantic models for markdown tools."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class MarkdownHeading(BaseModel):
9
+ """Represents a heading found in a Markdown file."""
10
+
11
+ line_number: int
12
+ text: str
13
+ level: int
14
+
15
+ @property
16
+ def normalized_text(self) -> str:
17
+ """Return heading text without # prefix, stripped and lowercased."""
18
+ return self.text.lstrip("#").strip().lower()
19
+
20
+
21
+ HeadingList = list[MarkdownHeading]
22
+
23
+
24
+ class HeadingMatch(BaseModel):
25
+ """Result of a successful heading match."""
26
+
27
+ heading: MarkdownHeading
28
+ confidence: float
29
+
30
+
31
+ class CloseMatch(BaseModel):
32
+ """A close match result for error messages."""
33
+
34
+ heading_text: str
35
+ confidence: float
36
+
37
+
38
+ class SectionNumber(BaseModel):
39
+ """Parsed section number from a heading.
40
+
41
+ Examples:
42
+ - "## 3. Title" -> prefix="3", has_trailing_dot=True
43
+ - "### 4.4 Title" -> prefix="4.4", has_trailing_dot=False
44
+ - "#### 1.2.3.4 Title" -> prefix="1.2.3.4", has_trailing_dot=False
45
+ """
46
+
47
+ prefix: str # The number part, e.g., "4.4" or "3"
48
+ has_trailing_dot: bool # Whether it ends with a dot before the title
49
+
50
+
51
+ class MarkdownFileContext(BaseModel):
52
+ """Context for a loaded markdown file ready for section operations.
53
+
54
+ This encapsulates the common state needed by all section manipulation tools:
55
+ file path, content split into lines, line ending style, and extracted headings.
56
+ """
57
+
58
+ file_path: Path
59
+ filename: str # Original filename for error messages
60
+ lines: list[str]
61
+ line_ending: str
62
+ headings: HeadingList
63
+
64
+ model_config = {"arbitrary_types_allowed": True}
65
+
66
+
67
+ class SectionMatchResult(BaseModel):
68
+ """Result of finding and validating a section match.
69
+
70
+ Either contains a successful match with the heading and bounds,
71
+ or an error message explaining why the match failed.
72
+ """
73
+
74
+ # Success fields (all present when error is None)
75
+ heading: MarkdownHeading | None = None
76
+ confidence: float = 0.0
77
+ start_line: int = 0
78
+ end_line: int = 0
79
+
80
+ # Error field (present when match failed)
81
+ error: str | None = None
82
+
83
+ @property
84
+ def is_success(self) -> bool:
85
+ """Return True if this is a successful match."""
86
+ return self.error is None and self.heading is not None
@@ -0,0 +1,114 @@
1
+ """Tool for removing markdown sections."""
2
+
3
+ from pydantic_ai import RunContext
4
+
5
+ from shotgun.agents.models import AgentDeps, FileOperationType
6
+ from shotgun.agents.tools.file_management import _validate_agent_scoped_path
7
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
+ from shotgun.logging_config import get_logger
9
+
10
+ from .utils import (
11
+ find_and_validate_section,
12
+ load_markdown_file,
13
+ parse_section_number,
14
+ renumber_headings_after,
15
+ write_markdown_file,
16
+ )
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ @register_tool(
22
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
23
+ display_text="Removing section",
24
+ key_arg="filename",
25
+ secondary_key_arg="section_heading",
26
+ )
27
+ async def remove_markdown_section(
28
+ ctx: RunContext[AgentDeps],
29
+ filename: str,
30
+ section_heading: str,
31
+ ) -> str:
32
+ """Remove an entire section from a Markdown file.
33
+
34
+ Uses fuzzy matching on headings so minor typos are tolerated.
35
+ Removes from the target heading down to (but not including) the next
36
+ heading at the same or higher level.
37
+
38
+ Note: If the removed section has a numbered heading (e.g., "### 4.4 Title"),
39
+ subsequent numbered sections at the same level will be automatically
40
+ decremented to maintain proper numbering order.
41
+
42
+ Args:
43
+ ctx: Run context with agent dependencies
44
+ filename: Path to the Markdown file (relative to .shotgun directory)
45
+ section_heading: The heading to find and remove. Fuzzy matched.
46
+
47
+ Returns:
48
+ Success message or error message
49
+ """
50
+ logger.debug("Removing section '%s' from: %s", section_heading, filename)
51
+
52
+ try:
53
+ # Validate path with agent scoping
54
+ file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
55
+
56
+ # Load and parse the markdown file
57
+ file_ctx = await load_markdown_file(file_path, filename)
58
+ if isinstance(file_ctx, str):
59
+ return file_ctx # Error message
60
+
61
+ # Find and validate the target section
62
+ match = find_and_validate_section(file_ctx, section_heading)
63
+ if not match.is_success:
64
+ return match.error # type: ignore[return-value]
65
+
66
+ removed_lines = match.end_line - match.start_line
67
+
68
+ # Check if the removed section has a numbered heading
69
+ section_num = parse_section_number(match.heading.text) # type: ignore[union-attr]
70
+ heading_level = match.heading.level # type: ignore[union-attr]
71
+
72
+ # Remove the section
73
+ new_lines = (
74
+ file_ctx.lines[: match.start_line] + file_ctx.lines[match.end_line :]
75
+ )
76
+
77
+ # If the removed section had a numbered heading, decrement subsequent sections
78
+ if section_num:
79
+ new_lines = renumber_headings_after(
80
+ new_lines,
81
+ start_line=match.start_line,
82
+ heading_level=heading_level,
83
+ increment=False,
84
+ )
85
+
86
+ # Write the modified file
87
+ await write_markdown_file(file_ctx, new_lines)
88
+
89
+ # Track the file operation
90
+ ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
91
+
92
+ logger.debug(
93
+ "Successfully removed section '%s' from %s",
94
+ match.heading.text, # type: ignore[union-attr]
95
+ filename,
96
+ )
97
+
98
+ confidence_display = f"{int(match.confidence * 100)}%"
99
+
100
+ return (
101
+ f"Successfully removed section '{match.heading.text}' from {filename} " # type: ignore[union-attr]
102
+ f"(matched with {confidence_display} confidence, {removed_lines} lines removed)"
103
+ )
104
+
105
+ except ValueError as e:
106
+ # Path validation errors
107
+ error_msg = f"Error removing section from '{filename}': {e}"
108
+ logger.error("Section removal failed: %s", error_msg)
109
+ return error_msg
110
+
111
+ except Exception as e:
112
+ error_msg = f"Error removing section from '{filename}': {e}"
113
+ logger.error("Section removal failed: %s", error_msg)
114
+ return error_msg
@@ -0,0 +1,119 @@
1
+ """Tool for replacing markdown sections."""
2
+
3
+ from pydantic_ai import RunContext
4
+
5
+ from shotgun.agents.models import AgentDeps, FileOperationType
6
+ from shotgun.agents.tools.file_management import _validate_agent_scoped_path
7
+ from shotgun.agents.tools.registry import ToolCategory, register_tool
8
+ from shotgun.logging_config import get_logger
9
+
10
+ from .utils import (
11
+ find_and_validate_section,
12
+ load_markdown_file,
13
+ split_normalized_content,
14
+ write_markdown_file,
15
+ )
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ @register_tool(
21
+ category=ToolCategory.ARTIFACT_MANAGEMENT,
22
+ display_text="Replacing section",
23
+ key_arg="filename",
24
+ secondary_key_arg="section_heading",
25
+ )
26
+ async def replace_markdown_section(
27
+ ctx: RunContext[AgentDeps],
28
+ filename: str,
29
+ section_heading: str,
30
+ new_contents: str,
31
+ new_heading: str | None = None,
32
+ ) -> str:
33
+ """Replace an entire section in a Markdown file.
34
+
35
+ PREFER THIS TOOL over rewriting the entire file - it is faster, less costly,
36
+ and less error-prone.
37
+
38
+ Uses fuzzy matching on headings so minor typos are tolerated.
39
+ Replaces from the target heading down to (but not including) the next
40
+ heading at the same or higher level.
41
+
42
+ Args:
43
+ ctx: Run context with agent dependencies
44
+ filename: Path to the Markdown file (relative to .shotgun directory)
45
+ section_heading: The heading to find (e.g., '## Requirements'). Fuzzy matched.
46
+ new_contents: The new content for the section body (not including the heading)
47
+ new_heading: Optional new heading text to replace the old one
48
+
49
+ Returns:
50
+ Success message or error message
51
+ """
52
+ logger.debug("Replacing section '%s' in: %s", section_heading, filename)
53
+
54
+ try:
55
+ # Validate path with agent scoping
56
+ file_path = _validate_agent_scoped_path(filename, ctx.deps.agent_mode)
57
+
58
+ # Load and parse the markdown file
59
+ file_ctx = await load_markdown_file(file_path, filename)
60
+ if isinstance(file_ctx, str):
61
+ return file_ctx # Error message
62
+
63
+ # Find and validate the target section
64
+ match = find_and_validate_section(file_ctx, section_heading)
65
+ if not match.is_success:
66
+ return match.error # type: ignore[return-value]
67
+
68
+ old_section_lines = match.end_line - match.start_line
69
+
70
+ # Build new section
71
+ final_heading = new_heading if new_heading else match.heading.text # type: ignore[union-attr]
72
+ new_content_lines = split_normalized_content(new_contents)
73
+
74
+ # Build the new section: heading + blank line + content
75
+ new_section_lines = [final_heading, ""]
76
+ new_section_lines.extend(new_content_lines)
77
+
78
+ # Add trailing blank line if not at EOF
79
+ if match.end_line < len(file_ctx.lines):
80
+ new_section_lines.append("")
81
+
82
+ # Replace section
83
+ new_lines = (
84
+ file_ctx.lines[: match.start_line]
85
+ + new_section_lines
86
+ + file_ctx.lines[match.end_line :]
87
+ )
88
+
89
+ # Write the modified file
90
+ await write_markdown_file(file_ctx, new_lines)
91
+
92
+ # Track the file operation
93
+ ctx.deps.file_tracker.add_operation(file_path, FileOperationType.UPDATED)
94
+
95
+ logger.debug(
96
+ "Successfully replaced section '%s' in %s",
97
+ match.heading.text, # type: ignore[union-attr]
98
+ filename,
99
+ )
100
+
101
+ new_section_line_count = len(new_section_lines)
102
+ confidence_display = f"{int(match.confidence * 100)}%"
103
+
104
+ return (
105
+ f"Successfully replaced section '{match.heading.text}' in {filename} " # type: ignore[union-attr]
106
+ f"(matched with {confidence_display} confidence, "
107
+ f"{old_section_lines} lines -> {new_section_line_count} lines)"
108
+ )
109
+
110
+ except ValueError as e:
111
+ # Path validation errors
112
+ error_msg = f"Error replacing section in '{filename}': {e}"
113
+ logger.error("Section replacement failed: %s", error_msg)
114
+ return error_msg
115
+
116
+ except Exception as e:
117
+ error_msg = f"Error replacing section in '{filename}': {e}"
118
+ logger.error("Section replacement failed: %s", error_msg)
119
+ return error_msg