orbit-auto 3.0.0__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.
orbit_auto/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ Orbit Auto - Autonomous AI Development Tool
3
+
4
+ A Python implementation of the Orbit Auto technique for autonomous
5
+ AI-assisted development. Supports both sequential and parallel execution
6
+ with orbit integration.
7
+
8
+ Usage:
9
+ orbit-auto <task-name> # Parallel (default, 8 workers)
10
+ orbit-auto <task-name> -w 12 # Parallel with 12 workers
11
+ orbit-auto <task-name> --sequential # Sequential mode
12
+ orbit-auto <task-name> --dry-run # Show execution plan
13
+ orbit-auto init <task-name> "desc" # Initialize task
14
+ orbit-auto status <task-name> # Show task status
15
+ """
16
+
17
+ __version__ = "3.0.0"
18
+ __author__ = "Tom Brami"
19
+
20
+ from orbit_auto.models import Task, State, Config, ExecutionResult
21
+ from orbit_auto.dag import DAG
22
+ from orbit_auto.state import StateManager
23
+
24
+ __all__ = [
25
+ "Task",
26
+ "State",
27
+ "Config",
28
+ "ExecutionResult",
29
+ "DAG",
30
+ "StateManager",
31
+ "__version__",
32
+ ]
orbit_auto/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ Entry point for running orbit-auto as a module.
3
+
4
+ Usage:
5
+ python -m orbit_auto <task-name> [options]
6
+ """
7
+
8
+ import sys
9
+
10
+ from orbit_auto.cli import main
11
+
12
+ if __name__ == "__main__":
13
+ sys.exit(main())
@@ -0,0 +1,439 @@
1
+ """
2
+ Claude CLI integration for Orbit Auto.
3
+
4
+ Handles invoking the Claude CLI, parsing streaming output,
5
+ and extracting structured results from responses.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Callable
16
+
17
+ from orbit_auto.models import ExecutionResult, Visibility
18
+
19
+
20
+ @dataclass
21
+ class StreamContext:
22
+ """Context for stream processing."""
23
+
24
+ start_time: float = field(default_factory=time.time)
25
+ tool_count: int = 0
26
+ files_modified: list[str] = field(default_factory=list)
27
+ accumulated_text: str = ""
28
+
29
+
30
+ class ClaudeRunner:
31
+ """
32
+ Runs Claude CLI and processes output.
33
+
34
+ Supports streaming output parsing, tool visibility modes,
35
+ and extraction of learning tags from responses.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ visibility: Visibility = Visibility.VERBOSE,
41
+ on_tool_use: Callable[[str, str], None] | None = None,
42
+ ) -> None:
43
+ """
44
+ Initialize ClaudeRunner.
45
+
46
+ Args:
47
+ visibility: Output visibility level
48
+ on_tool_use: Optional callback for tool use events (tool_name, display_info)
49
+ """
50
+ self.visibility = visibility
51
+ self.on_tool_use = on_tool_use
52
+
53
+ def run(
54
+ self,
55
+ prompt: str,
56
+ working_dir: Path,
57
+ print_output: bool = True,
58
+ log_file: Path | None = None,
59
+ timeout: int | None = None,
60
+ session_name: str | None = None,
61
+ ) -> ExecutionResult:
62
+ """
63
+ Run Claude with a prompt and parse the response.
64
+
65
+ Args:
66
+ prompt: The prompt to send to Claude
67
+ working_dir: Working directory for the Claude process
68
+ print_output: Whether to print tool visibility output
69
+ log_file: Optional path to write full Claude output for debugging
70
+ timeout: Max seconds to wait for completion (None = no limit)
71
+ session_name: Optional display name for the session (--name flag)
72
+
73
+ Returns:
74
+ ExecutionResult with parsed response data
75
+ """
76
+ start_time = time.time()
77
+ ctx = StreamContext(start_time=start_time)
78
+
79
+ # Build command
80
+ # Note: --verbose is required when using --print with --output-format=stream-json
81
+ # --exclude-dynamic-system-prompt-sections improves prompt-cache hits across parallel workers.
82
+ cmd = ["claude", "--print", "--output-format", "stream-json", "--verbose", "--exclude-dynamic-system-prompt-sections"]
83
+ if session_name:
84
+ cmd.extend(["--name", session_name])
85
+
86
+ # Set up environment to signal autonomous execution
87
+ # This allows hooks to skip when running in orbit-auto mode
88
+ env = os.environ.copy()
89
+ env["ORBIT_AUTO_MODE"] = "1"
90
+
91
+ # Run Claude
92
+ process = subprocess.Popen(
93
+ cmd,
94
+ stdin=subprocess.PIPE,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.PIPE,
97
+ cwd=working_dir,
98
+ text=True,
99
+ env=env,
100
+ )
101
+
102
+ # Send prompt (with optional timeout)
103
+ try:
104
+ stdout, stderr = process.communicate(input=prompt, timeout=timeout)
105
+ except subprocess.TimeoutExpired:
106
+ process.kill()
107
+ stdout, stderr = process.communicate() # drain pipes after kill
108
+ result = ExecutionResult(
109
+ task_id="",
110
+ success=False,
111
+ output="",
112
+ duration=time.time() - start_time,
113
+ )
114
+ result.cli_error = f"Task timed out after {timeout}s"
115
+ if log_file:
116
+ self._write_log_file(
117
+ log_file,
118
+ prompt,
119
+ stdout or "",
120
+ stderr or "",
121
+ result,
122
+ start_time,
123
+ )
124
+ return result
125
+
126
+ # Process output
127
+ result = self._process_output(stdout, ctx, print_output)
128
+ result.duration = time.time() - start_time
129
+
130
+ # Capture CLI errors from stderr (e.g., rate limits, auth errors, flag errors)
131
+ if stderr and stderr.strip():
132
+ # Filter out noise - only capture actual errors
133
+ error_lines = [
134
+ line
135
+ for line in stderr.strip().split("\n")
136
+ if line.strip() and not line.startswith("Warning:")
137
+ ]
138
+ if error_lines:
139
+ result.cli_error = "\n".join(error_lines[:5]) # Limit to first 5 lines
140
+
141
+ # Write log file if requested
142
+ if log_file:
143
+ self._write_log_file(log_file, prompt, stdout, stderr, result, start_time)
144
+
145
+ return result
146
+
147
+ def _write_log_file(
148
+ self,
149
+ log_file: Path,
150
+ prompt: str,
151
+ stdout: str,
152
+ stderr: str,
153
+ result: ExecutionResult,
154
+ start_time: float,
155
+ ) -> None:
156
+ """Write worker execution log to file."""
157
+ from datetime import datetime
158
+
159
+ log_file.parent.mkdir(parents=True, exist_ok=True)
160
+
161
+ with open(log_file, "w") as f:
162
+ f.write("=== Orbit Auto Worker Log ===\n")
163
+ f.write(f"Started: {datetime.fromtimestamp(start_time).isoformat()}\n")
164
+ f.write(f"Duration: {result.duration:.1f}s\n")
165
+ f.write("\n")
166
+
167
+ f.write("--- PROMPT ---\n")
168
+ f.write(prompt[:500] + "...\n" if len(prompt) > 500 else prompt + "\n")
169
+ f.write("\n")
170
+
171
+ f.write("--- RAW CLAUDE OUTPUT (stream-json) ---\n")
172
+ f.write(stdout if stdout else "(no stdout)\n")
173
+ f.write("\n")
174
+
175
+ if stderr and stderr.strip():
176
+ f.write("--- STDERR ---\n")
177
+ f.write(stderr)
178
+ f.write("\n")
179
+
180
+ f.write("--- EXECUTION RESULT ---\n")
181
+ f.write(f"Success: {result.success}\n")
182
+ f.write(f"Tools used: {result.tools_used}\n")
183
+ f.write(f"Files modified: {result.files_modified}\n")
184
+ if result.cli_error:
185
+ f.write(f"CLI error: {result.cli_error}\n")
186
+ if result.what_worked:
187
+ f.write(f"What worked: {result.what_worked}\n")
188
+ if result.what_failed:
189
+ f.write(f"What failed: {result.what_failed}\n")
190
+ if result.is_blocked:
191
+ f.write("Status: BLOCKED (waiting for human)\n")
192
+
193
+ def _process_output(
194
+ self,
195
+ output: str,
196
+ ctx: StreamContext,
197
+ print_output: bool,
198
+ ) -> ExecutionResult:
199
+ """Process Claude's stream-json output."""
200
+ for line in output.split("\n"):
201
+ line = line.strip()
202
+ if not line:
203
+ continue
204
+
205
+ # Parse tool use
206
+ if '"type":"tool_use"' in line:
207
+ self._handle_tool_use(line, ctx, print_output)
208
+
209
+ # Capture text content
210
+ if '"type":"assistant"' in line:
211
+ text_content = self._extract_text_content(line)
212
+ if text_content:
213
+ ctx.accumulated_text += text_content
214
+
215
+ if '"type":"result"' in line:
216
+ result_content = self._extract_result_content(line)
217
+ if result_content:
218
+ ctx.accumulated_text += result_content
219
+
220
+ # Build result
221
+ return self._build_result(ctx)
222
+
223
+ def _handle_tool_use(
224
+ self,
225
+ line: str,
226
+ ctx: StreamContext,
227
+ print_output: bool,
228
+ ) -> None:
229
+ """Handle a tool use event from the stream."""
230
+ tool_name = self._extract_json_field(line, "name")
231
+ if not tool_name:
232
+ return
233
+
234
+ ctx.tool_count += 1
235
+ display_info = ""
236
+
237
+ if self.visibility == Visibility.VERBOSE:
238
+ display_info = self._get_verbose_info(line, tool_name, ctx)
239
+ elif self.visibility == Visibility.MINIMAL:
240
+ display_info = self._get_minimal_info(line, tool_name, ctx)
241
+
242
+ if print_output and self.on_tool_use:
243
+ self.on_tool_use(tool_name, display_info)
244
+
245
+ def _get_verbose_info(self, line: str, tool_name: str, ctx: StreamContext) -> str:
246
+ """Extract verbose display info for a tool call."""
247
+ if tool_name in ("Read", "Write", "Edit"):
248
+ file_path = self._extract_input_field(line, "file_path")
249
+ if file_path and tool_name in ("Write", "Edit"):
250
+ ctx.files_modified.append(file_path)
251
+ return file_path or ""
252
+ elif tool_name == "Bash":
253
+ cmd = self._extract_input_field(line, "command")
254
+ if cmd:
255
+ # Strip ANSI escape sequences
256
+ cmd = re.sub(r"\x1b\[[0-9;]*m", "", cmd)
257
+ if len(cmd) > 50:
258
+ cmd = cmd[:47] + "..."
259
+ return cmd or ""
260
+ elif tool_name in ("Glob", "Grep"):
261
+ return self._extract_input_field(line, "pattern") or ""
262
+ return ""
263
+
264
+ def _get_minimal_info(self, line: str, tool_name: str, ctx: StreamContext) -> str:
265
+ """Extract minimal display info for a tool call."""
266
+ if tool_name in ("Read", "Write", "Edit"):
267
+ file_path = self._extract_input_field(line, "file_path")
268
+ if file_path:
269
+ if tool_name in ("Write", "Edit"):
270
+ ctx.files_modified.append(file_path)
271
+ return Path(file_path).name
272
+ return ""
273
+ elif tool_name == "Bash":
274
+ cmd = self._extract_input_field(line, "command")
275
+ if cmd:
276
+ cmd = re.sub(r"\x1b\[[0-9;]*m", "", cmd)
277
+ return cmd.split()[0] if cmd.split() else ""
278
+ return ""
279
+ return ""
280
+
281
+ def _extract_json_field(self, line: str, field: str) -> str:
282
+ """Extract a field value from JSON line."""
283
+ pattern = rf'"{field}":"([^"]*)"'
284
+ match = re.search(pattern, line)
285
+ return match.group(1) if match else ""
286
+
287
+ def _extract_input_field(self, line: str, field: str) -> str:
288
+ """Extract a field from the input object in a tool_use message."""
289
+ # Try to parse as JSON
290
+ try:
291
+ data = json.loads(line)
292
+ # Check nested path first
293
+ if "message" in data and "content" in data["message"]:
294
+ for item in data["message"]["content"]:
295
+ if item.get("type") == "tool_use" and "input" in item:
296
+ return item["input"].get(field, "")
297
+ # Check top-level input
298
+ if "input" in data:
299
+ return data["input"].get(field, "")
300
+ except json.JSONDecodeError:
301
+ pass
302
+ return ""
303
+
304
+ def _extract_text_content(self, line: str) -> str:
305
+ """Extract text content from an assistant message."""
306
+ try:
307
+ data = json.loads(line)
308
+ if "message" in data and "content" in data["message"]:
309
+ for item in data["message"]["content"]:
310
+ if item.get("type") == "text":
311
+ return item.get("text", "")
312
+ except json.JSONDecodeError:
313
+ pass
314
+ return ""
315
+
316
+ def _extract_result_content(self, line: str) -> str:
317
+ """Extract result content from a result message."""
318
+ try:
319
+ data = json.loads(line)
320
+ return data.get("result", "")
321
+ except json.JSONDecodeError:
322
+ pass
323
+ return ""
324
+
325
+ def _build_result(self, ctx: StreamContext) -> ExecutionResult:
326
+ """Build ExecutionResult from accumulated context."""
327
+ text = ctx.accumulated_text
328
+
329
+ # Extract learning tags
330
+ learnings = self._extract_tag(text, "learnings")
331
+ what_worked = self._extract_tag(text, "what_worked")
332
+ what_failed = self._extract_tag(text, "what_failed")
333
+ dont_retry = self._extract_tag(text, "dont_retry")
334
+ try_next = self._extract_tag(text, "try_next")
335
+ pattern = self._extract_tag(text, "pattern_discovered")
336
+ gotcha = self._extract_tag(text, "gotcha")
337
+
338
+ # Check status signals
339
+ is_complete = "<promise>COMPLETE</promise>" in text
340
+ is_blocked = "<blocker>WAITING_FOR_HUMAN</blocker>" in text
341
+
342
+ # Determine success - require explicit positive signal
343
+ # Task is successful ONLY if:
344
+ # 1. <promise>COMPLETE</promise> is present, OR
345
+ # 2. <what_worked> tag is present (explicit success signal)
346
+ # This prevents marking tasks complete when Claude crashes, rate limits, or outputs nothing
347
+ success = is_complete or (what_worked is not None and not is_blocked)
348
+
349
+ return ExecutionResult(
350
+ task_id="", # Set by caller
351
+ success=success,
352
+ output=text,
353
+ duration=time.time() - ctx.start_time,
354
+ tools_used=ctx.tool_count,
355
+ files_modified=list(set(ctx.files_modified)),
356
+ learnings=learnings,
357
+ what_worked=what_worked,
358
+ what_failed=what_failed,
359
+ dont_retry=dont_retry,
360
+ try_next=try_next,
361
+ pattern_discovered=pattern,
362
+ gotcha=gotcha,
363
+ is_complete=is_complete,
364
+ is_blocked=is_blocked,
365
+ )
366
+
367
+ def _extract_tag(self, text: str, tag: str) -> str | None:
368
+ """Extract content between XML-style tags."""
369
+ start_tag = f"<{tag}>"
370
+ end_tag = f"</{tag}>"
371
+
372
+ if start_tag not in text or end_tag not in text:
373
+ return None
374
+
375
+ start_idx = text.index(start_tag) + len(start_tag)
376
+ end_idx = text.index(end_tag)
377
+
378
+ if end_idx > start_idx:
379
+ return text[start_idx:end_idx].strip()
380
+ return None
381
+
382
+
383
+ def build_generic_prompt(
384
+ task_number: str,
385
+ task_title: str,
386
+ tasks_file: Path,
387
+ context_file: Path,
388
+ auto_log: Path | None = None,
389
+ ) -> str:
390
+ """
391
+ Build a generic prompt for tasks without optimized prompts.
392
+
393
+ This replicates the prompt structure used by the bash implementation.
394
+ """
395
+ prompt_parts = [
396
+ "You are working on an autonomous development task using Orbit Auto.",
397
+ "",
398
+ "## Current Task",
399
+ f"Task {task_number}: {task_title}",
400
+ "",
401
+ "## Files to Reference",
402
+ f"- Tasks file: {tasks_file}",
403
+ f"- Context file: {context_file}",
404
+ ]
405
+
406
+ if auto_log and auto_log.exists():
407
+ prompt_parts.append(f"- Auto log: {auto_log}")
408
+
409
+ prompt_parts.extend(
410
+ [
411
+ "",
412
+ "## Instructions",
413
+ "1. Read the tasks file to understand the full task list",
414
+ "2. Read the context file for project-specific information",
415
+ "3. Complete the current task following the acceptance criteria",
416
+ "4. DO NOT mark the task checkbox - orbit-auto handles task completion tracking",
417
+ "",
418
+ "## Output Tags (REQUIRED)",
419
+ "",
420
+ "**CRITICAL:** orbit-auto detects success via these tags. Without them, the task is marked FAILED.",
421
+ "",
422
+ "Always include:",
423
+ "- <learnings>What you learned from this attempt</learnings>",
424
+ "",
425
+ "**On SUCCESS (REQUIRED for task completion):**",
426
+ "- <what_worked>The approach that succeeded</what_worked>",
427
+ "",
428
+ "On FAILURE:",
429
+ "- <what_failed>What went wrong</what_failed>",
430
+ "- <dont_retry>What not to try again</dont_retry>",
431
+ "- <try_next>What to try next</try_next>",
432
+ "",
433
+ "When ALL tasks are complete:",
434
+ "- <run_summary>Summary of all work done</run_summary>",
435
+ "- <promise>COMPLETE</promise>",
436
+ ]
437
+ )
438
+
439
+ return "\n".join(prompt_parts)