forgeoptimizer 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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,155 @@
1
+ """High-level runner that wraps the ExecutionEngine for CLI use.
2
+
3
+ Provides :func:`run_engine` which accepts the same high-level
4
+ parameters as the CLI (prompt, provider, optimizer, graph, etc.)
5
+ and returns an :class:`EngineResult` ready for rendering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from forgecli.engine.context import EngineContext
14
+ from forgecli.engine.defaults import default_registry
15
+ from forgecli.engine.execution import EngineResult, ExecutionEngine
16
+
17
+
18
+ def run_engine(
19
+ prompt: str,
20
+ root: Path,
21
+ *,
22
+ provider: Any = None,
23
+ optimizer: Any = None,
24
+ caveman_optimizer: Any = None,
25
+ graph: Any = None,
26
+ classifier: Any = None,
27
+ router: Any = None,
28
+ test_command: str | None = None,
29
+ test_timeout: float = 120.0,
30
+ retries: int = 0,
31
+ skip_tests: bool = False,
32
+ skip_graph: bool = False,
33
+ skip_ponytail: bool = False,
34
+ skip_caveman: bool = False,
35
+ pipeline_names: tuple[str, ...] | None = None,
36
+ extras: dict[str, Any] | None = None,
37
+ ) -> EngineResult:
38
+ """Build an :class:`ExecutionEngine`, run the pipeline, and return the result.
39
+
40
+ Parameters mirror the CLI flags; keyword arguments not consumed by
41
+ this function are forwarded to :func:`default_registry`.
42
+
43
+ Returns an :class:`EngineResult` that callers can render as text
44
+ or JSON.
45
+ """
46
+ import asyncio
47
+
48
+ engine_ctx = EngineContext(
49
+ prompt=prompt,
50
+ cwd=root,
51
+ )
52
+ if extras:
53
+ engine_ctx.extras.update(extras)
54
+
55
+ if not skip_caveman and caveman_optimizer is not None:
56
+ engine_ctx.extras["caveman_optimizer"] = caveman_optimizer
57
+ if not skip_graph and graph is not None:
58
+ engine_ctx.extras["graph"] = graph
59
+ if not skip_ponytail and optimizer is not None:
60
+ engine_ctx.extras["optimizer"] = optimizer
61
+ if provider is not None:
62
+ engine_ctx.extras["provider"] = provider
63
+ if retries:
64
+ engine_ctx.extras["retries"] = retries
65
+ if test_command is not None and not skip_tests:
66
+ engine_ctx.extras["test_command"] = test_command
67
+ engine_ctx.extras["test_timeout"] = test_timeout
68
+
69
+ registry = default_registry(
70
+ provider=provider,
71
+ optimizer=optimizer if not skip_ponytail else None,
72
+ caveman_optimizer=caveman_optimizer if not skip_caveman else None,
73
+ graph=graph if not skip_graph else None,
74
+ classifier=classifier,
75
+ router=router,
76
+ test_command=None if skip_tests else test_command,
77
+ )
78
+
79
+ names = pipeline_names or ExecutionEngine.DEFAULT_PIPELINE
80
+ engine = ExecutionEngine.from_registry(registry, names=names)
81
+
82
+ return asyncio.run(engine.run(engine_ctx))
83
+
84
+
85
+ def engine_result_to_dict(result: EngineResult) -> dict[str, object]:
86
+ """Return a JSON-serializable view of an :class:`EngineResult`."""
87
+ ctx = result.context
88
+ return {
89
+ "success": result.success,
90
+ "cancelled": result.cancelled,
91
+ "failed_stage": result.failed_stage,
92
+ "error": result.error,
93
+ "prompt": ctx.prompt,
94
+ "run_id": ctx.run_id,
95
+ "intent": ctx.intent_analysis.intent.value if ctx.intent_analysis else None,
96
+ "intent_confidence": ctx.intent_analysis.confidence if ctx.intent_analysis else 0.0,
97
+ "retrieval_match_count": len(ctx.retrieval.matched_nodes) if ctx.retrieval else 0,
98
+ "model": ctx.model_selection.model if ctx.model_selection else None,
99
+ "provider": ctx.model_selection.provider if ctx.model_selection else None,
100
+ "diff_length": len(ctx.diff_text),
101
+ "applied_files": [str(p) for p in ctx.applied_files],
102
+ "test_returncode": ctx.test_returncode,
103
+ "fix_attempts": ctx.fix_attempts,
104
+ "stages": [
105
+ {
106
+ "name": log.stage,
107
+ "status": log.status,
108
+ "duration_seconds": log.duration_seconds,
109
+ "error": log.error,
110
+ }
111
+ for log in ctx.log
112
+ ],
113
+ }
114
+
115
+
116
+ def render_engine_result(result: EngineResult) -> str:
117
+ """Return a human-readable summary of the engine run.
118
+
119
+ Format is similar to the build pipeline's ``build_summary`` but
120
+ reads from :class:`EngineContext` instead of :class:`BuildContext`.
121
+ """
122
+ ctx = result.context
123
+ lines: list[str] = []
124
+ lines.append(f"Goal: {ctx.prompt}")
125
+ if ctx.model_selection is not None:
126
+ ms = ctx.model_selection
127
+ lines.append(
128
+ f"Route: {ms.provider}/{ms.model} (mode={ms.mode}, "
129
+ f"in=${ms.cost_in:.5f}/1k, out=${ms.cost_out:.5f}/1k)"
130
+ )
131
+ if ctx.applied_files:
132
+ rel = [str(p.relative_to(ctx.cwd) if p.is_relative_to(ctx.cwd) else p) for p in ctx.applied_files]
133
+ lines.append(f"Files touched ({len(rel)}):")
134
+ for path in rel:
135
+ lines.append(f" - {path}")
136
+ else:
137
+ lines.append("Files touched: (none)")
138
+ if ctx.test_returncode is None:
139
+ lines.append("Tests: skipped")
140
+ elif ctx.test_returncode == 0:
141
+ lines.append("Tests: passed")
142
+ else:
143
+ lines.append(f"Tests: FAILED (exit {ctx.test_returncode})")
144
+ total = sum(log.duration_seconds for log in ctx.log)
145
+ lines.append(f"Total time: {total:.2f}s across {len(ctx.log)} stages")
146
+ if result.error:
147
+ lines.append(f"Error: {result.error}")
148
+ return "\n".join(lines)
149
+
150
+
151
+ __all__ = [
152
+ "engine_result_to_dict",
153
+ "render_engine_result",
154
+ "run_engine",
155
+ ]
@@ -0,0 +1,33 @@
1
+ """Concrete Stage implementations for the Execution Engine.
2
+
3
+ Each module in this package implements one or more :class:`Stage`
4
+ protocol objects that wire into the engine's
5
+ :attr:`~forgecli.engine.execution.ExecutionEngine.DEFAULT_PIPELINE`.
6
+
7
+ Stages are thin adapters over the existing build pipeline functions
8
+ in :mod:`forgecli.build` — they map between :class:`EngineContext`
9
+ and :class:`BuildContext`, then delegate to the well-tested build
10
+ functions.
11
+ """
12
+
13
+ from forgecli.engine.stages.caveman_optimizer import CavemanOptimizerStage
14
+ from forgecli.engine.stages.context_optimizer import ContextOptimizerStage
15
+ from forgecli.engine.stages.execution_engine_stage import ExecutionEngineStage
16
+ from forgecli.engine.stages.git_engine import GitEngineStage
17
+ from forgecli.engine.stages.intent_analyzer import IntentAnalyzerStage
18
+ from forgecli.engine.stages.model_router import ModelRouterStage
19
+ from forgecli.engine.stages.planning_engine import PlanningEngineStage
20
+ from forgecli.engine.stages.repository_analyzer import RepositoryAnalyzerStage
21
+ from forgecli.engine.stages.validation_engine import ValidationEngineStage
22
+
23
+ __all__ = [
24
+ "CavemanOptimizerStage",
25
+ "ContextOptimizerStage",
26
+ "ExecutionEngineStage",
27
+ "GitEngineStage",
28
+ "IntentAnalyzerStage",
29
+ "ModelRouterStage",
30
+ "PlanningEngineStage",
31
+ "RepositoryAnalyzerStage",
32
+ "ValidationEngineStage",
33
+ ]
@@ -0,0 +1,47 @@
1
+ """Stage 3 — Caveman Optimizer.
2
+
3
+ Applies the Caveman ruleset to the user prompt to reduce token usage
4
+ in LLM responses. Wraps :func:`forgecli.build.caveman_optimize.caveman_optimization`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from forgecli.build import BuildContext
12
+ from forgecli.build.caveman_optimize import caveman_optimization as _build_caveman_optimize
13
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
14
+ from forgecli.optimizer.caveman import CavemanPromptOptimizer
15
+
16
+
17
+ class CavemanOptimizerStage:
18
+ """Optimize the prompt using Caveman rules."""
19
+
20
+ name = "caveman-optimizer"
21
+
22
+ def __init__(self, optimizer: CavemanPromptOptimizer | None = None) -> None:
23
+ self._optimizer = optimizer
24
+
25
+ async def __call__(self, context: StageContext) -> StageResult:
26
+ optimizer = self._optimizer or context.engine.extras.get("caveman_optimizer")
27
+ build_ctx = BuildContext(
28
+ prompt=context.engine.prompt,
29
+ root=Path(context.engine.cwd),
30
+ )
31
+ build_ctx.extras["caveman_optimizer"] = optimizer
32
+
33
+ build_ctx = await _build_caveman_optimize(build_ctx)
34
+
35
+ context.engine.caveman_optimized_request = build_ctx.caveman_optimized_request
36
+ context.engine.caveman_optimized_notes = build_ctx.caveman_optimized_notes
37
+
38
+ return StageResult(
39
+ status=StageStatus.SUCCEEDED,
40
+ data={
41
+ "notes": list(build_ctx.caveman_optimized_notes),
42
+ },
43
+ notes=build_ctx.caveman_optimized_notes or ("no caveman optimization applied",),
44
+ )
45
+
46
+
47
+ __all__ = ["CavemanOptimizerStage"]
@@ -0,0 +1,44 @@
1
+ """Stage 3 — Context Optimizer.
2
+
3
+ Applies the Ponytail ruleset to the user prompt. Wraps
4
+ :func:`forgecli.build.optimize.ponytail_optimization`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from forgecli.build import BuildContext
12
+ from forgecli.build.optimize import ponytail_optimization as _build_optimize
13
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
14
+ from forgecli.optimizer.ponytail import PromptOptimizer
15
+
16
+
17
+ class ContextOptimizerStage:
18
+ """Optimize the prompt using Ponytail rules."""
19
+
20
+ name = "context-optimizer"
21
+
22
+ def __init__(self, optimizer: PromptOptimizer | None = None) -> None:
23
+ self._optimizer = optimizer
24
+
25
+ async def __call__(self, context: StageContext) -> StageResult:
26
+ optimizer = self._optimizer or context.engine.extras.get("optimizer")
27
+ build_ctx = BuildContext(
28
+ prompt=context.engine.prompt,
29
+ root=Path(context.engine.cwd),
30
+ )
31
+ build_ctx.extras["optimizer"] = optimizer
32
+
33
+ build_ctx = await _build_optimize(build_ctx)
34
+
35
+ context.engine.optimized_request = build_ctx.optimized_request
36
+ context.engine.optimized_notes = build_ctx.optimized_notes
37
+
38
+ return StageResult(
39
+ status=StageStatus.SUCCEEDED,
40
+ data={
41
+ "notes": list(build_ctx.optimized_notes),
42
+ },
43
+ notes=build_ctx.optimized_notes or ("no optimization applied",),
44
+ )
@@ -0,0 +1,108 @@
1
+ """Stage 6 - Execution Engine.
2
+
3
+ Invokes the LLM with the assembled prompt and extracts a unified diff
4
+ from the response. Combines :func:`forgecli.build.llm.llm_call` and
5
+ :func:`forgecli.build.diff_extract.diff_extraction` into a single stage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from forgecli.build import BuildContext
13
+ from forgecli.build.diff_extract import diff_extraction
14
+ from forgecli.build.llm import llm_call
15
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
16
+ from forgecli.providers.base import ChatMessage, ChatRequest, Provider, Role
17
+ from forgecli.providers.router import RouteDecision
18
+
19
+
20
+ class ExecutionEngineStage:
21
+ """Send the prompt to the LLM and extract a diff."""
22
+
23
+ name = "execution-engine"
24
+
25
+ def __init__(self, provider: Provider | None = None) -> None:
26
+ self._provider = provider
27
+
28
+ async def __call__(self, context: StageContext) -> StageResult:
29
+ provider = self._provider or context.engine.extras.get("provider")
30
+ if provider is None:
31
+ return StageResult(
32
+ status=StageStatus.FAILED,
33
+ error="no provider available for LLM call",
34
+ notes=("no provider in extras or constructor",),
35
+ )
36
+
37
+ decision: RouteDecision | None = context.engine.extras.get("decision")
38
+ build_ctx = BuildContext(
39
+ prompt=context.engine.prompt,
40
+ root=Path(context.engine.cwd),
41
+ decision=decision,
42
+ )
43
+ if context.engine.retrieval is not None:
44
+ build_ctx.retrieval = context.engine.retrieval.context_text
45
+ if context.engine.optimized_request is not None:
46
+ build_ctx.optimized_request = context.engine.optimized_request
47
+ build_ctx.optimized_notes = context.engine.optimized_notes
48
+ # Merge caveman-optimized system prompt into the final request.
49
+ if (
50
+ context.engine.caveman_optimized_request is not None
51
+ and context.engine.optimized_request is not None
52
+ ):
53
+ build_ctx.optimized_request = _merge_caveman_request(
54
+ context.engine.caveman_optimized_request,
55
+ context.engine.optimized_request,
56
+ )
57
+ elif context.engine.caveman_optimized_request is not None:
58
+ build_ctx.optimized_request = context.engine.caveman_optimized_request
59
+ retries = int(context.engine.extras.get("retries", 0))
60
+ build_ctx.extras["provider"] = provider
61
+ build_ctx.extras["retries"] = retries
62
+
63
+ build_ctx = await llm_call(build_ctx)
64
+ build_ctx = await diff_extraction(build_ctx)
65
+
66
+ context.engine.response = build_ctx.response
67
+ context.engine.diff_text = build_ctx.diff_text
68
+
69
+ diff_len = len(build_ctx.diff_text)
70
+ notes: tuple[str, ...] = ()
71
+ if diff_len > 0:
72
+ notes = (f"extracted {diff_len}-char diff",)
73
+ else:
74
+ notes = ("no diff found in LLM response",)
75
+
76
+ return StageResult(
77
+ status=StageStatus.SUCCEEDED,
78
+ data={
79
+ "diff_length": diff_len,
80
+ "has_response": build_ctx.response is not None,
81
+ },
82
+ notes=notes,
83
+ )
84
+
85
+
86
+ def _merge_caveman_request(
87
+ caveman_req: ChatRequest,
88
+ ponytail_req: ChatRequest,
89
+ ) -> ChatRequest:
90
+ """Merge caveman and ponytail system prompts into one request."""
91
+ caveman_sys = [
92
+ m for m in caveman_req.messages if m.role is Role.SYSTEM
93
+ ]
94
+ ponytail_sys = [
95
+ m for m in ponytail_req.messages if m.role is Role.SYSTEM
96
+ ]
97
+ other_messages = [
98
+ m for m in ponytail_req.messages if m.role is not Role.SYSTEM
99
+ ]
100
+
101
+ merged_sys_content = "\n\n".join(
102
+ m.content for m in [*caveman_sys, *ponytail_sys] if m.content.strip()
103
+ )
104
+ merged_messages: list[ChatMessage] = [
105
+ ChatMessage(role=Role.SYSTEM, content=merged_sys_content),
106
+ *other_messages,
107
+ ]
108
+ return ponytail_req.model_copy(update={"messages": merged_messages})
@@ -0,0 +1,30 @@
1
+ """Stage 8 — Git Engine (placeholder).
2
+
3
+ Stages and optionally pushes the applied changes. Currently
4
+ a pass-through that records intent; actual git integration will follow.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
10
+
11
+
12
+ class GitEngineStage:
13
+ """Stage and push applied changes."""
14
+
15
+ name = "git-engine"
16
+
17
+ async def __call__(self, context: StageContext) -> StageResult:
18
+ if not context.engine.applied_files:
19
+ return StageResult(
20
+ status=StageStatus.SKIPPED,
21
+ notes=("no files to stage",),
22
+ data={"staged": False},
23
+ )
24
+
25
+ context.engine.staged = True
26
+ return StageResult(
27
+ status=StageStatus.SUCCEEDED,
28
+ notes=("files staged (placeholder)",),
29
+ data={"staged": True},
30
+ )
@@ -0,0 +1,38 @@
1
+ """Stage 1 — Intent Analyzer.
2
+
3
+ Classifies the user's prompt into an :class:`Intent` using the
4
+ :class:`HeuristicIntentClassifier`. The result is stored in
5
+ ``context.engine.intent_analysis``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from forgecli.engine.context import IntentAnalysis
11
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
12
+ from forgecli.orchestrator import HeuristicIntentClassifier
13
+ from forgecli.plugins import IntentClassifier
14
+
15
+
16
+ class IntentAnalyzerStage:
17
+ """Classify the user prompt into an :class:`Intent`."""
18
+
19
+ name = "intent-analyzer"
20
+
21
+ def __init__(self, classifier: IntentClassifier | None = None) -> None:
22
+ self._classifier = classifier or HeuristicIntentClassifier()
23
+
24
+ async def __call__(self, context: StageContext) -> StageResult:
25
+ prediction = self._classifier.classify(context.engine.prompt)
26
+ context.engine.intent_analysis = IntentAnalysis(
27
+ intent=prediction.intent,
28
+ confidence=prediction.confidence,
29
+ rationale=prediction.rationale,
30
+ )
31
+ return StageResult(
32
+ status=StageStatus.SUCCEEDED,
33
+ data={
34
+ "intent": prediction.intent.value,
35
+ "confidence": prediction.confidence,
36
+ },
37
+ notes=prediction.rationale,
38
+ )
@@ -0,0 +1,66 @@
1
+ """Stage 5 — Model Router.
2
+
3
+ Resolves the user's model choice (or "auto") into a concrete
4
+ (provider, model) pair using :class:`ModelRouter`. The result
5
+ is stored in ``context.engine.model_selection``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from forgecli.engine.context import ModelSelection
11
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
12
+ from forgecli.providers.router import ModelCapabilities, ModelRouter
13
+ from forgecli.providers.router_state import RouterState, load_state
14
+
15
+
16
+ class ModelRouterStage:
17
+ """Resolve the model choice and store the selection."""
18
+
19
+ name = "model-router"
20
+
21
+ def __init__(
22
+ self,
23
+ router: ModelRouter | None = None,
24
+ state: RouterState | None = None,
25
+ ) -> None:
26
+ self._router = router or ModelRouter()
27
+ self._state = state
28
+
29
+ async def __call__(self, context: StageContext) -> StageResult:
30
+ state = self._state or _resolve_state(context)
31
+ choice = state.choice
32
+ capabilities: ModelCapabilities = context.engine.extras.get(
33
+ "model_capabilities", ModelCapabilities()
34
+ )
35
+ decision = self._router.select(choice, capabilities=capabilities)
36
+
37
+ context.engine.model_selection = ModelSelection(
38
+ provider=decision.provider_name,
39
+ model=decision.model,
40
+ mode=decision.mode.value,
41
+ cost_in=decision.cost_in,
42
+ cost_out=decision.cost_out,
43
+ )
44
+ context.engine.extras["decision"] = decision
45
+
46
+ return StageResult(
47
+ status=StageStatus.SUCCEEDED,
48
+ data={
49
+ "provider": decision.provider_name,
50
+ "model": decision.model,
51
+ "mode": decision.mode.value,
52
+ "cost_in": decision.cost_in,
53
+ "cost_out": decision.cost_out,
54
+ },
55
+ notes=(
56
+ f"route: {decision.provider_name}/{decision.model} "
57
+ f"(mode={decision.mode.value})",
58
+ ),
59
+ )
60
+
61
+
62
+ def _resolve_state(context: StageContext) -> RouterState:
63
+ paths = context.engine.extras.get("paths")
64
+ if paths is not None:
65
+ return load_state(paths.data_dir / "router.json")
66
+ return RouterState()
@@ -0,0 +1,45 @@
1
+ """Stage 4 — Planning Engine (placeholder).
2
+
3
+ Generates a software plan using :func:`forgecli.planner.software.build_software_plan`.
4
+ Stores the plan in ``context.engine.plan``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
10
+ from forgecli.planner.software import PlannerOptions, build_software_plan
11
+
12
+
13
+ class PlanningEngineStage:
14
+ """Generate a deterministic software plan from the prompt."""
15
+
16
+ name = "planning-engine"
17
+
18
+ def __init__(self, enabled: bool = True) -> None:
19
+ self._enabled = enabled
20
+
21
+ async def __call__(self, context: StageContext) -> StageResult:
22
+ if not self._enabled:
23
+ return StageResult(
24
+ status=StageStatus.SKIPPED,
25
+ notes=("planning disabled",),
26
+ data={"skipped": True},
27
+ )
28
+
29
+ prompt = context.engine.prompt
30
+ options = context.engine.extras.get("planner_options", PlannerOptions())
31
+ plan = build_software_plan(prompt, options)
32
+ context.engine.plan = plan
33
+
34
+ return StageResult(
35
+ status=StageStatus.SUCCEEDED,
36
+ data={
37
+ "summary": plan.summary,
38
+ "milestones": len(plan.milestones),
39
+ "tasks": len(plan.tasks) if hasattr(plan, "tasks") else 0,
40
+ },
41
+ notes=(
42
+ f"plan: {plan.summary}",
43
+ f"{len(plan.milestones)} milestones",
44
+ ),
45
+ )
@@ -0,0 +1,54 @@
1
+ """Stage 2 — Repository Analyzer.
2
+
3
+ Queries the configured :class:`RepositoryGraph` for nodes relevant to
4
+ the user prompt. Wraps :func:`forgecli.build.retrieval.graphify_retrieval`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from forgecli.build import BuildContext
13
+ from forgecli.build.retrieval import graphify_retrieval as _build_retrieval
14
+ from forgecli.engine.context import RetrievalResult
15
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
16
+ from forgecli.graph.repository import RepositoryGraph
17
+
18
+
19
+ class RepositoryAnalyzerStage:
20
+ """Query the repository graph for context relevant to the prompt."""
21
+
22
+ name = "repository-analyzer"
23
+
24
+ def __init__(self, graph: RepositoryGraph | None = None) -> None:
25
+ self._graph = graph
26
+
27
+ async def __call__(self, context: StageContext) -> StageResult:
28
+ graph = self._graph or context.engine.extras.get("graph")
29
+ build_ctx = BuildContext(
30
+ prompt=context.engine.prompt,
31
+ root=Path(context.engine.cwd),
32
+ )
33
+ build_ctx.extras["graph"] = graph
34
+
35
+ started = time.perf_counter()
36
+ build_ctx = await _build_retrieval(build_ctx)
37
+ elapsed = time.perf_counter() - started
38
+
39
+ context.engine.retrieval = RetrievalResult(
40
+ query=context.engine.prompt,
41
+ context_text=build_ctx.retrieval,
42
+ notes=(
43
+ f"retrieved in {elapsed:.3f}s",
44
+ f"{len(build_ctx.retrieval)} chars",
45
+ ),
46
+ )
47
+ return StageResult(
48
+ status=StageStatus.SUCCEEDED,
49
+ data={
50
+ "retrieval_length": len(build_ctx.retrieval),
51
+ "elapsed_seconds": round(elapsed, 3),
52
+ },
53
+ notes=(f"retrieved {len(build_ctx.retrieval)} chars of context",),
54
+ )