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