emdash-core 0.1.7__py3-none-any.whl → 0.1.33__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.
Files changed (55) hide show
  1. emdash_core/__init__.py +6 -1
  2. emdash_core/agent/__init__.py +4 -0
  3. emdash_core/agent/events.py +52 -1
  4. emdash_core/agent/inprocess_subagent.py +123 -10
  5. emdash_core/agent/prompts/__init__.py +6 -0
  6. emdash_core/agent/prompts/main_agent.py +53 -3
  7. emdash_core/agent/prompts/plan_mode.py +255 -0
  8. emdash_core/agent/prompts/subagents.py +84 -16
  9. emdash_core/agent/prompts/workflow.py +270 -56
  10. emdash_core/agent/providers/base.py +4 -0
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/models.py +7 -0
  13. emdash_core/agent/providers/openai_provider.py +137 -13
  14. emdash_core/agent/runner/__init__.py +49 -0
  15. emdash_core/agent/runner/agent_runner.py +753 -0
  16. emdash_core/agent/runner/context.py +451 -0
  17. emdash_core/agent/runner/factory.py +108 -0
  18. emdash_core/agent/runner/plan.py +217 -0
  19. emdash_core/agent/runner/sdk_runner.py +324 -0
  20. emdash_core/agent/runner/utils.py +67 -0
  21. emdash_core/agent/skills.py +358 -0
  22. emdash_core/agent/toolkit.py +85 -5
  23. emdash_core/agent/toolkits/plan.py +9 -11
  24. emdash_core/agent/tools/__init__.py +3 -2
  25. emdash_core/agent/tools/coding.py +48 -4
  26. emdash_core/agent/tools/modes.py +207 -55
  27. emdash_core/agent/tools/search.py +4 -0
  28. emdash_core/agent/tools/skill.py +193 -0
  29. emdash_core/agent/tools/spec.py +61 -94
  30. emdash_core/agent/tools/task.py +41 -2
  31. emdash_core/agent/tools/tasks.py +15 -78
  32. emdash_core/api/agent.py +562 -8
  33. emdash_core/api/index.py +1 -1
  34. emdash_core/api/projectmd.py +4 -2
  35. emdash_core/api/router.py +2 -0
  36. emdash_core/api/skills.py +241 -0
  37. emdash_core/checkpoint/__init__.py +40 -0
  38. emdash_core/checkpoint/cli.py +175 -0
  39. emdash_core/checkpoint/git_operations.py +250 -0
  40. emdash_core/checkpoint/manager.py +231 -0
  41. emdash_core/checkpoint/models.py +107 -0
  42. emdash_core/checkpoint/storage.py +201 -0
  43. emdash_core/config.py +1 -1
  44. emdash_core/core/config.py +18 -2
  45. emdash_core/graph/schema.py +5 -5
  46. emdash_core/ingestion/orchestrator.py +19 -10
  47. emdash_core/models/agent.py +1 -1
  48. emdash_core/server.py +42 -0
  49. emdash_core/skills/frontend-design/SKILL.md +56 -0
  50. emdash_core/sse/stream.py +5 -0
  51. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -2
  52. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/RECORD +54 -37
  53. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +1 -0
  54. emdash_core/agent/runner.py +0 -601
  55. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
