steerdev 0.4.27__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 (57) hide show
  1. steerdev-0.4.27.dist-info/METADATA +224 -0
  2. steerdev-0.4.27.dist-info/RECORD +57 -0
  3. steerdev-0.4.27.dist-info/WHEEL +4 -0
  4. steerdev-0.4.27.dist-info/entry_points.txt +2 -0
  5. steerdev_agent/__init__.py +10 -0
  6. steerdev_agent/api/__init__.py +32 -0
  7. steerdev_agent/api/activity.py +278 -0
  8. steerdev_agent/api/agents.py +145 -0
  9. steerdev_agent/api/client.py +158 -0
  10. steerdev_agent/api/commands.py +399 -0
  11. steerdev_agent/api/configs.py +238 -0
  12. steerdev_agent/api/context.py +306 -0
  13. steerdev_agent/api/events.py +294 -0
  14. steerdev_agent/api/hooks.py +178 -0
  15. steerdev_agent/api/implementation_plan.py +408 -0
  16. steerdev_agent/api/messages.py +231 -0
  17. steerdev_agent/api/prd.py +281 -0
  18. steerdev_agent/api/runs.py +526 -0
  19. steerdev_agent/api/sessions.py +403 -0
  20. steerdev_agent/api/specs.py +321 -0
  21. steerdev_agent/api/tasks.py +659 -0
  22. steerdev_agent/api/workflow_runs.py +351 -0
  23. steerdev_agent/api/workflows.py +191 -0
  24. steerdev_agent/cli.py +2254 -0
  25. steerdev_agent/config/__init__.py +19 -0
  26. steerdev_agent/config/models.py +236 -0
  27. steerdev_agent/config/platform.py +272 -0
  28. steerdev_agent/config/settings.py +62 -0
  29. steerdev_agent/daemon.py +675 -0
  30. steerdev_agent/executor/__init__.py +64 -0
  31. steerdev_agent/executor/base.py +121 -0
  32. steerdev_agent/executor/claude.py +328 -0
  33. steerdev_agent/executor/stream.py +163 -0
  34. steerdev_agent/git/__init__.py +1 -0
  35. steerdev_agent/handlers/__init__.py +5 -0
  36. steerdev_agent/handlers/prd.py +533 -0
  37. steerdev_agent/integration.py +334 -0
  38. steerdev_agent/prompt/__init__.py +10 -0
  39. steerdev_agent/prompt/builder.py +263 -0
  40. steerdev_agent/prompt/templates.py +422 -0
  41. steerdev_agent/py.typed +0 -0
  42. steerdev_agent/runner.py +829 -0
  43. steerdev_agent/setup/__init__.py +5 -0
  44. steerdev_agent/setup/claude_setup.py +560 -0
  45. steerdev_agent/setup/templates/claude_md_section.md +140 -0
  46. steerdev_agent/setup/templates/settings.json +69 -0
  47. steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
  48. steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
  49. steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
  50. steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
  51. steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
  52. steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
  53. steerdev_agent/setup/templates/steerdev.yaml +51 -0
  54. steerdev_agent/version.py +149 -0
  55. steerdev_agent/workflow/__init__.py +10 -0
  56. steerdev_agent/workflow/executor.py +494 -0
  57. steerdev_agent/workflow/memory.py +185 -0
