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