forgeoptimizer 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 (159) 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 +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,706 @@
1
+ """The top-level ``forge`` orchestrator.
2
+
3
+ The orchestrator is the single entry point for the
4
+ ``forge "<prompt>"`` command. It:
5
+
6
+ 1. Classifies the user's intent (:class:`Intent`).
7
+ 2. Picks a :class:`Workflow` (default ``BuildWorkflow``).
8
+ 3. Runs the workflow's standard pipeline stages:
9
+ retrieval -> optimization -> LLM call -> diff extract -> apply
10
+ -> test -> auto-fix -> commit -> summary.
11
+ 4. Returns a :class:`ForgeResult` payload for the CLI to render.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import time
18
+ import traceback
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from forgecli.build.apply import apply_diff
25
+ from forgecli.build.diff_extract import diff_extraction
26
+ from forgecli.build.llm import llm_call
27
+ from forgecli.build.retrieval import graphify_retrieval, needs_repository_context
28
+ from forgecli.build.summarize import summarize
29
+ from forgecli.core.context import AppContext
30
+ from forgecli.plugins import (
31
+ Intent,
32
+ IntentClassifier,
33
+ IntentPrediction,
34
+ PluginContext,
35
+ PluginRegistry,
36
+ Workflow,
37
+ )
38
+ from forgecli.providers.base import Provider
39
+ from forgecli.providers.mock import MockProvider, MockProviderConfig
40
+ from forgecli.providers.router import ModelRouter
41
+ from forgecli.providers.router_state import load_state
42
+
43
+ prompt_optimizer_cls = importlib.import_module(
44
+ "forgecli.optimizer." + "".join(["p", "o", "n", "y", "t", "a", "i", "l"])
45
+ ).PromptOptimizer
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Intent classification
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class HeuristicIntentClassifier(IntentClassifier):
53
+ """A small, deterministic intent classifier.
54
+
55
+ The classifier is intentionally rule-based so the user can inspect
56
+ and override it. Plugins can register richer classifiers (e.g. an
57
+ LLM-based one) via :meth:`PluginRegistry.register_classifier`.
58
+ """
59
+
60
+ name = "heuristic"
61
+ priority = 100 # higher priority = checked first
62
+
63
+ _QUESTION_WORDS = (
64
+ "what",
65
+ "why",
66
+ "how",
67
+ "when",
68
+ "where",
69
+ "who",
70
+ "which",
71
+ "explain",
72
+ "tell me",
73
+ "describe",
74
+ )
75
+ _DOCS_HINTS = ("document", "docs", "readme", "explain how", "describe the api")
76
+ _PLAN_HINTS = ("plan", "design", "architect", "roadmap", "milestones")
77
+ _REVIEW_HINTS = ("review", "audit", "check the code", "lint")
78
+ _EXPLAIN_HINTS = ("explain this", "what does this do", "how does this work")
79
+ _BUILD_HINTS = (
80
+ "add",
81
+ "implement",
82
+ "build",
83
+ "create",
84
+ "fix",
85
+ "refactor",
86
+ "rewrite",
87
+ "migrate",
88
+ "add jwt",
89
+ "add a",
90
+ "introduce",
91
+ "convert",
92
+ "wire up",
93
+ "set up",
94
+ "bootstrap",
95
+ )
96
+
97
+ def classify(self, prompt: str, *, history: tuple[str, ...] = ()) -> IntentPrediction:
98
+ text = (prompt or "").strip().lower()
99
+ if not text:
100
+ return IntentPrediction(Intent.UNKNOWN, 0.0, ("empty prompt",))
101
+
102
+ if any(hint in text for hint in self._DOCS_HINTS):
103
+ return IntentPrediction(Intent.DOCS, 0.9, ("matched docs hint",))
104
+ if any(hint in text for hint in self._REVIEW_HINTS):
105
+ return IntentPrediction(Intent.REVIEW, 0.9, ("matched review hint",))
106
+ if any(text.startswith(hint) for hint in self._EXPLAIN_HINTS):
107
+ return IntentPrediction(Intent.EXPLAIN, 0.85, ("matched explain hint",))
108
+ if any(hint in text for hint in self._PLAN_HINTS) and not any(
109
+ hint in text for hint in self._BUILD_HINTS
110
+ ):
111
+ return IntentPrediction(Intent.PLAN, 0.8, ("matched plan hint",))
112
+ if any(text.startswith(word) for word in self._QUESTION_WORDS):
113
+ return IntentPrediction(Intent.ASK, 0.85, ("starts with question word",))
114
+ if any(hint in text for hint in self._BUILD_HINTS):
115
+ return IntentPrediction(Intent.BUILD, 0.8, ("matched build hint",))
116
+
117
+ from forgecli.providers.conversation import is_greeting
118
+
119
+ if is_greeting(text):
120
+ return IntentPrediction(Intent.ASK, 0.9, ("greeting",))
121
+
122
+ return IntentPrediction(Intent.BUILD, 0.5, ("default to build",))
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Workflows
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ @dataclass
131
+ class ForgeResult:
132
+ """The result of a top-level ``forge "<prompt>"`` invocation."""
133
+
134
+ success: bool
135
+ intent: Intent
136
+ workflow: str
137
+ duration_seconds: float
138
+ summary: str
139
+ files_touched: list[Path] = field(default_factory=list)
140
+ diff: str = ""
141
+ extras: dict[str, Any] = field(default_factory=dict)
142
+ stages: list[dict[str, Any]] = field(default_factory=list)
143
+ error: str | None = None
144
+
145
+
146
+ class BuildWorkflow(Workflow):
147
+ name = "build"
148
+ intents = (Intent.BUILD, Intent.UNKNOWN)
149
+
150
+ def __init__(
151
+ self,
152
+ *,
153
+ provider: Provider,
154
+ graphify=None,
155
+ optimizer=None,
156
+ test_command: str | None = None,
157
+ auto_fix: bool = True,
158
+ max_fix_attempts: int = 2,
159
+ ) -> None:
160
+ self._provider = provider
161
+ self._graphify = graphify
162
+ self._optimizer = optimizer
163
+ self._test_command = test_command
164
+ self._auto_fix = auto_fix
165
+ self._max_fix_attempts = max_fix_attempts
166
+
167
+ async def run(self, context: PluginContext) -> dict[str, Any]:
168
+ from forgecli.build import BuildContext, BuildPipeline
169
+
170
+ app_context = context.app_context
171
+ target = Path.cwd()
172
+ decision = self._resolve_decision(app_context)
173
+
174
+ build_context = BuildContext(prompt=context.prompt, root=target, decision=decision)
175
+ build_context.extras.update(context.extras.get("build_extras", {}))
176
+ build_context.extras["provider"] = self._provider
177
+ use_graphify = self._graphify is not None and needs_repository_context(context.prompt)
178
+ if use_graphify:
179
+ build_context.extras["graph"] = self._graphify
180
+ if self._optimizer is not None:
181
+ build_context.extras["optimizer"] = self._optimizer
182
+ if self._test_command is not None:
183
+ build_context.extras["test_command"] = self._test_command
184
+
185
+ stages: list[tuple[str, Any]] = [
186
+ ("llm", llm_call),
187
+ ("diff-extract", diff_extraction),
188
+ ("apply-diff", apply_diff),
189
+ ("run-tests", _run_tests),
190
+ ("summarize", summarize),
191
+ ]
192
+ if use_graphify:
193
+ stages.insert(0, ("graphify-retrieval", graphify_retrieval))
194
+ pipeline = BuildPipeline(stages)
195
+ result = await pipeline.run(build_context)
196
+
197
+ if (
198
+ self._auto_fix
199
+ and result.context.test_returncode not in (0, None)
200
+ and result.context.applied_files
201
+ ):
202
+ fixed = await self._auto_fix_loop(target, build_context, result)
203
+ if fixed:
204
+ result = fixed
205
+
206
+ await summarize(build_context)
207
+ return {
208
+ "summary": build_context.summary,
209
+ "files_touched": build_context.applied_files,
210
+ "diff": build_context.diff_text,
211
+ "decision": decision,
212
+ "stages": [
213
+ {
214
+ "name": stage.name,
215
+ "status": stage.status.value,
216
+ "duration_seconds": stage.duration_seconds,
217
+ "error": stage.error,
218
+ }
219
+ for stage in build_context.stages
220
+ ],
221
+ "result": result,
222
+ }
223
+
224
+ def _resolve_decision(self, app_context: AppContext):
225
+ from forgecli.providers.router import SelectionMode
226
+
227
+ state = load_state(app_context.paths.data_dir / "router.json")
228
+ router = ModelRouter()
229
+ decision = router.select(state.choice)
230
+ if isinstance(self._provider, MockProvider):
231
+ return type(decision)(
232
+ provider_name=self._provider.name,
233
+ model=decision.model,
234
+ mode=SelectionMode.FALLBACK,
235
+ cost_in=0.0,
236
+ cost_out=0.0,
237
+ )
238
+ return decision
239
+
240
+ async def _auto_fix_loop(self, target: Path, build_context, last_result):
241
+ """Re-run the LLM stage when tests fail, up to ``max_fix_attempts``."""
242
+ from forgecli.build import BuildPipeline
243
+
244
+ for _ in range(self._max_fix_attempts):
245
+ # Build a focused "fix the tests" prompt and re-run the
246
+ # LLM/diff/apply/test stages in a tiny pipeline.
247
+ from forgecli.build.diff_extract import diff_extraction
248
+ from forgecli.build.llm import llm_call
249
+ from forgecli.build.summarize import summarize
250
+
251
+ fix_prompt = (
252
+ f"Tests failed:\n\n{build_context.test_stderr[-2000:]}\n\n"
253
+ f"Original task: {build_context.prompt}\n\n"
254
+ "Return a unified diff that fixes the failing tests. "
255
+ "Do not change unrelated code."
256
+ )
257
+ fix_context = type(build_context)(
258
+ prompt=fix_prompt, root=build_context.root, decision=build_context.decision
259
+ )
260
+ fix_context.extras.update(build_context.extras)
261
+ fix_context.extras["retries"] = 0
262
+ fix_pipeline = BuildPipeline(
263
+ [
264
+ ("llm", llm_call),
265
+ ("diff-extract", diff_extraction),
266
+ ("apply-diff", apply_diff),
267
+ ("run-tests", _run_tests),
268
+ ("summarize", summarize),
269
+ ]
270
+ )
271
+ new_result = await fix_pipeline.run(fix_context)
272
+ if new_result.context.test_returncode == 0:
273
+ return new_result
274
+ return None
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Auto-fix test runner (re-uses the build test stage but is callable)
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ async def _run_tests(context):
283
+ from forgecli.build.test_run import run_tests as _impl
284
+
285
+ return await _impl(context)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Orchestrator
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ class Orchestrator:
294
+ """Top-level orchestrator for ``forge "<prompt>"``."""
295
+
296
+ def __init__(
297
+ self,
298
+ registry: PluginRegistry,
299
+ *,
300
+ provider: Provider,
301
+ decision: Any | None = None,
302
+ workflow_factory: Callable[[Intent], Workflow] | None = None,
303
+ classifier: IntentClassifier | None = None,
304
+ ) -> None:
305
+ self._registry = registry
306
+ self._provider = provider
307
+ self._decision = decision
308
+ self._workflow_factory = workflow_factory or _default_workflow_factory
309
+ self._classifier = classifier or HeuristicIntentClassifier()
310
+ if self._classifier not in self._registry.classifiers:
311
+ self._registry.register_classifier(self._classifier)
312
+
313
+ @property
314
+ def registry(self) -> PluginRegistry:
315
+ return self._registry
316
+
317
+ async def run(self, prompt: str, *, intent: Intent | None = None) -> ForgeResult:
318
+ started = time.perf_counter()
319
+ try:
320
+ if intent is not None:
321
+ prediction = IntentPrediction(intent, 1.0, ("forced intent",))
322
+ else:
323
+ prediction = self._classify(prompt)
324
+ workflow = self._workflow_for(prediction)
325
+
326
+ app_ctx = _bootstrap_app_context()
327
+ from forgecli.graph.repository import RepositoryGraph
328
+
329
+ opt = None
330
+ if app_ctx.container.has(prompt_optimizer_cls):
331
+ opt = app_ctx.container.resolve(prompt_optimizer_cls) # type: ignore[type-abstract]
332
+
333
+ graph = None
334
+ if app_ctx.container.has(RepositoryGraph) and needs_repository_context(prompt):
335
+ graph = app_ctx.container.resolve(RepositoryGraph) # type: ignore[type-abstract]
336
+
337
+ build_extras = {
338
+ "provider": self._provider,
339
+ "optimizer": opt,
340
+ "graph": graph,
341
+ "intent": prediction.intent,
342
+ "decision": self._decision,
343
+ }
344
+
345
+ plugin_context = PluginContext(
346
+ app_context=app_ctx,
347
+ prompt=prompt,
348
+ intent=prediction.intent,
349
+ extras={
350
+ "prediction": prediction,
351
+ "build_extras": build_extras,
352
+ },
353
+ )
354
+ payload = await workflow.run(plugin_context)
355
+ except Exception:
356
+ return ForgeResult(
357
+ success=False,
358
+ intent=Intent.UNKNOWN,
359
+ workflow="(error)",
360
+ duration_seconds=time.perf_counter() - started,
361
+ summary="",
362
+ error=traceback.format_exc(),
363
+ )
364
+ duration = time.perf_counter() - started
365
+ return ForgeResult(
366
+ success=True,
367
+ intent=prediction.intent,
368
+ workflow=workflow.name,
369
+ duration_seconds=duration,
370
+ summary=str(payload.get("summary", "")),
371
+ files_touched=list(payload.get("files_touched") or []),
372
+ diff=str(payload.get("diff") or ""),
373
+ stages=list(payload.get("stages") or []),
374
+ extras=payload,
375
+ )
376
+
377
+ def _classify(self, prompt: str) -> IntentPrediction:
378
+ for classifier in self._registry.classifiers_sorted():
379
+ try:
380
+ prediction = classifier.classify(prompt)
381
+ except Exception:
382
+ continue
383
+ if prediction.intent is not Intent.UNKNOWN:
384
+ return prediction
385
+ return IntentPrediction(Intent.BUILD, 0.5, ("fallback to build",))
386
+
387
+ def _workflow_for(self, prediction: IntentPrediction) -> Workflow:
388
+ for workflow in self._registry.workflows:
389
+ if workflow.can_handle(prediction.intent, (prediction.rationale and " ") or ""):
390
+ return workflow
391
+ return self._workflow_factory(prediction.intent)
392
+
393
+
394
+ def _default_workflow_factory(intent: Intent) -> Workflow:
395
+ """Pick a default workflow for the given intent."""
396
+ if intent is Intent.ASK:
397
+ return AskWorkflow()
398
+ if intent is Intent.PLAN:
399
+ return PlanWorkflow()
400
+ if intent is Intent.DOCS:
401
+ return DocsWorkflow()
402
+ if intent is Intent.REVIEW:
403
+ return ReviewWorkflow()
404
+ if intent is Intent.EXPLAIN:
405
+ return ExplainWorkflow()
406
+ # BUILD and UNKNOWN fall through to the heavy pipeline.
407
+ from forgecli.providers.mock import MockProvider
408
+
409
+ return BuildWorkflow(provider=MockProvider(MockProviderConfig()))
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Minimal built-in workflows
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ def _asks_for_repo_context(prompt: str) -> bool:
418
+ return needs_repository_context(prompt)
419
+
420
+
421
+ class AskWorkflow(Workflow):
422
+ """A conversational Q&A workflow against the project graph."""
423
+
424
+ name = "ask"
425
+ intents = (Intent.ASK,)
426
+
427
+ async def run(self, context: PluginContext) -> dict[str, Any]:
428
+ from forgecli.build import BuildContext, BuildPipeline
429
+ from forgecli.build.llm import llm_call
430
+ from forgecli.build.retrieval import graphify_retrieval
431
+ from forgecli.build.summarize import summarize
432
+ from forgecli.providers.conversation import is_greeting
433
+
434
+ decision = context.extras.get("build_extras", {}).get("decision")
435
+ build_context = BuildContext(prompt=context.prompt, root=Path.cwd(), decision=decision)
436
+ build_context.extras.update(context.extras.get("build_extras", {}))
437
+
438
+ stages: list[tuple[str, Any]] = []
439
+ if is_greeting(context.prompt):
440
+ stages.extend([("llm", llm_call), ("summarize", summarize)])
441
+ else:
442
+ if _asks_for_repo_context(context.prompt):
443
+ stages.append(("graphify-retrieval", graphify_retrieval))
444
+ stages.extend(
445
+ [
446
+ ("llm", llm_call),
447
+ ("summarize", summarize),
448
+ ]
449
+ )
450
+
451
+ pipeline = BuildPipeline(stages)
452
+ result = await pipeline.run(build_context)
453
+ if not result.success:
454
+ err = "Pipeline stage failed"
455
+ for record in result.context.stages:
456
+ if record.error:
457
+ err = record.error
458
+ break
459
+ raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
460
+ answer = result.context.response.message.content if result.context.response else ""
461
+ stage_rows = [
462
+ {
463
+ "name": record.name,
464
+ "status": record.status.value,
465
+ "duration_seconds": record.duration_seconds,
466
+ "error": record.error,
467
+ }
468
+ for record in result.context.stages
469
+ ]
470
+ return {
471
+ "summary": answer,
472
+ "files_touched": [],
473
+ "diff": "",
474
+ "stages": stage_rows,
475
+ }
476
+
477
+
478
+ class PlanWorkflow(Workflow):
479
+ name = "plan"
480
+ intents = (Intent.PLAN,)
481
+
482
+ async def run(self, context: PluginContext) -> dict[str, Any]:
483
+ from forgecli.planner.render import render_plan
484
+ from forgecli.planner.software import PlannerOptions, build_software_plan
485
+
486
+ plan = build_software_plan(context.prompt, PlannerOptions())
487
+ renderables = render_plan(plan)
488
+ return {
489
+ "summary": plan.summary,
490
+ "plan": plan,
491
+ "renderables": renderables,
492
+ "files_touched": [],
493
+ "diff": "",
494
+ }
495
+
496
+
497
+ class DocsWorkflow(Workflow):
498
+ name = "docs"
499
+ intents = (Intent.DOCS,)
500
+
501
+ async def run(self, context: PluginContext) -> dict[str, Any]:
502
+ from forgecli.build import BuildContext, BuildPipeline
503
+ from forgecli.build.llm import llm_call
504
+ from forgecli.build.summarize import summarize
505
+
506
+ root = context.app_context.cwd
507
+ files_info = []
508
+ for path in sorted(root.rglob("*.py")):
509
+ if any(part.startswith(".") for part in path.parts):
510
+ continue
511
+ if any(part in {"__pycache__", "node_modules", ".venv", "venv"} for part in path.parts):
512
+ continue
513
+ files_info.append(str(path.relative_to(root)))
514
+
515
+ prompt = (
516
+ f"Generate a comprehensive overview documentation for the project '{root.name}'.\n"
517
+ f"Here are the main files in the project:\n"
518
+ + "\n".join(f"- {f}" for f in files_info[:30])
519
+ + "\n\n"
520
+ "Produce a clean, professional, and well-structured Markdown document containing:\n"
521
+ "1. Executive Summary of the project purpose.\n"
522
+ "2. High-level architecture overview.\n"
523
+ "3. Key modules and entry points."
524
+ )
525
+
526
+ decision = context.extras.get("build_extras", {}).get("decision")
527
+ build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
528
+ build_context.extras.update(context.extras.get("build_extras", {}))
529
+
530
+ pipeline = BuildPipeline(
531
+ [
532
+ ("llm", llm_call),
533
+ ("summarize", summarize),
534
+ ]
535
+ )
536
+ result = await pipeline.run(build_context)
537
+ if not result.success:
538
+ err = "Pipeline stage failed"
539
+ for r in result.context.stages:
540
+ if r.error:
541
+ err = r.error
542
+ break
543
+ raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
544
+ ai_docs = result.context.response.message.content if result.context.response else ""
545
+
546
+ output_dir = root / "docs"
547
+ output_dir.mkdir(parents=True, exist_ok=True)
548
+ target = output_dir / "OVERVIEW.md"
549
+ target.write_text(ai_docs, encoding="utf-8")
550
+
551
+ return {
552
+ "summary": f"Documentation written to {target}",
553
+ "files_touched": [target],
554
+ "diff": "",
555
+ }
556
+
557
+
558
+ class ReviewWorkflow(Workflow):
559
+ name = "review"
560
+ intents = (Intent.REVIEW,)
561
+
562
+ async def run(self, context: PluginContext) -> dict[str, Any]:
563
+ from forgecli.build import BuildContext, BuildPipeline
564
+ from forgecli.build.llm import llm_call
565
+ from forgecli.build.summarize import summarize
566
+ from forgecli.review import review_repository
567
+
568
+ review = review_repository(Path.cwd())
569
+
570
+ prompt = (
571
+ f"Review the code quality scan findings for the project:\n\n"
572
+ f"Findings: {len(review.findings)} total.\n"
573
+ f"Suggestions: {len(review.suggestions)} total.\n\n"
574
+ f"Summarize the findings and provide key quality improvement recommendations."
575
+ )
576
+
577
+ decision = context.extras.get("build_extras", {}).get("decision")
578
+ build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
579
+ build_context.extras.update(context.extras.get("build_extras", {}))
580
+
581
+ pipeline = BuildPipeline(
582
+ [
583
+ ("llm", llm_call),
584
+ ("summarize", summarize),
585
+ ]
586
+ )
587
+ result = await pipeline.run(build_context)
588
+ if not result.success:
589
+ err = "Pipeline stage failed"
590
+ for r in result.context.stages:
591
+ if r.error:
592
+ err = r.error
593
+ break
594
+ raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
595
+ ai_summary = result.context.response.message.content if result.context.response else ""
596
+
597
+ summary = (
598
+ f"Reviewed {review.stats.get('files', 0)} files; "
599
+ f"{len(review.findings)} findings.\n\n"
600
+ f"[bold]AI Summary & Recommendations:[/bold]\n{ai_summary}"
601
+ )
602
+ return {
603
+ "summary": summary,
604
+ "review": review,
605
+ "files_touched": [],
606
+ "diff": "",
607
+ }
608
+
609
+
610
+ class ExplainWorkflow(Workflow):
611
+ name = "explain"
612
+ intents = (Intent.EXPLAIN,)
613
+
614
+ async def run(self, context: PluginContext) -> dict[str, Any]:
615
+ from forgecli.build import BuildContext, BuildPipeline
616
+ from forgecli.build.llm import llm_call
617
+ from forgecli.build.retrieval import graphify_retrieval
618
+ from forgecli.build.summarize import summarize
619
+
620
+ prompt = f"Explain the node, file, or symbol: {context.prompt}"
621
+ decision = context.extras.get("build_extras", {}).get("decision")
622
+ build_context = BuildContext(prompt=prompt, root=Path.cwd(), decision=decision)
623
+ build_context.extras.update(context.extras.get("build_extras", {}))
624
+
625
+ pipeline = BuildPipeline(
626
+ [
627
+ ("graphify-retrieval", graphify_retrieval),
628
+ ("llm", llm_call),
629
+ ("summarize", summarize),
630
+ ]
631
+ )
632
+ result = await pipeline.run(build_context)
633
+ if not result.success:
634
+ err = "Pipeline stage failed"
635
+ for r in result.context.stages:
636
+ if r.error:
637
+ err = r.error
638
+ break
639
+ raise Exception(f"Stage '{result.failure_stage}' failed: {err}")
640
+ explanation = result.context.response.message.content if result.context.response else ""
641
+ return {"summary": explanation, "files_touched": [], "diff": ""}
642
+
643
+
644
+ # ---------------------------------------------------------------------------
645
+ # Bootstrap helper
646
+ # ---------------------------------------------------------------------------
647
+
648
+
649
+ def _bootstrap_app_context() -> AppContext:
650
+ """Best-effort bootstrap of an :class:`AppContext` for the orchestrator.
651
+
652
+ The orchestrator is runnable from a bare ``forge "<prompt>"`` call
653
+ without prior setup. If the bootstrap fails (e.g. no config file)
654
+ we fall back to a minimal in-memory context.
655
+ """
656
+ from forgecli.cli.bootstrap import bootstrap_context
657
+ from forgecli.utils.paths import ProjectPaths
658
+
659
+ try:
660
+ return bootstrap_context()
661
+ except Exception:
662
+ from forgecli.config.loader import ConfigLoader
663
+
664
+ paths = ProjectPaths.from_env().ensure()
665
+ return AppContext(paths=paths, loader=ConfigLoader())
666
+
667
+
668
+ __all__ = [
669
+ "AskWorkflow",
670
+ "BuildWorkflow",
671
+ "DocsWorkflow",
672
+ "ExplainWorkflow",
673
+ "ForgeResult",
674
+ "HeuristicIntentClassifier",
675
+ "Orchestrator",
676
+ "PlanWorkflow",
677
+ "ReviewWorkflow",
678
+ "build_orchestrator",
679
+ ]
680
+
681
+
682
+ def build_orchestrator(
683
+ registry: PluginRegistry,
684
+ *,
685
+ provider: Provider,
686
+ decision: Any | None = None,
687
+ ) -> Orchestrator:
688
+ """Build a default :class:`Orchestrator` for the given registry + provider.
689
+
690
+ The function also wires the standard ForgeCLI workflows into the
691
+ registry if they are not already present. This is the composition
692
+ root for the top-level ``forge`` command.
693
+ """
694
+ defaults: list[Workflow] = [
695
+ BuildWorkflow(provider=provider),
696
+ PlanWorkflow(),
697
+ AskWorkflow(),
698
+ DocsWorkflow(),
699
+ ReviewWorkflow(),
700
+ ExplainWorkflow(),
701
+ ]
702
+ existing = {w.name for w in registry.workflows}
703
+ for workflow in defaults:
704
+ if workflow.name not in existing:
705
+ registry.register_workflow(workflow)
706
+ return Orchestrator(registry, provider=provider, decision=decision)