@@ -0,0 +1,358 @@
1
+ """Skill loader from .emdash/skills/*/SKILL.md files.
2
+
3
+ Skills are markdown-based instruction files that teach the agent how to
4
+ perform specific, repeatable tasks. They can be automatically applied
5
+ when relevant or explicitly invoked via /skill_name.
6
+
7
+ Similar to Claude Code's skills system:
8
+ https://docs.anthropic.com/en/docs/claude-code/skills
9
+
10
+ Skills are loaded from two locations:
11
+ 1. Built-in skills bundled with emdash_core (always available)
12
+ 2. User repo skills in .emdash/skills/ (can override built-in)
13
+ """
14
+
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from ..utils.logger import log
20
+
21
+
22
+ def _get_builtin_skills_dir() -> Path:
23
+ """Get the directory containing built-in skills bundled with emdash_core."""
24
+ return Path(__file__).parent.parent / "skills"
25
+
26
+
27
+ @dataclass
28
+ class Skill:
29
+ """A skill configuration loaded from SKILL.md.
30
+
31
+ Attributes:
32
+ name: Unique skill identifier (from directory name or frontmatter)
33
+ description: Brief description of when to use this skill
34
+ instructions: The main prompt/instructions content
35
+ tools: List of tools this skill needs access to
36
+ user_invocable: Whether skill can be invoked with /name
37
+ file_path: Source file path
38
+ _builtin: Whether this is a built-in skill bundled with emdash_core
39
+ """
40
+
41
+ name: str
42
+ description: str = ""
43
+ instructions: str = ""
44
+ tools: list[str] = field(default_factory=list)
45
+ user_invocable: bool = False
46
+ file_path: Optional[Path] = None
47
+ _builtin: bool = False
48
+
49
+
50
+ class SkillRegistry:
51
+ """Registry for managing loaded skills.
52
+
53
+ Singleton that maintains the list of available skills
54
+ and provides lookup functionality.
55
+ """
56
+
57
+ _instance: Optional["SkillRegistry"] = None
58
+ _skills: dict[str, Skill]
59
+ _skills_dir: Optional[Path]
60
+
61
+ def __new__(cls):
62
+ if cls._instance is None:
63
+ cls._instance = super().__new__(cls)
64
+ cls._instance._skills = {}
65
+ cls._instance._skills_dir = None
66
+ return cls._instance
67
+
68
+ @classmethod
69
+ def get_instance(cls) -> "SkillRegistry":
70
+ """Get the singleton instance."""
71
+ return cls()
72
+
73
+ @classmethod
74
+ def reset(cls) -> None:
75
+ """Reset the singleton instance."""
76
+ if cls._instance is not None:
77
+ cls._instance._skills = {}
78
+ cls._instance._skills_dir = None
79
+
80
+ def load_skills(self, skills_dir: Optional[Path] = None) -> dict[str, Skill]:
81
+ """Load skills from built-in and user repo directories.
82
+
83
+ Skills are loaded from two locations (in order):
84
+ 1. Built-in skills bundled with emdash_core (always available)
85
+ 2. User repo skills in .emdash/skills/ (can override built-in)
86
+
87
+ Each skill is a directory containing a SKILL.md file:
88
+
89
+ ```
90
+ .emdash/skills/
91
+ ├── commit/
92
+ │ └── SKILL.md
93
+ └── review-pr/
94
+ └── SKILL.md
95
+ ```
96
+
97
+ SKILL.md format:
98
+ ```markdown
99
+ ---
100
+ name: commit
101
+ description: Generate commit messages following conventions
102
+ user_invocable: true
103
+ tools: [execute_command, read_file]
104
+ ---
105
+
106
+ # Commit Message Generation
107
+
108
+ Instructions for the skill...
109
+ ```
110
+
111
+ Args:
112
+ skills_dir: Directory containing user skill subdirectories.
113
+ Defaults to .emdash/skills/ in cwd.
114
+
115
+ Returns:
116
+ Dict mapping skill name to Skill
117
+ """
118
+ if skills_dir is None:
119
+ skills_dir = Path.cwd() / ".emdash" / "skills"
120
+
121
+ self._skills_dir = skills_dir
122
+
123
+ skills = {}
124
+
125
+ # First, load built-in skills bundled with emdash_core
126
+ builtin_dir = _get_builtin_skills_dir()
127
+ if builtin_dir.exists():
128
+ builtin_skills = self._load_skills_from_dir(builtin_dir, is_builtin=True)
129
+ skills.update(builtin_skills)
130
+
131
+ # Then, load user repo skills (can override built-in)
132
+ if skills_dir.exists():
133
+ user_skills = self._load_skills_from_dir(skills_dir, is_builtin=False)
134
+ skills.update(user_skills)
135
+
136
+ if skills:
137
+ log.info(f"Loaded {len(skills)} skills ({len([s for s in self._skills.values() if getattr(s, '_builtin', False)])} built-in)")
138
+
139
+ return skills
140
+
141
+ def _load_skills_from_dir(self, skills_dir: Path, is_builtin: bool = False) -> dict[str, Skill]:
142
+ """Load skills from a specific directory.
143
+
144
+ Args:
145
+ skills_dir: Directory containing skill subdirectories
146
+ is_builtin: Whether these are built-in skills
147
+
148
+ Returns:
149
+ Dict mapping skill name to Skill
150
+ """
151
+ skills = {}
152
+
153
+ # Look for SKILL.md in subdirectories
154
+ for skill_dir in skills_dir.iterdir():
155
+ if not skill_dir.is_dir():
156
+ continue
157
+
158
+ skill_file = skill_dir / "SKILL.md"
159
+ if not skill_file.exists():
160
+ # Also try lowercase
161
+ skill_file = skill_dir / "skill.md"
162
+ if not skill_file.exists():
163
+ continue
164
+
165
+ try:
166
+ skill = _parse_skill_file(skill_file, skill_dir.name)
167
+ if skill:
168
+ skill._builtin = is_builtin # Mark as built-in or user-defined
169
+ skills[skill.name] = skill
170
+ self._skills[skill.name] = skill
171
+ source = "built-in" if is_builtin else "user"
172
+ log.debug(f"Loaded {source} skill: {skill.name}")
173
+ except Exception as e:
174
+ log.warning(f"Failed to load skill from {skill_file}: {e}")
175
+
176
+ return skills
177
+
178
+ def get_skill(self, name: str) -> Optional[Skill]:
179
+ """Get a specific skill by name.
180
+
181
+ Args:
182
+ name: Skill name
183
+
184
+ Returns:
185
+ Skill or None if not found
186
+ """
187
+ return self._skills.get(name)
188
+
189
+ def list_skills(self) -> list[str]:
190
+ """List available skill names.
191
+
192
+ Returns:
193
+ List of skill names
194
+ """
195
+ return list(self._skills.keys())
196
+
197
+ def get_all_skills(self) -> dict[str, Skill]:
198
+ """Get all loaded skills.
199
+
200
+ Returns:
201
+ Dict mapping skill name to Skill
202
+ """
203
+ return self._skills.copy()
204
+
205
+ def get_user_invocable_skills(self) -> list[Skill]:
206
+ """Get skills that can be invoked with /name.
207
+
208
+ Returns:
209
+ List of user-invocable skills
210
+ """
211
+ return [s for s in self._skills.values() if s.user_invocable]
212
+
213
+ def get_skills_for_prompt(self) -> str:
214
+ """Generate skills section for system prompt.
215
+
216
+ Returns:
217
+ Formatted string describing available skills
218
+ """
219
+ if not self._skills:
220
+ return ""
221
+
222
+ lines = ["## Available Skills\n"]
223
+ lines.append("The following skills are available. Use them when the task matches their description:\n")
224
+
225
+ for skill in self._skills.values():
226
+ invocable = " (user-invocable: /{})".format(skill.name) if skill.user_invocable else ""
227
+ lines.append(f"- **{skill.name}**: {skill.description}{invocable}")
228
+
229
+ lines.append("")
230
+ lines.append("To activate a skill, use the `skill` tool with the skill name.")
231
+ lines.append("")
232
+
233
+ return "\n".join(lines)
234
+
235
+
236
+ def _parse_skill_file(file_path: Path, default_name: str) -> Optional[Skill]:
237
+ """Parse a single SKILL.md file.
238
+
239
+ Args:
240
+ file_path: Path to the SKILL.md file
241
+ default_name: Default name from directory (used if not in frontmatter)
242
+
243
+ Returns:
244
+ Skill or None if parsing fails
245
+ """
246
+ content = file_path.read_text()
247
+
248
+ # Extract frontmatter
249
+ frontmatter = {}
250
+ body = content
251
+
252
+ if content.startswith("---"):
253
+ parts = content.split("---", 2)
254
+ if len(parts) >= 3:
255
+ frontmatter = _parse_frontmatter(parts[1])
256
+ body = parts[2].strip()
257
+
258
+ # Get name from frontmatter or use directory name
259
+ name = frontmatter.get("name", default_name)
260
+
261
+ # Validate name format (lowercase, hyphens, max 64 chars)
262
+ if len(name) > 64:
263
+ log.warning(f"Skill name '{name}' exceeds 64 characters, truncating")
264
+ name = name[:64]
265
+
266
+ return Skill(
267
+ name=name,
268
+ description=frontmatter.get("description", ""),
269
+ instructions=body,
270
+ tools=frontmatter.get("tools", []),
271
+ user_invocable=frontmatter.get("user_invocable", False),
272
+ file_path=file_path,
273
+ )
274
+
275
+
276
+ def _parse_frontmatter(frontmatter_str: str) -> dict:
277
+ """Parse YAML-like frontmatter.
278
+
279
+ Simple parser for key: value pairs.
280
+
281
+ Args:
282
+ frontmatter_str: Frontmatter string
283
+
284
+ Returns:
285
+ Dict of parsed values
286
+ """
287
+ result = {}
288
+
289
+ for line in frontmatter_str.strip().split("\n"):
290
+ if ":" not in line:
291
+ continue
292
+
293
+ key, value = line.split(":", 1)
294
+ key = key.strip()
295
+ value = value.strip()
296
+
297
+ # Parse boolean values
298
+ if value.lower() == "true":
299
+ result[key] = True
300
+ elif value.lower() == "false":
301
+ result[key] = False
302
+ # Parse list values
303
+ elif value.startswith("[") and value.endswith("]"):
304
+ items = value[1:-1].split(",")
305
+ result[key] = [item.strip().strip("'\"") for item in items if item.strip()]
306
+ else:
307
+ result[key] = value.strip("'\"")
308
+
309
+ return result
310
+
311
+
312
+ # Convenience functions
313
+
314
+
315
+ def load_skills(skills_dir: Optional[Path] = None) -> dict[str, Skill]:
316
+ """Load skills from directory.
317
+
318
+ Args:
319
+ skills_dir: Optional skills directory
320
+
321
+ Returns:
322
+ Dict mapping skill name to Skill
323
+ """
324
+ registry = SkillRegistry.get_instance()
325
+ return registry.load_skills(skills_dir)
326
+
327
+
328
+ def get_skill(name: str) -> Optional[Skill]:
329
+ """Get a specific skill by name.
330
+
331
+ Args:
332
+ name: Skill name
333
+
334
+ Returns:
335
+ Skill or None if not found
336
+ """
337
+ registry = SkillRegistry.get_instance()
338
+ return registry.get_skill(name)
339
+
340
+
341
+ def list_skills() -> list[str]:
342
+ """List available skill names.
343
+
344
+ Returns:
345
+ List of skill names
346
+ """
347
+ registry = SkillRegistry.get_instance()
348
+ return registry.list_skills()
349
+
350
+
351
+ def get_user_invocable_skills() -> list[Skill]:
352
+ """Get skills that can be invoked with /name.
353
+
354
+ Returns:
355
+ List of user-invocable skills
356
+ """
357
+ registry = SkillRegistry.get_instance()
358
+ return registry.get_user_invocable_skills()
@@ -41,6 +41,7 @@ class AgentToolkit:
41
41
  repo_root: Optional[Path] = None,