@@ -0,0 +1,64 @@
1
+ """Executor module for running AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from steerdev_agent.executor.base import AgentExecutor, StreamEvent
8
+ from steerdev_agent.executor.claude import ClaudeExecutor
9
+ from steerdev_agent.executor.stream import StreamParser
10
+
11
+ if TYPE_CHECKING:
12
+ from steerdev_agent.config.models import ExecutorConfig
13
+
14
+ __all__ = [
15
+ "AgentExecutor",
16
+ "ClaudeExecutor",
17
+ "ExecutorFactory",
18
+ "StreamEvent",
19
+ "StreamParser",
20
+ ]
21
+
22
+
23
+ class ExecutorFactory:
24
+ """Factory for creating agent executors based on configuration."""
25
+
26
+ @staticmethod
27
+ def create(
28
+ config: ExecutorConfig,
29
+ working_directory: str,
30
+ model: str | None = None,
31
+ max_turns: int | None = None,
32
+ dry_run: bool = False,
33
+ worktree_name: str | None = None,
34
+ ) -> AgentExecutor:
35
+ """Create an executor based on the configuration.
36
+
37
+ Args:
38
+ config: Executor configuration specifying type and settings.
39
+ working_directory: Directory to run the agent in.
40
+ model: Model override (takes precedence over config).
41
+ max_turns: Maximum number of agent turns.
42
+ dry_run: If True, print command without executing.
43
+ worktree_name: Name for Claude CLI --worktree flag.
44
+
45
+ Returns:
46
+ Configured AgentExecutor instance.
47
+
48
+ Raises:
49
+ ValueError: If executor type is not supported.
50
+ """
51
+ if config.type == "claude":
52
+ return ClaudeExecutor(
53
+ working_directory=working_directory,
54
+ model=model,
55
+ max_turns=max_turns,
56
+ allowed_tools=config.allowed_tools or None,
57
+ disallowed_tools=config.disallowed_tools or None,
58
+ mcp_config=config.mcp_config,
59
+ permission_mode=config.permission_mode,
60
+ dry_run=dry_run,
61
+ worktree_name=worktree_name,
62
+ )
63
+
64
+ raise ValueError(f"Unsupported executor type: {config.type}")
@@ -0,0 +1,121 @@
1
+ """Base executor interface for AI agents."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncIterator
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ class EventType(str, Enum):
12
+ """Types of events emitted by agents."""
13
+
14
+ SYSTEM = "system"
15
+ USER = "user"
16
+ ASSISTANT = "assistant"
17
+ TOOL_USE = "tool_use"
18
+ TOOL_RESULT = "tool_result"
19
+ ERROR = "error"
20
+ RESULT = "result"
21
+
22
+
23
+ @dataclass
24
+ class StreamEvent:
25
+ """An event from the agent's output stream."""
26
+
27
+ event_type: EventType
28
+ timestamp: datetime
29
+ data: dict[str, Any]
30
+ raw_json: str
31
+
32
+ @property
33
+ def is_final(self) -> bool:
34
+ """Check if this is the final event (result)."""
35
+ return self.event_type == EventType.RESULT
36
+
37
+
38
+ class AgentExecutor(ABC):
39
+ """Abstract base class for agent executors.
40
+
41
+ Executors handle launching and managing CLI agents (Claude Code, Codex, Aider)
42
+ as subprocesses, streaming their JSON output.
43
+ """
44
+
45
+ def __init__(self, working_directory: str) -> None:
46
+ """Initialize the executor.
47
+
48
+ Args:
49
+ working_directory: Directory to run the agent in.
50
+ """
51
+ self.working_directory = working_directory
52
+ self._session_id: str | None = None
53
+
54
+ @property
55
+ def session_id(self) -> str | None:
56
+ """Get the agent's native session ID for resume support."""
57
+ return self._session_id
58
+
59
+ @property
60
+ @abstractmethod
61
+ def agent_type(self) -> str:
62
+ """Return the agent type identifier (e.g., 'claude', 'codex', 'aider')."""
63
+ ...
64
+
65
+ @abstractmethod
66
+ async def start(self, prompt: str) -> None:
67
+ """Start the agent with an initial prompt.
68
+
69
+ Args:
70
+ prompt: The initial prompt to send to the agent.
71
+ """
72
+ ...
73
+
74
+ @abstractmethod
75
+ async def resume(self, session_id: str, message: str) -> None:
76
+ """Resume an existing session with a new message.
77
+
78
+ Args:
79
+ session_id: The native session ID to resume.
80
+ message: The message to continue the conversation with.
81
+ """
82
+ ...
83
+
84
+ @abstractmethod
85
+ def stream_events(self) -> AsyncIterator[StreamEvent]:
86
+ """Stream events from the agent's output.
87
+
88
+ Yields:
89
+ StreamEvent objects as they are parsed from the agent's output.
90
+ """
91
+ ...
92
+
93
+ @abstractmethod
94
+ async def stop(self) -> None:
95
+ """Stop the agent process."""
96
+ ...
97
+
98
+ @abstractmethod
99
+ async def wait(self) -> int:
100
+ """Wait for the agent process to complete.
101
+
102
+ Returns:
103
+ The exit code of the agent process.
104
+ """
105
+ ...
106
+
107
+ @property
108
+ @abstractmethod
109
+ def is_running(self) -> bool:
110
+ """Check if the agent process is still running."""
111
+ ...
112
+
113
+ async def get_stderr(self) -> str:
114
+ """Get any stderr output from the agent.
115
+
116
+ Override in subclasses that support stderr capture.
117
+
118
+ Returns:
119
+ Stderr output as a string, empty by default.
120
+ """
121
+ return ""
@@ -0,0 +1,328 @@
1
+ """Claude Code executor implementation."""
2
+
3
+ import asyncio
4
+ import shutil
5
+ from collections.abc import AsyncIterator
6
+
7
+ from loguru import logger
8
+
9
+ from steerdev_agent.executor.base import AgentExecutor, StreamEvent
10
+ from steerdev_agent.executor.stream import StreamParser
11
+
12
+
13
+ class ClaudeExecutorError(Exception):
14
+ """Error during Claude executor operation."""
15
+
16
+ pass
17
+
18
+
19
+ class ClaudeExecutor(AgentExecutor):
20
+ """Executor for Claude Code CLI.
21
+
22
+ Launches Claude Code as a subprocess with JSON streaming output
23
+ and manages the process lifecycle.
24
+ """
25
+
26
+ # Claude Code CLI executable name
27
+ CLAUDE_CLI = "claude"
28
+
29
+ def __init__(
30
+ self,
31
+ working_directory: str,
32
+ model: str | None = None,
33
+ max_turns: int | None = None,
34
+ allowed_tools: list[str] | None = None,
35
+ disallowed_tools: list[str] | None = None,
36
+ mcp_config: str | None = None,
37
+ permission_mode: str = "dangerously-skip-permissions",
38
+ dry_run: bool = False,
39
+ worktree_name: str | None = None,
40
+ ) -> None:
41
+ """Initialize the Claude executor.
42
+
43
+ Args:
44
+ working_directory: Directory to run Claude in.
45
+ model: Model to use (e.g., 'claude-sonnet-4-20250514').
46
+ max_turns: Maximum number of agent turns.
47
+ allowed_tools: List of allowed tools.
48
+ disallowed_tools: List of disallowed tools.
49
+ mcp_config: Path to MCP config file.
50
+ permission_mode: Permission mode. Default skips all permissions.
51
+ dry_run: If True, print the command without executing it.
52
+ worktree_name: Name for Claude CLI --worktree flag.
53
+ """
54
+ super().__init__(working_directory)
55
+
56
+ self.model = model
57
+ self.max_turns = max_turns
58
+ self.allowed_tools = allowed_tools or []
59
+ self.disallowed_tools = disallowed_tools or []
60
+ self.mcp_config = mcp_config
61
+ self.permission_mode = permission_mode
62
+ self.dry_run = dry_run
63
+ self.worktree_name = worktree_name
64
+
65
+ self._process: asyncio.subprocess.Process | None = None
66
+ self._parser = StreamParser()
67
+ self._dry_run_complete = False
68
+
69
+ @property
70
+ def agent_type(self) -> str:
71
+ """Return the agent type identifier."""
72
+ return "claude"
73
+
74
+ @property
75
+ def session_id(self) -> str | None:
76
+ """Get Claude's session ID from parsed output."""
77
+ return self._parser.session_id or self._session_id
78
+
79
+ def _find_claude_cli(self) -> str:
80
+ """Find the Claude CLI executable.
81
+
82
+ Returns:
83
+ Path to the Claude CLI.
84
+
85
+ Raises:
86
+ ClaudeExecutorError: If Claude CLI is not found.
87
+ """
88
+ claude_path = shutil.which(self.CLAUDE_CLI)
89
+ if claude_path is None:
90
+ raise ClaudeExecutorError(
91
+ f"Claude CLI '{self.CLAUDE_CLI}' not found in PATH. "
92
+ "Please install Claude Code: https://claude.ai/code"
93
+ )
94
+ return claude_path
95
+
96
+ def _build_command(self, prompt: str, resume_session: str | None = None) -> list[str]:
97
+ """Build the Claude CLI command.
98
+
99
+ Args:
100
+ prompt: The prompt or message to send.
101
+ resume_session: Session ID to resume, if any.
102
+
103
+ Returns:
104
+ Command and arguments as a list.
105
+ """
106
+ claude_path = self._find_claude_cli()
107
+ cmd = [claude_path]
108
+
109
+ # Add prompt
110
+ cmd.extend(["-p", prompt])
111
+
112
+ # Output format for streaming (requires --verbose with -p)
113
+ cmd.extend(["--output-format", "stream-json", "--verbose"])
114
+
115
+ # Permission mode
116
+ if self.permission_mode == "dangerously-skip-permissions":
117
+ cmd.append("--dangerously-skip-permissions")
118
+
119
+ # Resume session if specified
120
+ if resume_session:
121
+ cmd.extend(["--resume", resume_session])
122
+
123
+ # Model
124
+ if self.model:
125
+ cmd.extend(["--model", self.model])
126
+
127
+ # Max turns
128
+ if self.max_turns:
129
+ cmd.extend(["--max-turns", str(self.max_turns)])
130
+
131
+ # Allowed tools
132
+ for tool in self.allowed_tools:
133
+ cmd.extend(["--allowedTools", tool])
134
+
135
+ # Disallowed tools
136
+ for tool in self.disallowed_tools:
137
+ cmd.extend(["--disallowedTools", tool])
138
+
139
+ # MCP config
140
+ if self.mcp_config:
141
+ cmd.extend(["--mcp-config", self.mcp_config])
142
+
143
+ # Worktree isolation
144
+ if self.worktree_name:
145
+ cmd.extend(["--worktree", self.worktree_name])
146
+
147
+ return cmd
148
+
149
+ async def start(self, prompt: str) -> None:
150
+ """Start Claude with an initial prompt.
151
+
152
+ Args:
153
+ prompt: The initial prompt to send.
154
+
155
+ Raises:
156
+ ClaudeExecutorError: If Claude fails to start.
157
+ """
158
+ if self._process is not None and self._process.returncode is None:
159
+ raise ClaudeExecutorError("Claude is already running")
160
+
161
+ cmd = self._build_command(prompt)
162
+ logger.info(f"Starting Claude in {self.working_directory}")
163
+ logger.debug(f"Command: {' '.join(cmd)}")
164
+
165
+ if self.dry_run:
166
+ # Print the command that would be executed
167
+ print("\n[DRY RUN] Would execute command:")
168
+ print(f" {' '.join(cmd)}")
169
+ print(f"\n[DRY RUN] Working directory: {self.working_directory}")
170
+ print("\n[DRY RUN] Waiting 5 seconds...")
171
+ await asyncio.sleep(5)
172
+ print("[DRY RUN] Done.")
173
+ self._dry_run_complete = True
174
+ return
175
+
176
+ try:
177
+ self._process = await asyncio.create_subprocess_exec(
178
+ *cmd,
179
+ stdout=asyncio.subprocess.PIPE,
180
+ stderr=asyncio.subprocess.PIPE,
181
+ cwd=self.working_directory,
182
+ limit=10 * 1024 * 1024, # 10MB buffer for large JSON outputs
183
+ )
184
+ logger.info(f"Claude started with PID {self._process.pid}")
185
+ except OSError as e:
186
+ raise ClaudeExecutorError(f"Failed to start Claude: {e}") from e
187
+
188
+ async def resume(self, session_id: str, message: str) -> None:
189
+ """Resume an existing Claude session.
190
+
191
+ Args:
192
+ session_id: The Claude session ID to resume.
193
+ message: The message to continue the conversation with.
194
+
195
+ Raises:
196
+ ClaudeExecutorError: If Claude fails to start.
197
+ """
198
+ if self._process is not None and self._process.returncode is None:
199
+ raise ClaudeExecutorError("Claude is already running")
200
+
201
+ self._session_id = session_id
202
+ cmd = self._build_command(message, resume_session=session_id)
203
+ logger.info(f"Resuming Claude session {session_id}")
204
+ logger.debug(f"Command: {' '.join(cmd)}")
205
+
206
+ if self.dry_run:
207
+ # Print the command that would be executed
208
+ print("\n[DRY RUN] Would execute command:")
209
+ print(f" {' '.join(cmd)}")
210
+ print(f"\n[DRY RUN] Working directory: {self.working_directory}")
211
+ print("\n[DRY RUN] Waiting 5 seconds...")
212
+ await asyncio.sleep(5)
213
+ print("[DRY RUN] Done.")
214
+ self._dry_run_complete = True
215
+ return
216
+
217
+ try:
218
+ self._process = await asyncio.create_subprocess_exec(
219
+ *cmd,
220
+ stdout=asyncio.subprocess.PIPE,
221
+ stderr=asyncio.subprocess.PIPE,
222
+ cwd=self.working_directory,
223
+ limit=10 * 1024 * 1024, # 10MB buffer for large JSON outputs
224
+ )
225
+ logger.info(f"Claude resumed with PID {self._process.pid}")
226
+ except OSError as e:
227
+ raise ClaudeExecutorError(f"Failed to resume Claude: {e}") from e
228
+
229
+ async def stream_events(self) -> AsyncIterator[StreamEvent]:
230
+ """Stream events from Claude's output.
231
+
232
+ Yields:
233
+ StreamEvent objects parsed from Claude's JSON output.
234
+
235
+ Raises:
236
+ ClaudeExecutorError: If Claude is not running.
237
+ """
238
+ # In dry_run mode, there are no events to stream
239
+ if self.dry_run:
240
+ return
241
+
242
+ if self._process is None or self._process.stdout is None:
243
+ raise ClaudeExecutorError("Claude is not running")
244
+
245
+ async for line_bytes in self._process.stdout:
246
+ line = line_bytes.decode("utf-8", errors="replace")
247
+ event = self._parser.parse_line(line)
248
+ if event is not None:
249
+ # Update session ID if found
250
+ if self._parser.session_id:
251
+ self._session_id = self._parser.session_id
252
+ yield event
253
+
254
+ # Handle any remaining buffered data
255
+ final_event = self._parser.flush()
256
+ if final_event is not None:
257
+ yield final_event
258
+
259
+ async def stop(self) -> None:
260
+ """Stop the Claude process."""
261
+ # In dry_run mode, nothing to stop
262
+ if self.dry_run:
263
+ self._dry_run_complete = True
264
+ return
265
+
266
+ if self._process is None:
267
+ return
268
+
269
+ if self._process.returncode is not None:
270
+ logger.debug("Claude process already stopped")
271
+ return
272
+
273
+ logger.info("Stopping Claude process")
274
+
275
+ # Try graceful termination first
276
+ self._process.terminate()
277
+ try:
278
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
279
+ logger.debug("Claude terminated gracefully")
280
+ except TimeoutError:
281
+ logger.warning("Claude did not terminate, killing...")
282
+ self._process.kill()
283
+ await self._process.wait()
284
+ logger.debug("Claude killed")
285
+
286
+ async def wait(self) -> int:
287
+ """Wait for Claude to complete.
288
+
289
+ Returns:
290
+ The exit code of the Claude process.
291
+
292
+ Raises:
293
+ ClaudeExecutorError: If Claude is not running.
294
+ """
295
+ # In dry_run mode, return success immediately
296
+ if self.dry_run:
297
+ return 0
298
+
299
+ if self._process is None:
300
+ raise ClaudeExecutorError("Claude is not running")
301
+
302
+ return await self._process.wait()
303
+
304
+ @property
305
+ def is_running(self) -> bool:
306
+ """Check if Claude is still running."""
307
+ # In dry_run mode, it's not actually running
308
+ if self.dry_run:
309
+ return not self._dry_run_complete
310
+ if self._process is None:
311
+ return False
312
+ return self._process.returncode is None
313
+
314
+ async def get_stderr(self) -> str:
315
+ """Get any stderr output from Claude.
316
+
317
+ Returns:
318
+ Stderr output as a string.
319
+ """
320
+ if self._process is None or self._process.stderr is None:
321
+ return ""
322
+
323
+ try:
324
+ stderr_bytes = await self._process.stderr.read()
325
+ return stderr_bytes.decode("utf-8", errors="replace")
326
+ except Exception as e:
327
+ logger.warning(f"Failed to read stderr: {e}")
328
+ return ""
@@ -0,0 +1,163 @@
1
+ """JSON stream parser for Claude's stream-json output format."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+ from datetime import UTC, datetime
6
+ from typing import Any, ClassVar
7
+
8
+ from loguru import logger
9
+
10
+ from steerdev_agent.executor.base import EventType, StreamEvent
11
+
12
+
13
+ class StreamParser:
14
+ """Parser for Claude Code's stream-json output format.
15
+
16
+ Claude Code outputs newline-delimited JSON when run with --output-format stream-json.
17
+ Each line is a complete JSON object representing an event.
18
+ """
19
+
20
+ # Event type mapping from Claude's output to our EventType
21
+ EVENT_TYPE_MAP: ClassVar[dict[str, EventType]] = {
22
+ "system": EventType.SYSTEM,
23
+ "user": EventType.USER,
24
+ "assistant": EventType.ASSISTANT,
25
+ "tool_use": EventType.TOOL_USE,
26
+ "tool_result": EventType.TOOL_RESULT,
27
+ "error": EventType.ERROR,
28
+ "result": EventType.RESULT,
29
+ }
30
+
31
+ def __init__(self) -> None:
32
+ """Initialize the parser."""
33
+ self._buffer = ""
34
+ self._session_id: str | None = None
35
+
36
+ @property
37
+ def session_id(self) -> str | None:
38
+ """Get the session ID extracted from the stream."""
39
+ return self._session_id
40
+
41
+ def parse_line(self, line: str) -> StreamEvent | None:
42
+ """Parse a single JSON line into a StreamEvent.
43
+
44
+ Args:
45
+ line: A single line from the JSON stream.
46
+
47
+ Returns:
48
+ A StreamEvent if parsing succeeded, None otherwise.
49
+ """
50
+ line = line.strip()
51
+ if not line:
52
+ return None
53
+
54
+ try:
55
+ data = json.loads(line)
56
+ except json.JSONDecodeError as e:
57
+ logger.warning(f"Failed to parse JSON line: {e}")
58
+ return None
59
+
60
+ # Extract event type
61
+ event_type_str = data.get("type", "unknown")
62
+ event_type = self.EVENT_TYPE_MAP.get(event_type_str, EventType.SYSTEM)
63
+
64
+ # Extract session ID from result events
65
+ if event_type == EventType.RESULT:
66
+ self._extract_session_id(data)
67
+
68
+ # Parse timestamp if available, otherwise use now
69
+ timestamp = self._parse_timestamp(data.get("timestamp"))
70
+
71
+ return StreamEvent(
72
+ event_type=event_type,
73
+ timestamp=timestamp,
74
+ data=data,
75
+ raw_json=line,
76
+ )
77
+
78
+ def _extract_session_id(self, data: dict[str, Any]) -> None:
79
+ """Extract session ID from result data.
80
+
81
+ Args:
82
+ data: The parsed JSON data.
83
+ """
84
+ # Claude stores session ID in result.session_id
85
+ if "session_id" in data:
86
+ self._session_id = data["session_id"]
87
+ elif (
88
+ "result" in data and isinstance(data["result"], dict) and "session_id" in data["result"]
89
+ ):
90
+ self._session_id = data["result"]["session_id"]
91
+
92
+ def _parse_timestamp(self, ts: str | None) -> datetime:
93
+ """Parse a timestamp string or return current time.
94
+
95
+ Args:
96
+ ts: ISO format timestamp string or None.
97
+
98
+ Returns:
99
+ Parsed datetime or current UTC time.
100
+ """
101
+ if ts is None:
102
+ return datetime.now(UTC)
103
+
104
+ try:
105
+ # Handle ISO format with or without Z suffix
106
+ if ts.endswith("Z"):
107
+ ts = ts[:-1] + "+00:00"
108
+ return datetime.fromisoformat(ts)
109
+ except ValueError:
110
+ return datetime.now(UTC)
111
+
112
+ async def parse_stream(
113
+ self,
114
+ lines: AsyncIterator[str],
115
+ ) -> AsyncIterator[StreamEvent]:
116
+ """Parse an async stream of lines into events.
117
+
118
+ Args:
119
+ lines: Async iterator of lines from the process output.
120
+
121
+ Yields:
122
+ StreamEvent objects for each valid JSON line.
123
+ """
124
+ async for line in lines:
125
+ event = self.parse_line(line)
126
+ if event is not None:
127
+ yield event
128
+
129
+ def feed(self, chunk: str) -> list[StreamEvent]:
130
+ """Feed a chunk of data and return any complete events.
131
+
132
+ For use with non-async streams or buffered reading.
133
+
134
+ Args:
135
+ chunk: A chunk of data (may contain partial lines).
136
+
137
+ Returns:
138
+ List of parsed events from complete lines.
139
+ """
140
+ self._buffer += chunk
141
+ events: list[StreamEvent] = []
142
+
143
+ while "\n" in self._buffer:
144
+ line, self._buffer = self._buffer.split("\n", 1)
145
+ event = self.parse_line(line)
146
+ if event is not None:
147
+ events.append(event)
148
+
149
+ return events
150
+
151
+ def flush(self) -> StreamEvent | None:
152
+ """Flush any remaining buffered data.
153
+
154
+ Call this when the stream ends to handle any partial line.
155
+
156
+ Returns:
157
+ A StreamEvent if there was buffered data, None otherwise.
158
+ """
159
+ if self._buffer.strip():
160
+ event = self.parse_line(self._buffer)
161
+ self._buffer = ""
162
+ return event
163
+ return None
@@ -0,0 +1 @@
1
+ """Git module for steerdev."""
@@ -0,0 +1,5 @@
1
+ """Message handlers for steerdev."""
2
+
3
+ from steerdev_agent.handlers.prd import PRDHandler
4
+
5
+ __all__ = ["PRDHandler"]