forgex-cli 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 (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
forgex/cli/repl.py ADDED
@@ -0,0 +1,377 @@
1
+ """Persistent interactive REPL for FORGEX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shlex
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from prompt_toolkit import PromptSession
13
+ from prompt_toolkit.completion import WordCompleter
14
+ from prompt_toolkit.formatted_text import HTML
15
+ from prompt_toolkit.history import FileHistory
16
+ from prompt_toolkit.input import DummyInput, create_input
17
+ from prompt_toolkit.output import DummyOutput, create_output
18
+ from prompt_toolkit.styles import Style
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+
23
+ from forgex import __version__
24
+ from forgex.cli.intent import IntentAction, classify_intent
25
+ from forgex.cli.progress import render_pipeline_header, render_runtime_summary
26
+ from forgex.cli.runtime import AutonomousRuntime
27
+ from forgex.cli.session import Session
28
+ from forgex.config.loader import global_config_path
29
+ from forgex.schemas.error import ForgexError
30
+
31
+ BANNER = r"""[bold cyan]
32
+ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗
33
+ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝╚██╗██╔╝
34
+ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ╚███╔╝
35
+ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔██╗
36
+ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗██╔╝ ██╗
37
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝[/bold cyan]
38
+ [dim]Autonomous Adaptive Multi-Model Software Engineering CLI[/dim]
39
+ [dim]Just describe what you want. We Plan, Build, Verify & Repair. (v{version})[/dim]
40
+ """
41
+
42
+ COMMANDS = [
43
+ "do",
44
+ "init",
45
+ "plan",
46
+ "build",
47
+ "fix",
48
+ "review",
49
+ "verify",
50
+ "supervise",
51
+ "status",
52
+ "doctor",
53
+ "models",
54
+ "tools",
55
+ "checkpoint",
56
+ "rollback",
57
+ "diff",
58
+ "config",
59
+ "logs",
60
+ "task",
61
+ "context",
62
+ "run",
63
+ "cd",
64
+ "clear",
65
+ "help",
66
+ "exit",
67
+ "quit",
68
+ ]
69
+
70
+ SLASH_COMMANDS = [f"/{cmd}" for cmd in COMMANDS]
71
+
72
+ PROMPT_STYLE = Style.from_dict(
73
+ {
74
+ "brand": "#00d7ff bold",
75
+ "project": "#00ff87 bold",
76
+ "unattached": "#ffd700 dim",
77
+ "sep": "#666666",
78
+ "arrow": "#00ff87 bold",
79
+ }
80
+ )
81
+
82
+
83
+ class ForgexREPL:
84
+ """Interactive persistent shell session for FORGEX."""
85
+
86
+ def __init__(
87
+ self,
88
+ session: Session | None = None,
89
+ *,
90
+ output: Any = None,
91
+ input: Any = None,
92
+ ) -> None:
93
+ self.session = session or Session()
94
+ self.console = Console()
95
+ hist_dir = Path.home() / ".forgex"
96
+ try:
97
+ hist_dir.mkdir(parents=True, exist_ok=True)
98
+ except Exception:
99
+ pass
100
+ self.history_file = hist_dir / "repl_history"
101
+
102
+ # Safe input/output initialization to support headless CI & Windows non-console buffers
103
+ try:
104
+ pt_output = output or create_output()
105
+ except Exception:
106
+ pt_output = DummyOutput()
107
+
108
+ try:
109
+ pt_input = input or create_input()
110
+ except Exception:
111
+ pt_input = DummyInput()
112
+
113
+ try:
114
+ self.prompt_session: PromptSession = PromptSession(
115
+ history=FileHistory(str(self.history_file)),
116
+ completer=WordCompleter(COMMANDS + SLASH_COMMANDS, ignore_case=True),
117
+ style=PROMPT_STYLE,
118
+ output=pt_output,
119
+ input=pt_input,
120
+ )
121
+ except Exception:
122
+ self.prompt_session = PromptSession(
123
+ output=DummyOutput(),
124
+ input=DummyInput(),
125
+ )
126
+
127
+ def print_banner(self) -> None:
128
+ """Display the welcome banner and session overview."""
129
+ self.console.print(BANNER.format(version=__version__))
130
+
131
+ grid = Table.grid(padding=(0, 2))
132
+ grid.add_column(style="dim")
133
+ grid.add_column()
134
+
135
+ proj_display = (
136
+ f"[bold green]{self.session.project_root.name}[/bold green] [dim]({self.session.project_root})[/dim]"
137
+ if self.session.is_attached and self.session.project_root
138
+ else "[bold yellow]None[/bold yellow] [dim](Type 'init' or 'cd <dir>' to attach)[/dim]"
139
+ )
140
+ grid.add_row("Attached Project:", proj_display)
141
+
142
+ arch_model = f"{self.session.config.models.architect.provider}:{self.session.config.models.architect.model}"
143
+ grid.add_row("Default Model:", f"[cyan]{arch_model}[/cyan]")
144
+ grid.add_row("Permissions:", f"[magenta]{self.session.config.permissions.mode}[/magenta]")
145
+ if os.environ.get("FORGEX_OFFLINE") == "1":
146
+ grid.add_row("Network:", "[red]Offline mode active[/red]")
147
+
148
+ self.console.print(Panel(grid, title="[bold]Session Context[/bold]", border_style="cyan"))
149
+ self.console.print("[dim]Type [bold white]help[/bold white] for commands, or describe what to build in plain English.[/dim]\n")
150
+
151
+ def check_first_run(self) -> None:
152
+ """Guide the user through onboarding if global config does not exist."""
153
+ g_cfg = global_config_path()
154
+ if not g_cfg.is_file():
155
+ self.console.print(
156
+ Panel(
157
+ "[bold yellow]First time using FORGEX?[/bold yellow]\n\n"
158
+ "No global configuration was found at ~/.forgex/config.toml.\n"
159
+ "Let's configure your AI model provider and local profile.",
160
+ border_style="yellow",
161
+ )
162
+ )
163
+ if sys.stdin.isatty():
164
+ from rich.prompt import Confirm
165
+ if Confirm.ask("Launch the guided setup wizard now?", default=True):
166
+ self._run_cli_command(["config", "setup"])
167
+
168
+ def print_help(self) -> None:
169
+ """Display help table for natural language usage and power user commands."""
170
+ self.console.print(
171
+ Panel(
172
+ "[bold white]Describe what you want to build in plain English:[/bold white]\n\n"
173
+ " [cyan]❯[/cyan] [green]Build me a REST API with FastAPI and SQLite[/green]\n"
174
+ " [cyan]❯[/cyan] [green]Add JWT authentication with refresh tokens[/green]\n"
175
+ " [cyan]❯[/cyan] [green]Fix the failing tests in users.py[/green]\n"
176
+ " [cyan]❯[/cyan] [green]Create a full-stack dashboard with React[/green]\n\n"
177
+ "[dim]FORGEX will automatically initialize, plan, build, verify, repair, and checkpoint.[/dim]",
178
+ title="[bold cyan]FORGEX Universal CLI — How It Works[/bold cyan]",
179
+ border_style="cyan",
180
+ )
181
+ )
182
+
183
+ table = Table(title="Power User Commands (Optional Granular Control)", border_style="dim", show_header=True)
184
+ table.add_column("Command", style="bold cyan", width=22)
185
+ table.add_column("Description")
186
+
187
+ workflows = [
188
+ ("do <request>", "Execute end-to-end autonomous pipeline directly"),
189
+ ("plan <goal>", "Run only Architect: planning & task breakdown"),
190
+ ("build", "Run build pipeline for existing plan tasks"),
191
+ ("fix", "Auto-repair errors until tests pass — no rebuild"),
192
+ ("verify", "Deterministic verification (tests, lint, typecheck)"),
193
+ ("review", "Senior engineer code review of diff"),
194
+ ("supervise", "Run verification + code audit + soft gates verdict"),
195
+ ("status", "Project state, task breakdown, branch, and token usage"),
196
+ ("diff", "Inspect current uncommitted git changes"),
197
+ ("checkpoint [msg]", "Create reversible snapshot of codebase state"),
198
+ ("rollback [id]", "Reversibly rollback to a checkpoint"),
199
+ ("init [dir]", "Initialize FORGEX in current or target directory"),
200
+ ("models [catalog|health]", "Inspect configured models and providers"),
201
+ ("tools [list|run]", "Inspect and manually execute system tools"),
202
+ ("config [show|set|setup]", "View or modify configuration"),
203
+ ("doctor", "Validate environment, dependencies, and network"),
204
+ ("cd <dir>", "Switch directory and auto-attach project"),
205
+ ("clear", "Clear terminal screen"),
206
+ ("exit / quit", "Exit the interactive session"),
207
+ ]
208
+ for cmd, desc in workflows:
209
+ table.add_row(cmd, desc)
210
+
211
+ self.console.print(table)
212
+
213
+ def get_prompt_text(self) -> HTML:
214
+ """Generate dynamic colored prompt based on session state."""
215
+ if self.session.is_attached and self.session.project_root:
216
+ name = self.session.project_root.name
217
+ return HTML(f"<brand>forgex</brand> <sep>[</sep><project>{name}</project><sep>]</sep> <arrow>❯</arrow> ")
218
+ return HTML("<brand>forgex</brand> <arrow>❯</arrow> ")
219
+
220
+ def dispatch(self, raw_input: str) -> None:
221
+ """Dispatch user input to built-ins, Typer commands, or natural language intent."""
222
+ text = raw_input.strip()
223
+ if not text:
224
+ return
225
+
226
+ self.session.history.append(text)
227
+
228
+ # Strip optional leading slash
229
+ if text.startswith("/"):
230
+ text = text[1:].strip()
231
+
232
+ tokens = shlex.split(text, posix=sys.platform != "win32")
233
+ if not tokens:
234
+ return
235
+
236
+ cmd = tokens[0].lower()
237
+ args = tokens[1:]
238
+
239
+ # Handle built-ins
240
+ if cmd in ("exit", "quit", "q"):
241
+ raise EOFError
242
+ if cmd in ("clear", "cls"):
243
+ self.console.clear()
244
+ return
245
+ if cmd in ("help", "?"):
246
+ self.print_help()
247
+ return
248
+ if cmd == "cd":
249
+ raw_target = text[len(tokens[0]):].strip() if len(tokens) > 0 else ""
250
+ if raw_target:
251
+ target = raw_target.strip("'\"")
252
+ else:
253
+ target = str(Path.home())
254
+ if not self.session.change_directory(target):
255
+ self.console.print(f"[red]Directory not found: {target}[/red]")
256
+ else:
257
+ self.console.print(f"[dim]Switched to {Path.cwd()}[/dim]")
258
+ return
259
+
260
+ if cmd == "do" and len(tokens) > 1:
261
+ req = " ".join(tokens[1:])
262
+ self._run_autonomous(req, raw_input=text)
263
+ return
264
+
265
+ # Commands that do not accept positional words: if followed by prose (e.g. "build me a ...",
266
+ # "fix the failing ..."), treat as natural language rather than CLI syntax.
267
+ nl_commands = {"build", "fix", "verify", "supervise", "status", "doctor", "diff", "logs", "do"}
268
+ if cmd in nl_commands and len(tokens) > 1 and not tokens[1].startswith("-"):
269
+ pass
270
+ elif cmd in COMMANDS:
271
+ self._run_cli_command(tokens)
272
+ return
273
+
274
+ # Natural language intent classification
275
+ intent = classify_intent(text)
276
+ if intent.action == IntentAction.AUTONOMOUS:
277
+ target_req = intent.raw_request or text
278
+ effective_req = self.session.context.enrich_request(target_req)
279
+ self._run_autonomous(effective_req, raw_input=text)
280
+ return
281
+ elif intent.action != IntentAction.UNKNOWN and intent.confidence >= 0.5:
282
+ steps_desc = " → ".join(" ".join(step) for step in intent.pipeline)
283
+ self.console.print(
284
+ f"[bold cyan]Intent recognized:[/bold cyan] {intent.description}\n"
285
+ f"[dim]Pipeline: {steps_desc}[/dim]"
286
+ )
287
+ self._run_pipeline(intent.pipeline)
288
+ else:
289
+ self.console.print(
290
+ f"[yellow]Unrecognized command: '{cmd}'.[/yellow] "
291
+ "Type [bold]help[/bold] for available commands or describe your task in plain English."
292
+ )
293
+
294
+ def _run_autonomous(self, request: str, raw_input: str) -> bool:
295
+ """Run the end-to-end autonomous pipeline directly."""
296
+ render_pipeline_header(request, console=self.console)
297
+ runtime = AutonomousRuntime(session=self.session, ui=self.session.ui)
298
+ try:
299
+ result = asyncio.run(runtime.execute(request))
300
+ render_runtime_summary(result, console=self.console)
301
+ self.session.context.record_run(
302
+ request=raw_input,
303
+ status=result.status,
304
+ changed_files=result.changed_files,
305
+ checkpoint_id=result.checkpoint_id,
306
+ )
307
+ return result.is_success
308
+ except KeyboardInterrupt:
309
+ self.console.print("\n[yellow]Autonomous run cancelled by user.[/yellow]")
310
+ return False
311
+ except Exception as exc:
312
+ self.console.print(f"[red]Autonomous run error:[/red] {exc}")
313
+ return False
314
+ finally:
315
+ self.session.refresh()
316
+
317
+ def _run_pipeline(self, pipeline: list[list[str]]) -> None:
318
+ """Execute a pipeline of CLI commands sequentially, stopping on failure."""
319
+ for i, step in enumerate(pipeline, start=1):
320
+ if len(pipeline) > 1:
321
+ self.console.print(f"\n[bold green]▶ Step {i}/{len(pipeline)}: {' '.join(step)}[/bold green]")
322
+ ok = self._run_cli_command(step)
323
+ if not ok:
324
+ self.console.print(f"[red]Pipeline stopped due to error in step: {' '.join(step)}[/red]")
325
+ break
326
+
327
+ def _run_cli_command(self, args: list[str]) -> bool:
328
+ """Invoke a Typer CLI command within the REPL session."""
329
+ from typer.testing import CliRunner
330
+
331
+ from forgex.cli.app import app
332
+
333
+ runner = CliRunner()
334
+ success = False
335
+ try:
336
+ result = runner.invoke(app, args, catch_exceptions=True)
337
+ if result.output:
338
+ # Print output directly
339
+ sys.stdout.write(result.output)
340
+ sys.stdout.flush()
341
+ if result.exit_code != 0 and result.exception:
342
+ if isinstance(result.exception, ForgexError):
343
+ self.console.print(f"[red]✗[/red] {result.exception}")
344
+ elif not isinstance(result.exception, SystemExit):
345
+ self.console.print(f"[red]Error:[/red] {result.exception}")
346
+ success = result.exit_code == 0
347
+ except ForgexError as exc:
348
+ self.console.print(f"[red]✗[/red] {exc}")
349
+ except KeyboardInterrupt:
350
+ self.console.print("\n[yellow]Operation cancelled by user.[/yellow]")
351
+ except Exception as exc: # noqa: BLE001
352
+ self.console.print(f"[red]Unexpected error:[/red] {type(exc).__name__}: {exc}")
353
+ finally:
354
+ self.session.refresh()
355
+ return success
356
+
357
+ def run(self) -> None:
358
+ """Main REPL loop."""
359
+ self.print_banner()
360
+ self.check_first_run()
361
+
362
+ while True:
363
+ try:
364
+ line = self.prompt_session.prompt(self.get_prompt_text())
365
+ self.dispatch(line)
366
+ except KeyboardInterrupt:
367
+ self.console.print("\n[yellow]^C (Use 'exit' or Ctrl+D to quit)[/yellow]")
368
+ continue
369
+ except EOFError:
370
+ self.console.print("\n[dim]Goodbye! On-disk state is preserved.[/dim]")
371
+ break
372
+
373
+
374
+ def start_repl(session: Session | None = None) -> None:
375
+ """Entry point to launch the FORGEX REPL."""
376
+ repl = ForgexREPL(session=session)
377
+ repl.run()
forgex/cli/runtime.py ADDED
@@ -0,0 +1,233 @@
1
+ """Autonomous runtime engine for FORGEX Universal CLI.
2
+
3
+ Unifies project initialization, planning, building, verification,
4
+ supervision, self-healing repairs, and git checkpoints into a single,
5
+ frictionless autonomous pipeline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import subprocess
13
+ import time
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ from rich.console import Console
20
+
21
+ from forgex.cli.build_views import format_progress
22
+ from forgex.cli.session import Session
23
+ from forgex.cli.ui import UI, Output, make_approval
24
+ from forgex.config import load_config
25
+ from forgex.orchestration.checkpoints import latest_checkpoint
26
+ from forgex.orchestration.engine import BuildEngine, DryRunReport
27
+ from forgex.orchestration.stores import open_stores
28
+ from forgex.project import initialize_project
29
+ from forgex.schemas.result import TestResults
30
+ from forgex.utils.paths import find_project_root
31
+
32
+ logger = logging.getLogger("forgex.runtime")
33
+
34
+ ProgressFn = Callable[[str], None]
35
+
36
+
37
+ @dataclass
38
+ class RuntimeResult:
39
+ """Consolidated outcome of an autonomous execution."""
40
+
41
+ status: Literal["completed", "failed"]
42
+ request: str
43
+ phases_completed: list[str] = field(default_factory=list)
44
+ tasks_total: int = 0
45
+ tasks_completed: int = 0
46
+ changed_files: list[str] = field(default_factory=list)
47
+ tests: TestResults | None = None
48
+ build_ok: bool | None = None
49
+ checkpoint_id: str | None = None
50
+ reason: str = ""
51
+ warnings: list[str] = field(default_factory=list)
52
+ usage: dict[str, Any] | None = None
53
+ duration_seconds: float = 0.0
54
+
55
+ @property
56
+ def is_success(self) -> bool:
57
+ return self.status == "completed"
58
+
59
+
60
+ class AutonomousRuntime:
61
+ """Unified autonomous pipeline executor for FORGEX.
62
+
63
+ Takes a high-level user request in natural language and drives:
64
+ 1. Zero-friction project initialization if not already initialized
65
+ 2. Architecture planning and task decomposition
66
+ 3. Per-task implementation with builder & expert coder
67
+ 4. Deterministic testing, linting, and build verification
68
+ 5. Supervisor auditing and soft gates
69
+ 6. Self-healing repair loops until green (bounded)
70
+ 7. Automated git checkpoint snapshot on completion
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ target_dir: Path | None = None,
76
+ session: Session | None = None,
77
+ ui: UI | None = None,
78
+ progress: ProgressFn | None = None,
79
+ approval: Callable[[str], bool] | None = None,
80
+ ) -> None:
81
+ self.target_dir = Path(target_dir).resolve() if target_dir else Path.cwd()
82
+ self.session = session
83
+ self.ui = ui or (session.ui if session else UI(mode=Output.RICH))
84
+ self.console = self.ui.console if hasattr(self.ui, "console") else Console()
85
+ self._custom_progress = progress
86
+ self._approval = approval or (make_approval(self.ui) if self.ui else None)
87
+
88
+ def _notify_progress(self, msg: str) -> None:
89
+ if self._custom_progress:
90
+ self._custom_progress(msg)
91
+ elif self.ui:
92
+ is_rich = getattr(self.ui, "mode", None) is Output.RICH
93
+ self.ui.text(format_progress(msg, rich_mode=is_rich))
94
+
95
+ def _ensure_project_initialized(self) -> tuple[Path, bool]:
96
+ """Check if target directory is a FORGEX project; auto-initialize if not."""
97
+ root = find_project_root(self.target_dir)
98
+ was_initialized = False
99
+
100
+ if root is None or not (root / ".forgex").is_dir():
101
+ self._notify_progress(f"Initializing project workspace at {self.target_dir}...")
102
+ # Auto-init git if git is available and repo doesn't exist
103
+ git_dir = self.target_dir / ".git"
104
+ if not git_dir.exists():
105
+ try:
106
+ subprocess.run(
107
+ ["git", "init"],
108
+ cwd=self.target_dir,
109
+ capture_output=True,
110
+ text=True,
111
+ check=False,
112
+ )
113
+ except Exception as exc:
114
+ logger.debug("git init failed: %s", exc)
115
+
116
+ root = initialize_project(self.target_dir)
117
+ was_initialized = True
118
+ if self.session:
119
+ self.session.refresh()
120
+ self._notify_progress("Project workspace initialized.")
121
+
122
+ return root, was_initialized
123
+
124
+ async def execute(
125
+ self,
126
+ request: str,
127
+ *,
128
+ resume: bool = False,
129
+ dry_run: bool = False,
130
+ agents: dict[str, Any] | None = None,
131
+ ) -> RuntimeResult:
132
+ """Run the full autonomous pipeline for the given task request."""
133
+ start_time = time.time()
134
+ phases: list[str] = []
135
+
136
+ root, was_newly_inited = self._ensure_project_initialized()
137
+ if was_newly_inited:
138
+ phases.append("init")
139
+
140
+ config = load_config(root)
141
+
142
+ with open_stores(root) as stores:
143
+ engine = BuildEngine(
144
+ stores=stores,
145
+ config=config,
146
+ approval=self._approval,
147
+ progress=self._notify_progress,
148
+ agents=agents,
149
+ )
150
+
151
+ build_result = await engine.run(
152
+ request=request,
153
+ dry_run=dry_run,
154
+ resume=resume,
155
+ )
156
+
157
+ duration = time.time() - start_time
158
+
159
+ if isinstance(build_result, DryRunReport):
160
+ phases.extend(["plan", "preview"])
161
+ return RuntimeResult(
162
+ status="completed",
163
+ request=request,
164
+ phases_completed=phases,
165
+ tasks_total=len(build_result.planned_tasks),
166
+ tasks_completed=0,
167
+ changed_files=build_result.planned_files,
168
+ reason="Dry run preview generated successfully",
169
+ duration_seconds=duration,
170
+ )
171
+
172
+ phases.extend(["plan", "build", "verify", "supervise"])
173
+
174
+ checkpoint_id = build_result.checkpoint
175
+ if build_result.status == "completed":
176
+ phases.append("checkpoint")
177
+ # If checkpoint wasn't automatically populated by git, look up latest checkpoint
178
+ if not checkpoint_id and (root / ".git").exists():
179
+ try:
180
+ with open_stores(root) as stores:
181
+ latest = latest_checkpoint(stores=stores)
182
+ if latest:
183
+ checkpoint_id = latest.git_commit
184
+ except Exception:
185
+ pass
186
+
187
+ runtime_res = RuntimeResult(
188
+ status=build_result.status,
189
+ request=request,
190
+ phases_completed=phases,
191
+ tasks_total=build_result.tasks_total,
192
+ tasks_completed=build_result.tasks_completed,
193
+ changed_files=build_result.changed_files,
194
+ tests=build_result.tests,
195
+ build_ok=build_result.build_ok,
196
+ checkpoint_id=checkpoint_id,
197
+ reason=build_result.reason,
198
+ warnings=build_result.warnings,
199
+ usage=build_result.usage,
200
+ duration_seconds=duration,
201
+ )
202
+
203
+ if self.session:
204
+ self.session.refresh()
205
+
206
+ return runtime_res
207
+
208
+
209
+ def execute_autonomous(
210
+ request: str,
211
+ target_dir: Path | None = None,
212
+ session: Session | None = None,
213
+ ui: UI | None = None,
214
+ dry_run: bool = False,
215
+ resume: bool = False,
216
+ approval: Callable[[str], bool] | None = None,
217
+ agents: dict[str, Any] | None = None,
218
+ ) -> RuntimeResult:
219
+ """Synchronous convenience wrapper for AutonomousRuntime.execute()."""
220
+ runtime = AutonomousRuntime(
221
+ target_dir=target_dir,
222
+ session=session,
223
+ ui=ui,
224
+ approval=approval,
225
+ )
226
+ return asyncio.run(
227
+ runtime.execute(
228
+ request=request,
229
+ dry_run=dry_run,
230
+ resume=resume,
231
+ agents=agents,
232
+ )
233
+ )