42
42
  plan_mode: bool = False,
43
43
  save_spec_path: Optional[Path] = None,
44
+ plan_file_path: Optional[str] = None,
44
45
  ):
45
46
  """Initialize the agent toolkit.
46
47
 
@@ -53,6 +54,7 @@ class AgentToolkit:
53
54
  If None, uses repo_root from config or current working directory.
54
55
  plan_mode: Whether to include spec planning tools and restrict to read-only.
55
56
  save_spec_path: If provided, specs will be saved to this path.
57
+ plan_file_path: Path to the plan file (only writable file in plan mode).
56
58
  """
57
59
  self.connection = connection or get_connection()
58
60
  self.session = AgentSession() if enable_session else None
@@ -61,6 +63,7 @@ class AgentToolkit:
61
63
  self._mcp_config_path = mcp_config_path
62
64
  self.plan_mode = plan_mode
63
65
  self.save_spec_path = save_spec_path
66
+ self.plan_file_path = plan_file_path
64
67
 
65
68
  # Get repo_root from config if not explicitly provided
66
69
  if repo_root is None:
@@ -70,8 +73,12 @@ class AgentToolkit:
70
73
  repo_root = Path(config.repo_root)
71
74
  self._repo_root = repo_root or Path.cwd()
72
75
 
73
- # Configure spec state if plan mode
76
+ # Configure mode state and spec state if plan mode
74
77
  if plan_mode:
