execforge 0.1.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.
Files changed (44) hide show
  1. execforge-0.1.0.dist-info/METADATA +367 -0
  2. execforge-0.1.0.dist-info/RECORD +44 -0
  3. execforge-0.1.0.dist-info/WHEEL +5 -0
  4. execforge-0.1.0.dist-info/entry_points.txt +5 -0
  5. execforge-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. execforge-0.1.0.dist-info/top_level.txt +1 -0
  7. orchestrator/__init__.py +4 -0
  8. orchestrator/__main__.py +5 -0
  9. orchestrator/backends/__init__.py +1 -0
  10. orchestrator/backends/base.py +29 -0
  11. orchestrator/backends/factory.py +53 -0
  12. orchestrator/backends/llm_cli_backend.py +87 -0
  13. orchestrator/backends/mock_backend.py +34 -0
  14. orchestrator/backends/shell_backend.py +49 -0
  15. orchestrator/cli/__init__.py +1 -0
  16. orchestrator/cli/main.py +971 -0
  17. orchestrator/config.py +272 -0
  18. orchestrator/domain/__init__.py +1 -0
  19. orchestrator/domain/types.py +77 -0
  20. orchestrator/exceptions.py +18 -0
  21. orchestrator/git/__init__.py +1 -0
  22. orchestrator/git/service.py +202 -0
  23. orchestrator/logging_setup.py +53 -0
  24. orchestrator/prompts/__init__.py +1 -0
  25. orchestrator/prompts/parser.py +91 -0
  26. orchestrator/reporting/__init__.py +1 -0
  27. orchestrator/reporting/console.py +197 -0
  28. orchestrator/reporting/events.py +44 -0
  29. orchestrator/reporting/selection_result.py +15 -0
  30. orchestrator/services/__init__.py +1 -0
  31. orchestrator/services/agent_runner.py +831 -0
  32. orchestrator/services/agent_service.py +122 -0
  33. orchestrator/services/project_service.py +47 -0
  34. orchestrator/services/prompt_source_service.py +65 -0
  35. orchestrator/services/run_service.py +42 -0
  36. orchestrator/services/step_executor.py +100 -0
  37. orchestrator/services/task_service.py +155 -0
  38. orchestrator/storage/__init__.py +1 -0
  39. orchestrator/storage/db.py +29 -0
  40. orchestrator/storage/models.py +95 -0
  41. orchestrator/utils/__init__.py +1 -0
  42. orchestrator/utils/process.py +44 -0
  43. orchestrator/validation/__init__.py +1 -0
  44. orchestrator/validation/pipeline.py +52 -0
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from hashlib import sha256
4
+ from pathlib import Path
5
+ import re
6
+
7
+ import yaml
8
+
9
+ from orchestrator.domain.types import PromptTask, TaskGitPolicy, TaskStep
10
+
11
+
12
+ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
13
+
14
+
15
+ def _parse_steps(metadata: dict, body: str) -> list[TaskStep]:
16
+ steps: list[TaskStep] = []
17
+ for idx, item in enumerate(list(metadata.get("steps", []) or []), start=1):
18
+ if not isinstance(item, dict):
19
+ continue
20
+ step_id = str(item.get("id") or f"step-{idx}")
21
+ step_type = str(item.get("type") or "llm_summary")
22
+ steps.append(
23
+ TaskStep(
24
+ id=step_id,
25
+ type=step_type,
26
+ tool_preferences=list(item.get("tool_preferences", []) or []),
27
+ prompt_file=item.get("prompt_file"),
28
+ prompt_inline=item.get("prompt_inline"),
29
+ command=item.get("command"),
30
+ metadata={k: v for k, v in item.items() if k not in {"id", "type", "tool_preferences", "prompt_file", "prompt_inline", "command"}},
31
+ )
32
+ )
33
+
34
+ if steps:
35
+ return steps
36
+
37
+ # Backward-compatible fallback: a single summary step from body.
38
+ if body.strip():
39
+ return [TaskStep(id="default", type="llm_summary", prompt_inline=body.strip())]
40
+ return []
41
+
42
+
43
+ def _parse_git_policy(metadata: dict) -> TaskGitPolicy:
44
+ data = metadata.get("git") or {}
45
+ if not isinstance(data, dict):
46
+ return TaskGitPolicy()
47
+ return TaskGitPolicy(
48
+ base_branch=data.get("base_branch"),
49
+ work_branch=data.get("work_branch"),
50
+ push_on_success=data.get("push_on_success"),
51
+ )
52
+
53
+
54
+ def parse_task_raw(raw: str, rel_path: str, suffix: str) -> PromptTask:
55
+ metadata: dict = {}
56
+ body = raw
57
+ if suffix.lower() == ".md":
58
+ match = FRONTMATTER_RE.match(raw)
59
+ if match:
60
+ metadata = yaml.safe_load(match.group(1)) or {}
61
+ body = match.group(2).strip()
62
+ else:
63
+ metadata = yaml.safe_load(raw) or {}
64
+ body = str(metadata.get("instructions") or metadata.get("description") or "").strip()
65
+
66
+ title = metadata.get("title") or Path(rel_path).stem
67
+ steps = _parse_steps(metadata, body)
68
+ git = _parse_git_policy(metadata)
69
+ task = PromptTask(
70
+ external_id=metadata.get("id"),
71
+ source_path=rel_path,
72
+ title=title,
73
+ description=body,
74
+ priority=metadata.get("priority", "medium"),
75
+ status=metadata.get("status", "todo"),
76
+ labels=list(metadata.get("labels", []) or []),
77
+ target_repo=metadata.get("target_repo"),
78
+ target_paths=list(metadata.get("target_paths", []) or []),
79
+ depends_on=list(metadata.get("depends_on", []) or []),
80
+ acceptance_criteria=list(metadata.get("acceptance_criteria", []) or []),
81
+ steps=steps,
82
+ git=git,
83
+ raw_content=raw,
84
+ last_seen_hash=sha256(raw.encode("utf-8")).hexdigest(),
85
+ )
86
+ return task
87
+
88
+
89
+ def parse_task_file(path: Path, rel_path: str) -> PromptTask:
90
+ raw = path.read_text(encoding="utf-8")
91
+ return parse_task_raw(raw, rel_path=rel_path, suffix=path.suffix)
@@ -0,0 +1 @@
1
+ """Console and event reporting for runtime execution."""
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timedelta
5
+
6
+ from orchestrator.reporting.events import LogEvent, clean_context
7
+
8
+
9
+ def _fmt_time(value: str | datetime | None) -> str:
10
+ if isinstance(value, datetime):
11
+ return value.strftime("%Y-%m-%d %H:%M:%S")
12
+ if isinstance(value, str) and value:
13
+ return value
14
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class ConsoleReporter:
19
+ mode: str = "default" # default | verbose | debug
20
+ warnings_in_run: int = 0
21
+
22
+ def _print(self, text: str = "") -> None:
23
+ print(text)
24
+
25
+ def render(self, event: LogEvent) -> None:
26
+ if self.mode == "debug":
27
+ self._print(str(event.to_dict()))
28
+ return
29
+
30
+ name = event.name
31
+ context = clean_context(event.context)
32
+
33
+ if name == "loop_started":
34
+ self._print("=" * 60)
35
+ self._print("Execforge Loop Started")
36
+ self._print(f" Time: {_fmt_time(context.get('time'))}")
37
+ self._print(f" Agent: {context.get('agent')}")
38
+ self._print(f" Project: {context.get('project')}")
39
+ self._print(f" Prompt Source: {context.get('prompt_source')}")
40
+ self._print(f" Interval: {context.get('interval_seconds')}s")
41
+ self._print(f" Only New Prompts: {str(context.get('only_new_prompts', True)).lower()}")
42
+ self._print(f" Reset Only New Baseline: {str(context.get('reset_only_new_baseline', False)).lower()}")
43
+ self._print(f" Initial Excluded Tasks: {context.get('initial_excluded', 0)}")
44
+ self._print(f" Allow Dirty Working Tree: {str(context.get('allow_dirty_worktree', False)).lower()}")
45
+ if context.get("branch_strategy"):
46
+ self._print(f" Branch Strategy: {context.get('branch_strategy')}")
47
+ self._print("=" * 60)
48
+ self._print("")
49
+ return
50
+
51
+ if name == "run_started":
52
+ self.warnings_in_run = 0
53
+ self._print("-" * 60)
54
+ self._print("Execforge Run")
55
+ self._print(f" Run: {context.get('run_id')}")
56
+ self._print(f" Time: {_fmt_time(context.get('time'))}")
57
+ self._print(f" Agent: {context.get('agent')}")
58
+ self._print(f" Project: {context.get('project')}")
59
+ self._print(f" Prompt Source: {context.get('prompt_source')}")
60
+ self._print("-" * 60)
61
+ self._print("")
62
+ return
63
+
64
+ if name in {"prompt_sync_started", "repo_validate_started", "task_select_started", "branch_prepare_started", "steps_started"}:
65
+ idx = event.phase_index or 0
66
+ total = event.phase_total or 0
67
+ self._print(f"[{idx}/{total}] {event.title}...")
68
+ return
69
+
70
+ if name == "prompt_synced":
71
+ self._print(f" Found {context.get('discovered_tasks', 0)} task")
72
+ return
73
+
74
+ if name == "repo_validated":
75
+ branch = context.get("current_branch")
76
+ if branch:
77
+ self._print(f" Current branch: {branch}")
78
+ return
79
+
80
+ if name == "task_selection_completed":
81
+ if context.get("selected_task_id"):
82
+ self._print(f" Selected: {context.get('selected_task_id')}")
83
+ else:
84
+ self._print(" No task selected")
85
+ self._print(f" Reason: {context.get('reason')}")
86
+ self._print(f" Eligible tasks: {context.get('eligible_count', 0)}")
87
+ self._print(f" Excluded tasks: {context.get('excluded_count', 0)}")
88
+ if context.get("next_hint"):
89
+ self._print(f" Next: {context.get('next_hint')}")
90
+ if self.mode == "verbose":
91
+ if context.get("code"):
92
+ self._print(f" Outcome: {context.get('code')}")
93
+ self._print(f" Discovered tasks: {context.get('discovered_count', 0)}")
94
+ return
95
+
96
+ if name == "branch_prepared":
97
+ if context.get("base_branch"):
98
+ self._print(f" Base: {context.get('base_branch')}")
99
+ if context.get("branch"):
100
+ self._print(f" Branch: {context.get('branch')}")
101
+ return
102
+
103
+ if name == "step_completed":
104
+ i = context.get("step_index")
105
+ total = context.get("step_total")
106
+ step = context.get("step")
107
+ backend = context.get("backend")
108
+ symbol = context.get("symbol", "✓")
109
+ self._print(f" [{i}/{total}] {symbol} {step:<16} {backend}")
110
+ return
111
+
112
+ if name == "step_failed":
113
+ i = context.get("step_index", "?")
114
+ total = context.get("step_total", "?")
115
+ step = context.get("step", "unknown")
116
+ backend = context.get("backend", "runtime")
117
+ self._print(f" [{i}/{total}] ✗ {step:<16} {backend}")
118
+ self._print("")
119
+ if context.get("base_branch"):
120
+ self._print(f" Base: {context.get('base_branch')}")
121
+ if context.get("branch"):
122
+ self._print(f" Branch: {context.get('branch')}")
123
+ if context.get("task_id"):
124
+ self._print(f" Task: {context.get('task_id')}")
125
+ if context.get("error"):
126
+ self._print(f" Error: {context.get('error')}")
127
+ return
128
+
129
+ if name == "warning":
130
+ self.warnings_in_run += 1
131
+ self._print(f"⚠ {event.message}")
132
+ if context.get("branch"):
133
+ self._print(f" Branch: {context.get('branch')}")
134
+ if context.get("task_id"):
135
+ self._print(f" Task: {context.get('task_id')}")
136
+ return
137
+
138
+ if name == "run_noop":
139
+ self._print("")
140
+ self._print("Run complete")
141
+ self._print(" Status: noop")
142
+ self._print(f" Reason: {context.get('reason', 'no actionable task found')}")
143
+ if context.get("next_hint"):
144
+ self._print(f" Next: {context.get('next_hint')}")
145
+ if context.get("project"):
146
+ self._print(f" Project: {context.get('project')}")
147
+ if context.get("warnings") is not None:
148
+ self._print(f" Warnings: {context.get('warnings')}")
149
+ return
150
+
151
+ if name == "run_completed":
152
+ self._print("")
153
+ self._print("Run complete")
154
+ self._print(f" Status: {context.get('status', 'success')}")
155
+ if context.get("reason"):
156
+ self._print(f" Reason: {context.get('reason')}")
157
+ if context.get("task_id"):
158
+ self._print(f" Task: {context.get('task_id')}")
159
+ if context.get("branch"):
160
+ self._print(f" Branch: {context.get('branch')}")
161
+ if context.get("push_enabled") is not None:
162
+ self._print(f" Push Enabled: {str(context.get('push_enabled')).lower()}")
163
+ if context.get("steps_total") is not None and context.get("steps_passed") is not None:
164
+ self._print(f" Steps: {context.get('steps_passed')}/{context.get('steps_total')} passed")
165
+ warnings = context.get("warnings", self.warnings_in_run)
166
+ self._print(f" Warnings: {warnings}")
167
+ if context.get("log_path"):
168
+ self._print(f" Log File: {context.get('log_path')}")
169
+ return
170
+
171
+ if name == "run_failed":
172
+ if self.mode == "verbose":
173
+ self._print(f" Failure reason: {context.get('reason')}")
174
+ return
175
+
176
+ if name == "loop_waiting":
177
+ interval = int(context.get("interval_seconds", 0) or 0)
178
+ next_at = context.get("next_run_at")
179
+ if not next_at:
180
+ next_at = _fmt_time(datetime.now() + timedelta(seconds=interval))
181
+ self._print("")
182
+ self._print("Waiting for next poll...")
183
+ self._print(f" Next run in: {interval}s")
184
+ self._print(f" Next run at: {next_at}")
185
+ self._print("")
186
+ return
187
+
188
+ if self.mode == "verbose" and event.message:
189
+ self._print(f" {event.message}")
190
+
191
+
192
+ class NullReporter(ConsoleReporter):
193
+ def __init__(self):
194
+ super().__init__(mode="default")
195
+
196
+ def render(self, event: LogEvent) -> None:
197
+ return
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ def clean_context(data: dict[str, Any] | None) -> dict[str, Any]:
8
+ if not data:
9
+ return {}
10
+ cleaned: dict[str, Any] = {}
11
+ for key, value in data.items():
12
+ if value is None:
13
+ continue
14
+ if isinstance(value, str) and value.strip() == "":
15
+ continue
16
+ cleaned[key] = value
17
+ return cleaned
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class LogEvent:
22
+ name: str
23
+ level: str = "info"
24
+ phase_index: int | None = None
25
+ phase_total: int | None = None
26
+ title: str | None = None
27
+ message: str | None = None
28
+ context: dict[str, Any] = field(default_factory=dict)
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ payload: dict[str, Any] = {
32
+ "name": self.name,
33
+ "level": self.level,
34
+ }
35
+ if self.phase_index is not None:
36
+ payload["phase_index"] = self.phase_index
37
+ if self.phase_total is not None:
38
+ payload["phase_total"] = self.phase_total
39
+ if self.title:
40
+ payload["title"] = self.title
41
+ if self.message:
42
+ payload["message"] = self.message
43
+ payload["context"] = clean_context(self.context)
44
+ return payload
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class SelectionOutcome:
8
+ code: str
9
+ reason: str
10
+ next_hint: str | None = None
11
+ selected_task_id: str | None = None
12
+ eligible_count: int = 0
13
+ excluded_count: int = 0
14
+ discovered_count: int = 0
15
+ total_tasks_for_source: int = 0
@@ -0,0 +1 @@
1
+ """Application service layer."""