78
+ from .tools.modes import ModeState, AgentMode
79
+ mode_state = ModeState.get_instance()
80
+ mode_state.current_mode = AgentMode.PLAN
81
+
75
82
  from .tools.spec import SpecState
76
83
  spec_state = SpecState.get_instance()
77
84
  spec_state.configure(save_path=save_spec_path)
@@ -103,12 +110,25 @@ class AgentToolkit:
103
110
  self.register_tool(GlobTool(self.connection))
104
111
  self.register_tool(WebTool(self.connection))
105
112
 
113
+ # Register skill tools and load skills
114
+ self._register_skill_tools()
115
+
106
116
  # Register read-only file tools (always available)
107
117
  self.register_tool(ReadFileTool(self._repo_root, self.connection))
108
118
  self.register_tool(ListFilesTool(self._repo_root, self.connection))
109
119
 
110
- # Register write tools (only in non-plan mode)
111
- if not self.plan_mode:
120
+ # Register write tools
121
+ if self.plan_mode:
122
+ # In plan mode: only allow writing to the plan file
123
+ if self.plan_file_path:
124
+ from .tools.coding import WriteToFileTool
125
+ self.register_tool(WriteToFileTool(
126
+ self._repo_root,
127
+ self.connection,
128
+ allowed_paths=[self.plan_file_path],
129
+ ))
130
+ else:
131
+ # In code mode: full write access
112
132
  from .tools.coding import (
113
133
  WriteToFileTool,
114
134
  ApplyDiffTool,
@@ -123,8 +143,15 @@ class AgentToolkit:
123
143
  # Register sub-agent tools for spawning lightweight agents
124
144
  self._register_subagent_tools()
125
145
 
126
- # Register task management tools (not in plan mode)
127
- if not self.plan_mode:
146
+ # Register mode tools
147
+ self._register_mode_tools()
148
+
149
+ # Register task management tools
150
+ # In plan mode: only register ask_followup_question for clarifications
151
+ # In code mode: register all task tools
152
+ if self.plan_mode:
153
+ self._register_plan_mode_task_tools()
154
+ else:
128
155
  self._register_task_tools()
129
156
 
130
157
  # Register spec planning tools (only in plan mode)
@@ -153,6 +180,36 @@ class AgentToolkit:
153
180
  self.register_tool(TaskTool(repo_root=self._repo_root, connection=self.connection))
154
181
  self.register_tool(TaskOutputTool(repo_root=self._repo_root, connection=self.connection))
155
182
 
183
+ def _register_mode_tools(self) -> None:
184
+ """Register mode switching tools.
185
+
186
+ - enter_plan_mode: Available in code mode to request entering plan mode
187
+ - exit_plan: Available in both modes to submit plan for approval
188
+ - get_mode: Always available to check current mode
189
+ """
190
+ from .tools.modes import EnterPlanModeTool, ExitPlanModeTool, GetModeTool
191
+
192
+ # get_mode is always available
193
+ self.register_tool(GetModeTool())
194
+
195
+ # exit_plan is available in both modes:
196
+ # - In plan mode: submit plan written to plan file
197
+ # - In code mode: submit plan received from Plan subagent
198
+ self.register_tool(ExitPlanModeTool())
199
+
200
+ if not self.plan_mode:
201
+ # In code mode: can also request to enter plan mode
202
+ self.register_tool(EnterPlanModeTool())
203
+
204
+ def _register_plan_mode_task_tools(self) -> None:
205
+ """Register subset of task tools for plan mode.
206
+
207
+ In plan mode, the agent can ask clarifying questions but
208
+ doesn't need completion/todo tools since exit_plan handles that.
209
+ """
210
+ from .tools.tasks import AskFollowupQuestionTool
211
+ self.register_tool(AskFollowupQuestionTool())
212
+
156
213
  def _register_task_tools(self) -> None:
157
214
  """Register task management tools.
158
215
 
@@ -187,6 +244,29 @@ class AgentToolkit:
187
244
  self.register_tool(GetSpecTool())
188
245
  self.register_tool(UpdateSpecTool())
189
246
 
247
+ def _register_skill_tools(self) -> None:
248
+ """Register skill tools and load skills from .emdash/skills/.
249
+
250
+ Skills are markdown-based instruction files that teach the agent
251
+ how to perform specific, repeatable tasks. Similar to Claude Code's
252
+ skills system.
253
+ """
254
+ from .tools.skill import SkillTool, ListSkillsTool
255
+ from .skills import SkillRegistry
256
+
257
+ # Load skills from .emdash/skills/
258
+ skills_dir = self._repo_root / ".emdash" / "skills"
259
+ registry = SkillRegistry.get_instance()
260
+ registry.load_skills(skills_dir)
261
+
262
+ # Register skill tools
263
+ self.register_tool(SkillTool(self.connection))
264
+ self.register_tool(ListSkillsTool(self.connection))
265
+
266
+ skills_count = len(registry.list_skills())
267
+ if skills_count > 0:
268
+ log.info(f"Registered skill tools with {skills_count} skills available")
269
+
190
270
  def _register_mcp_tools(self) -> None:
191
271
  """Register GitHub MCP tools if available.
192
272
 
@@ -1,19 +1,22 @@
1
- """Plan toolkit - exploration tools plus plan writing capability."""
1
+ """Plan toolkit - read-only exploration tools for planning.
2
+
3
+ The Plan subagent explores the codebase and returns a plan as text.
4
+ The main agent (in plan mode) writes the plan to .emdash/<feature>.md.
5
+ """
2
6
 
3
7
  from pathlib import Path
4
8
 
5
9
  from .base import BaseToolkit
6
10
  from ..tools.coding import ReadFileTool, ListFilesTool
7
11
  from ..tools.search import SemanticSearchTool, GrepTool, GlobTool
8
- from ..tools.plan_write import WritePlanTool
9
12
  from ...utils.logger import log
10
13
 
11
14
 
12
15
  class PlanToolkit(BaseToolkit):
13
- """Toolkit for planning with limited write access (plan files only).
16
+ """Read-only toolkit for Plan subagent.
14
17
 
15
- Provides all read-only exploration tools plus the ability to write
16
- implementation plans to .emdash/plans/*.md.
18
+ The Plan subagent explores the codebase and returns a structured plan.
19
+ It does NOT write files - the main agent handles that.
17
20
 
18
21
  Tools available:
19
22
  - read_file: Read file contents
@@ -21,7 +24,6 @@ class PlanToolkit(BaseToolkit):
21
24
  - glob: Find files by pattern
22
25
  - grep: Search file contents
23
26
  - semantic_search: AI-powered code search
24
- - write_plan: Write implementation plans (restricted to .emdash/plans/)
25
27
  """
26
28
 
27
29
  TOOLS = [
@@ -30,11 +32,10 @@ class PlanToolkit(BaseToolkit):
30
32
  "glob",
31
33
  "grep",
32
34
  "semantic_search",
33
- "write_plan",
34
35
  ]
35
36
 
36
37
  def _register_tools(self) -> None:
37
- """Register exploration and plan writing tools."""
38
+ """Register read-only exploration tools."""
38
39
  # All read-only exploration tools
39
40
  self.register_tool(ReadFileTool(repo_root=self.repo_root))
40
41
  self.register_tool(ListFilesTool(repo_root=self.repo_root))
@@ -49,7 +50,4 @@ class PlanToolkit(BaseToolkit):
49
50
  except Exception as e:
50
51
  log.debug(f"Semantic search not available: {e}")
51
52
 
52
- # Special: can only write to .emdash/plans/*.md
53
- self.register_tool(WritePlanTool(repo_root=self.repo_root))
54
-
55
53
  log.debug(f"PlanToolkit registered {len(self._tools)} tools")
@@ -43,7 +43,7 @@ from .tasks import (
43
43
  from .plan import PlanExplorationTool
44
44
 
45
45
  # Mode tools
46
- from .modes import AgentMode, ModeState, SwitchModeTool, GetModeTool
46
+ from .modes import AgentMode, ModeState, EnterPlanModeTool, ExitPlanModeTool, GetModeTool
47
47
 
48
48
  # Spec tools
49
49
  from .spec import SubmitSpecTool, GetSpecTool, UpdateSpecTool
@@ -111,7 +111,8 @@ __all__ = [
111
111
  # Mode
112
112
  "AgentMode",
113
113
  "ModeState",
114
- "SwitchModeTool",
114
+ "EnterPlanModeTool",
115
+ "ExitPlanModeTool",
115
116
  "GetModeTool",
116
117
  # Spec
117
118
  "SubmitSpecTool",
@@ -64,6 +64,8 @@ Returns the file content as text."""
64
64
  path: str,
65
65
  start_line: Optional[int] = None,
66
66
  end_line: Optional[int] = None,
67
+ offset: Optional[int] = None,
68
+ limit: Optional[int] = None,
67
69
  ) -> ToolResult:
68
70
  """Read a file.
69
71
 
@@ -71,6 +73,8 @@ Returns the file content as text."""
71
73
  path: Path to the file
72
74
  start_line: Optional starting line (1-indexed)
73
75
  end_line: Optional ending line (1-indexed)
76
+ offset: Alternative to start_line - line number to start from (1-indexed)
77
+ limit: Alternative to end_line - number of lines to read
74
78
 
75
79
  Returns:
76
80
  ToolResult with file content
@@ -89,8 +93,14 @@ Returns the file content as text."""
89
93
  content = full_path.read_text()
90
94
  lines = content.split("\n")
91
95
 
92
- # Handle line ranges
93
- if start_line or end_line:
96
+ # Handle line ranges - support both start_line/end_line and offset/limit
97
+ # offset/limit take precedence if provided
98
+ if offset is not None or limit is not None:
99
+ start_idx = (offset - 1) if offset else 0
100
+ end_idx = start_idx + limit if limit else len(lines)
101
+ lines = lines[start_idx:end_idx]
102
+ content = "\n".join(lines)
103
+ elif start_line or end_line:
94
104
  start_idx = (start_line - 1) if start_line else 0
95
105
  end_idx = end_line if end_line else len(lines)
96
106
  lines = lines[start_idx:end_idx]
@@ -123,6 +133,14 @@ Returns the file content as text."""
123
133
  "type": "integer",
124
134
  "description": "Ending line number (1-indexed)",
125
135
  },
136
+ "offset": {
137
+ "type": "integer",
138
+ "description": "Line number to start reading from (1-indexed). Alternative to start_line.",
139
+ },
140
+ "limit": {
141
+ "type": "integer",
142
+ "description": "Number of lines to read. Alternative to end_line.",
143
+ },
126
144
  },
127
145
  required=["path"],
128
146
  )
@@ -135,6 +153,18 @@ class WriteToFileTool(CodingTool):
135
153
  description = """Write content to a file.
136
154
  Creates the file if it doesn't exist, or overwrites if it does."""
137
155
 
156
+ def __init__(self, repo_root: Path, connection=None, allowed_paths: list[str] | None = None):
157
+ """Initialize with optional path restrictions.
158
+
159
+ Args:
160
+ repo_root: Root directory of the repository
161
+ connection: Optional connection (not used for file ops)
162
+ allowed_paths: If provided, only these paths can be written to.
163
+ Used in plan mode to restrict writes to the plan file.
164
+ """
165
+ super().__init__(repo_root, connection)
166
+ self.allowed_paths = allowed_paths
167
+
138
168
  def execute(
139
169
  self,
140
170
  path: str,
@@ -153,6 +183,19 @@ Creates the file if it doesn't exist, or overwrites if it does."""
153
183
  if not valid:
154
184
  return ToolResult.error_result(error)
155
185
 
186
+ # Check allowed paths restriction (used in plan mode)
187
+ if self.allowed_paths is not None:
188
+ path_str = str(full_path)
189
+ is_allowed = any(
190
+ path_str == allowed or path_str.endswith(allowed.lstrip("/"))
191
+ for allowed in self.allowed_paths
192
+ )
193
+ if not is_allowed:
194
+ return ToolResult.error_result(
195
+ f"In plan mode, you can only write to: {', '.join(self.allowed_paths)}",
196
+ suggestions=["Write your plan to the designated plan file"],
197
+ )
198
+
156
199
  try:
157
200
  # Create parent directories
158
201
  full_path.parent.mkdir(parents=True, exist_ok=True)
@@ -218,8 +261,9 @@ The diff should be in standard unified diff format."""
218
261
 
219
262
  try:
220
263
  # Try to apply with patch command
264
+ # --batch: suppress questions, --forward: skip already-applied patches
221
265
  result = subprocess.run(
222
- ["patch", "-p0", "--forward"],
266
+ ["patch", "-p0", "--forward", "--batch"],
223
267
  input=diff,
224
268
  capture_output=True,
225
269
  text=True,
@@ -230,7 +274,7 @@ The diff should be in standard unified diff format."""
230
274
  if result.returncode != 0:
231
275
  # Try with -p1
232
276
  result = subprocess.run(
233
- ["patch", "-p1", "--forward"],
277
+ ["patch", "-p1", "--forward", "--batch"],
234
278
  input=diff,
235
279
  capture_output=True,
236
280
  text=True,