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
forgecli/build/llm.py ADDED
@@ -0,0 +1,167 @@
1
+ """Stage 3 — LLM call.
2
+
3
+ Graphify retrieval as context) and dispatches it through the active
4
+ provider. The selected provider is the one chosen by the router (see
5
+ :mod:`forgecli.providers.router`).
6
+
7
+ The stage supports a small retry loop: when ``context.extras`` carries
8
+ a positive ``retries`` count, transient ``ProviderError`` exceptions
9
+ are retried with a short backoff. Non-transient errors (e.g. the
10
+ provider returns a 4xx) are surfaced immediately.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ from typing import Any
18
+
19
+ from forgecli.build import BuildContext
20
+ from forgecli.core.errors import ProviderError
21
+ from forgecli.providers.base import (
22
+ ChatMessage,
23
+ ChatRequest,
24
+ Provider,
25
+ Role,
26
+ )
27
+
28
+ _SYSTEM_PROMPT = (
29
+ " 1. Speculative need = skip it ().\n"
30
+ " 2. Reuse the helper/pattern that already lives in the codebase.\n"
31
+ " 3. Use the standard library.\n"
32
+ " 4. Native platform feature > third-party library.\n"
33
+ " 5. Use an already-installed dependency before adding a new one.\n"
34
+ " 6. One line beats many.\n"
35
+ " 7. Only then: the minimum code that works.\n\n"
36
+ "Return ONLY a unified diff in the response. No prose, no explanation, "
37
+ "no code fences. The diff must apply with `git apply`.\n"
38
+ )
39
+
40
+ _TRANSIENT_HTTP_CODES = {408, 425, 429, 500, 502, 503, 504}
41
+ _BACKOFF_SECONDS = 1.5
42
+ _log = logging.getLogger("forgecli.build.llm")
43
+
44
+
45
+ async def llm_call(context: BuildContext) -> BuildContext:
46
+ """Send the assembled prompt to the active provider and store the response."""
47
+ provider: Provider | None = context.extras.get("provider")
48
+ if provider is None:
49
+ raise RuntimeError("no provider wired into the build context")
50
+
51
+ intent = context.extras.get("intent")
52
+
53
+ retries = int(context.extras.get("retries", 0) or 0)
54
+ user_content = _format_user_prompt(context)
55
+ base_request: ChatRequest = context.optimized_request or ChatRequest(
56
+ messages=[ChatMessage(role=Role.USER, content=context.prompt)]
57
+ )
58
+ request = base_request.model_copy(
59
+ update={
60
+ "model": context.decision.model if context.decision else None,
61
+ "messages": _assemble_messages(base_request.messages, user_content, intent),
62
+ }
63
+ )
64
+
65
+ last_exc: Exception | None = None
66
+ for attempt in range(retries + 1):
67
+ try:
68
+ response = await provider.chat(request)
69
+ context.response = response
70
+ return context
71
+ except ProviderError as exc:
72
+ last_exc = exc
73
+ if not _is_transient(exc) or attempt >= retries:
74
+ raise
75
+ wait = _BACKOFF_SECONDS * (attempt + 1)
76
+ _log.warning(
77
+ "transient provider error (attempt %d/%d): %s; retrying in %.1fs",
78
+ attempt + 1,
79
+ retries + 1,
80
+ exc,
81
+ wait,
82
+ )
83
+ await asyncio.sleep(wait)
84
+ # Unreachable, but keep mypy happy.
85
+ raise last_exc if last_exc else RuntimeError("llm_call: no attempts made")
86
+
87
+
88
+ def _is_transient(exc: ProviderError) -> bool:
89
+ """Return True if ``exc`` looks like a transient HTTP failure."""
90
+ message = str(exc)
91
+ for code in _TRANSIENT_HTTP_CODES:
92
+ if f"({code})" in message:
93
+ return True
94
+ return False
95
+
96
+
97
+ def _format_user_prompt(context: BuildContext) -> str:
98
+ parts: list[str] = [context.prompt]
99
+ if context.retrieval:
100
+ parts.append("\n" + context.retrieval)
101
+ intent = context.extras.get("intent")
102
+ from forgecli.plugins import Intent
103
+
104
+ if intent in (None, Intent.BUILD, Intent.UNKNOWN, "build"):
105
+ parts.append(
106
+ "\nGenerate code that exactly matches the request above: use the "
107
+ "language, filename(s), framework, approximate size, and behavior "
108
+ "the user asked for. "
109
+ "Respond with a unified diff only that applies with `git apply`. "
110
+ "Do not include prose, explanations, or markdown fences."
111
+ )
112
+ return "\n".join(parts)
113
+
114
+
115
+ def _assemble_messages(
116
+ base: list[ChatMessage], user_content: str, intent: Any
117
+ ) -> list[ChatMessage]:
118
+ """Insert the system prompt at the head, replace the user message.
119
+
120
+ instructions on top by *replacing* the first user message with our
121
+ assembled content.
122
+ """
123
+ out: list[ChatMessage] = []
124
+ replaced_user = False
125
+ has_system = any(m.role is Role.SYSTEM for m in base)
126
+
127
+ if not has_system:
128
+ if intent is None or intent == "build":
129
+ out.append(ChatMessage(role=Role.SYSTEM, content=_SYSTEM_PROMPT))
130
+ else:
131
+ out.append(ChatMessage(role=Role.SYSTEM, content=(
132
+ "You are a senior software engineer. "
133
+ "Answer the user's query or perform the request clearly and concisely in natural language / Markdown. "
134
+ "Use the codebase context provided if relevant. "
135
+ )))
136
+
137
+ for message in base:
138
+ if not replaced_user and message.role is Role.USER:
139
+ out.append(ChatMessage(role=Role.USER, content=user_content))
140
+ replaced_user = True
141
+ continue
142
+ if message.role is Role.SYSTEM:
143
+ if intent is None or intent == "build":
144
+ extra = "\n\n" + _SYSTEM_PROMPT
145
+ else:
146
+ extra = (
147
+ "\n\nAnswer the user's query or perform the request clearly and concisely in natural language / Markdown. "
148
+ "Use the codebase context provided if relevant. "
149
+ )
150
+ out.append(
151
+ ChatMessage(
152
+ role=Role.SYSTEM,
153
+ content=message.content + extra,
154
+ )
155
+ )
156
+ continue
157
+ out.append(message)
158
+ if not replaced_user:
159
+ out.append(ChatMessage(role=Role.USER, content=user_content))
160
+ return out
161
+
162
+
163
+ __all__ = ["llm_call"]
164
+
165
+
166
+ # Silence unused-import warnings for symbols only used in some branches.
167
+ _ = asyncio
@@ -0,0 +1,37 @@
1
+ """Stage 2 — Ponytail optimization.
2
+
3
+ Wraps the user prompt with the configured :class:`PromptOptimizer`
4
+ (default: :class:`PonytailRulesetOptimizer`). The optimized request is
5
+ re-used by the LLM stage; the ruleset's notes are propagated to the
6
+ final summary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from forgecli.build import BuildContext
12
+ from forgecli.optimizer.ponytail import PromptOptimizer
13
+ from forgecli.providers.base import ChatMessage, ChatRequest, Role
14
+
15
+
16
+ async def ponytail_optimization(context: BuildContext) -> BuildContext:
17
+ """Run the configured :class:`PromptOptimizer` and stash the result."""
18
+ optimizer: PromptOptimizer | None = context.extras.get("optimizer")
19
+ if optimizer is None:
20
+ # No optimizer wired: produce a minimal request so downstream stages
21
+ # always have something to consume.
22
+ context.optimized_request = ChatRequest(
23
+ messages=[ChatMessage(role=Role.USER, content=context.prompt)],
24
+ )
25
+ context.optimized_notes = ()
26
+ return context
27
+
28
+ request = ChatRequest(
29
+ messages=[ChatMessage(role=Role.USER, content=context.prompt)],
30
+ )
31
+ optimized = await optimizer.optimize_chat(request)
32
+ context.optimized_request = optimized.request
33
+ context.optimized_notes = optimized.notes
34
+ return context
35
+
36
+
37
+ __all__ = ["ponytail_optimization"]
@@ -0,0 +1,76 @@
1
+ """Default pipeline composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from forgecli.build import (
9
+ BuildContext,
10
+ BuildPipeline,
11
+ PipelineStage,
12
+ )
13
+ from forgecli.build.apply import apply_diff
14
+ from forgecli.build.diff_extract import diff_extraction
15
+ from forgecli.build.llm import llm_call
16
+ from forgecli.build.optimize import ponytail_optimization
17
+ from forgecli.build.retrieval import graphify_retrieval
18
+ from forgecli.build.summarize import summarize
19
+ from forgecli.build.test_run import run_tests
20
+ from forgecli.optimizer.ponytail import PromptOptimizer
21
+ from forgecli.providers.base import Provider
22
+ from forgecli.providers.router import ModelRouter
23
+ from forgecli.providers.router_state import RouterState, load_state
24
+
25
+
26
+ def default_pipeline(
27
+ *,
28
+ provider: Provider,
29
+ optimizer: PromptOptimizer | None,
30
+ graph: Any | None = None,
31
+ test_command: str | None = None,
32
+ test_timeout: float = 120.0,
33
+ skip_tests: bool = False,
34
+ ) -> BuildPipeline:
35
+ """Construct the canonical pipeline."""
36
+ stages: list[tuple[str, PipelineStage]] = [
37
+ ("graphify-retrieval", _stage_with(graphify_retrieval, graph=graph)),
38
+ ("ponytail-optimize", _stage_with(ponytail_optimization, optimizer=optimizer)),
39
+ ("llm", _stage_with(llm_call, provider=provider)),
40
+ ("diff-extract", diff_extraction),
41
+ ("apply-diff", apply_diff),
42
+ ]
43
+ if not skip_tests:
44
+ stages.append(("run-tests", _stage_with(run_tests, test_command=test_command, test_timeout=test_timeout)))
45
+ stages.append(("summarize", summarize))
46
+ return BuildPipeline(stages)
47
+
48
+
49
+ def _stage_with(
50
+ stage: PipelineStage, **extras: Any
51
+ ) -> PipelineStage:
52
+ """Bind extras into a stage by closing over them."""
53
+ async def _wrapped(context: BuildContext) -> BuildContext:
54
+ context.extras.update(extras)
55
+ return await stage(context)
56
+ return _wrapped
57
+
58
+
59
+ def build_context_from(
60
+ prompt: str,
61
+ *,
62
+ root: Path,
63
+ router: ModelRouter | None = None,
64
+ state: RouterState | None = None,
65
+ ) -> BuildContext:
66
+ """Build a :class:`BuildContext` with the router's decision pre-resolved."""
67
+ state = state or RouterState()
68
+ decision = (router or ModelRouter()).select(state.choice)
69
+ return BuildContext(prompt=prompt, root=root, decision=decision)
70
+
71
+
72
+ __all__ = ["build_context_from", "default_pipeline"]
73
+
74
+
75
+ # Re-export helper for callers that want to load the persisted state too.
76
+ _ = load_state
@@ -0,0 +1,157 @@
1
+ """Stage 1 — Graphify retrieval.
2
+
3
+ Asks the configured :class:`RepositoryGraph` to find nodes relevant to
4
+ the user prompt. The output is a compact text block listing the
5
+ matching node labels, files, and edges, which is fed into the LLM as
6
+ context in :mod:`forgecli.build.llm`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ from forgecli.build import BuildContext
15
+ from forgecli.graph.repository import GraphNode, GraphSnapshot, RepositoryGraph
16
+
17
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]+")
18
+ _FILE_LIKE_RE = re.compile(r"^[\w./-]+\.[A-Za-z0-9]+$")
19
+ _REPO_HINTS = (
20
+ "project",
21
+ "repository",
22
+ "repo",
23
+ "code",
24
+ "file",
25
+ "files",
26
+ "architecture",
27
+ "implementation",
28
+ "bug",
29
+ "bugs",
30
+ "docs",
31
+ "documentation",
32
+ "structure",
33
+ "folder",
34
+ "directory",
35
+ "dir",
36
+ "function",
37
+ "method",
38
+ "class",
39
+ "module",
40
+ "current",
41
+ "this",
42
+ "here",
43
+ )
44
+ _STANDALONE_HINTS = (
45
+ "10 lines",
46
+ "hello world",
47
+ "landing page",
48
+ "button",
49
+ "snippet",
50
+ )
51
+
52
+ _RETRIEVAL_CACHE: dict[tuple[int, str, int], str] = {}
53
+
54
+
55
+ def needs_repository_context(prompt: str) -> bool:
56
+ """Return True when the prompt likely needs repository context."""
57
+ text = (prompt or "").strip().lower()
58
+ if not text:
59
+ return False
60
+
61
+ words = set(_TOKEN_RE.findall(text))
62
+ if len(words) <= 4 and any(word in {"hi", "hello", "hey", "howdy", "greetings"} for word in words):
63
+ return False
64
+
65
+ if any(hint in text for hint in _STANDALONE_HINTS):
66
+ return False
67
+
68
+ if any(ext in text for ext in (".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".html", ".css", ".yml", ".yaml", ".toml", "package.json")):
69
+ return True
70
+
71
+ if any(hint in words for hint in _REPO_HINTS):
72
+ return True
73
+
74
+ return "what is this" in text or "explain this" in text or "this project" in text
75
+
76
+
77
+ async def graphify_retrieval(
78
+ context: BuildContext, *, top_k: int = 8
79
+ ) -> BuildContext:
80
+ """Return a context with ``context.retrieval`` populated."""
81
+ graph: RepositoryGraph | None = context.extras.get("graph")
82
+ if graph is None or not needs_repository_context(context.prompt):
83
+ context.retrieval = ""
84
+ return context
85
+
86
+ try:
87
+ snapshot = await graph.load()
88
+ except Exception as exc:
89
+ context.retrieval = f"[graph: failed to load ({exc!r})]"
90
+ return context
91
+
92
+ cache_key = (id(snapshot), context.prompt.strip().lower(), top_k)
93
+ cached = _RETRIEVAL_CACHE.get(cache_key)
94
+ if cached is not None:
95
+ context.retrieval = cached
96
+ return context
97
+
98
+ matches = _rank_nodes(snapshot, context.prompt, limit=top_k)
99
+ if not matches:
100
+ context.retrieval = "[graph: no matches]"
101
+ return context
102
+
103
+ lines = ["[graph retrieval]"]
104
+ for node, _score in matches:
105
+ location = (
106
+ f" ({node.source_file}:{node.source_location})"
107
+ if node.source_file
108
+ else ""
109
+ )
110
+ lines.append(f"- {node.label}{location}")
111
+ context.retrieval = "\n".join(lines)
112
+ _RETRIEVAL_CACHE[cache_key] = context.retrieval
113
+ return context
114
+
115
+
116
+ def _rank_nodes(
117
+ snapshot: GraphSnapshot, query: str, *, limit: int
118
+ ) -> list[tuple[GraphNode, int]]:
119
+ """Return the top ``limit`` nodes most relevant to ``query``.
120
+
121
+ Scoring is intentionally simple and deterministic: each token in
122
+ ``query`` contributes one point for every node whose label or
123
+ source file contains the token as a substring. Tokens that look
124
+ like filenames (``foo.py``, ``auth/bar.ts``) get a bonus when
125
+ they match a node's ``source_file`` exactly.
126
+ """
127
+ tokens = [tok.lower() for tok in _TOKEN_RE.findall(query or "")]
128
+ if not tokens:
129
+ return []
130
+ scored: list[tuple[GraphNode, int]] = []
131
+ for node in snapshot.nodes:
132
+ label = (node.label or "").lower()
133
+ source = (node.source_file or "").lower()
134
+ if not label and not source:
135
+ continue
136
+ score = 0
137
+ for token in tokens:
138
+ if token and token in label:
139
+ score += 1
140
+ if token and token in source:
141
+ score += 1
142
+ if (
143
+ _FILE_LIKE_RE.match(token)
144
+ and node.source_file
145
+ and token == node.source_file.lower()
146
+ ):
147
+ score += 3
148
+ if score > 0:
149
+ scored.append((node, score))
150
+ scored.sort(key=lambda pair: (pair[1], pair[0].label), reverse=True)
151
+ return scored[:limit]
152
+
153
+
154
+ __all__ = ["graphify_retrieval", "needs_repository_context"]
155
+
156
+
157
+ _ = Any # keep typing.Any referenced for the test-only type hints
@@ -0,0 +1,76 @@
1
+ """Stage 7 — summarize.
2
+
3
+ Composes a short human-readable summary of the run. The summary is
4
+ shown in the terminal and stored in ``context.summary``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+
11
+ from forgecli.build import BuildContext, BuildResult, StageStatus
12
+
13
+
14
+ def build_summary(context: BuildContext) -> str:
15
+ """Return a multi-line summary of the run."""
16
+ lines: list[str] = []
17
+ lines.append(f"Goal: {context.prompt}")
18
+ if context.decision is not None:
19
+ d = context.decision
20
+ lines.append(
21
+ f"Route: {d.provider_name}/{d.model} "
22
+ f"(mode={d.mode.value}, "
23
+ f"in=${d.cost_in:.5f}/1k, out=${d.cost_out:.5f}/1k)"
24
+ )
25
+ if context.applied_files:
26
+ rel = [str(p.relative_to(context.root)) for p in context.applied_files]
27
+ lines.append(f"Files touched ({len(rel)}):")
28
+ for path in rel:
29
+ lines.append(f" - {path}")
30
+ else:
31
+ lines.append("Files touched: (none)")
32
+ if context.test_returncode is None:
33
+ lines.append("Tests: skipped (no test runner available)")
34
+ elif context.test_returncode == 0:
35
+ lines.append("Tests: passed")
36
+ else:
37
+ lines.append(f"Tests: FAILED (exit {context.test_returncode})")
38
+ if context.test_stderr.strip():
39
+ lines.append("--- stderr ---")
40
+ lines.append(context.test_stderr.strip())
41
+ total = sum(s.duration_seconds or 0.0 for s in context.stages)
42
+ lines.append(f"Total time: {total:.2f}s across {len(context.stages)} stages")
43
+ return "\n".join(lines)
44
+
45
+
46
+ async def summarize(context: BuildContext) -> BuildContext:
47
+ """Populate ``context.summary`` with a human-readable overview."""
48
+ context.summary = build_summary(context)
49
+ return context
50
+
51
+
52
+ def result_to_dict(result: BuildResult) -> dict[str, object]:
53
+ """Return a JSON-serializable view of the result (for --json output)."""
54
+ return {
55
+ "success": result.success,
56
+ "failure_stage": result.failure_stage,
57
+ "summary": result.context.summary,
58
+ "applied_files": [str(p) for p in result.context.applied_files],
59
+ "test_returncode": result.context.test_returncode,
60
+ "stages": [
61
+ {
62
+ "name": s.name,
63
+ "status": s.status.value,
64
+ "duration_seconds": s.duration_seconds,
65
+ "notes": list(s.notes),
66
+ "error": s.error,
67
+ }
68
+ for s in result.context.stages
69
+ ],
70
+ }
71
+
72
+
73
+ __all__ = ["build_summary", "result_to_dict", "summarize"]
74
+
75
+
76
+ # Silence unused-import warnings.
@@ -0,0 +1,75 @@
1
+ """Stage 6 — run tests.
2
+
3
+ Runs an optional test command (default: ``pytest -q``) under the
4
+ project root and captures stdout/stderr/return code. The build does
5
+ not fail when tests fail — failures are recorded in the context and
6
+ surfaced in the summary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ from forgecli.build import BuildContext
18
+
19
+
20
+ DEFAULT_TEST_COMMAND = "pytest -q"
21
+
22
+
23
+ async def run_tests(context: BuildContext) -> BuildContext:
24
+ """Execute the test command and record the outcome."""
25
+ command = context.extras.get("test_command") or DEFAULT_TEST_COMMAND
26
+ if not shutil.which(command.split()[0]):
27
+ context.test_stdout = ""
28
+ context.test_stderr = f"`{command.split()[0]}` not found on PATH; skipping tests."
29
+ context.test_returncode = None
30
+ return context
31
+
32
+ proc = await asyncio.create_subprocess_shell(
33
+ command,
34
+ cwd=str(context.root),
35
+ stdout=asyncio.subprocess.PIPE,
36
+ stderr=asyncio.subprocess.PIPE,
37
+ env=os.environ.copy(),
38
+ )
39
+ try:
40
+ stdout, stderr = await asyncio.wait_for(
41
+ proc.communicate(), timeout=_timeout_for(context)
42
+ )
43
+ except asyncio.TimeoutError:
44
+ proc.kill()
45
+ context.test_stdout = ""
46
+ context.test_stderr = "tests timed out"
47
+ context.test_returncode = 124
48
+ return context
49
+ context.test_stdout = stdout.decode(errors="replace")
50
+ context.test_stderr = stderr.decode(errors="replace")
51
+ context.test_returncode = proc.returncode
52
+ return context
53
+
54
+
55
+ def _timeout_for(context: BuildContext) -> float:
56
+ timeout = context.extras.get("test_timeout")
57
+ if isinstance(timeout, (int, float)):
58
+ return float(timeout)
59
+ return 120.0
60
+
61
+
62
+ def run_subprocess_sync(command: str, root: Path, *, timeout: float) -> subprocess.CompletedProcess:
63
+ """Synchronous helper used by tests."""
64
+ return subprocess.run(
65
+ command,
66
+ cwd=str(root),
67
+ shell=True,
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=timeout,
71
+ check=False,
72
+ )
73
+
74
+
75
+ __all__ = ["DEFAULT_TEST_COMMAND", "run_subprocess_sync", "run_tests"]
@@ -0,0 +1,11 @@
1
+ """Code building pipeline: format, generate, apply edits."""
2
+
3
+ from forgecli.builder.builder import Builder
4
+ from forgecli.builder.editor import Editor
5
+ from forgecli.builder.formatter import Formatter
6
+
7
+ __all__ = [
8
+ "Builder",
9
+ "Editor",
10
+ "Formatter",
11
+ ]
@@ -0,0 +1,53 @@
1
+ """Top-level builder pipeline orchestrator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from forgecli.builder.editor import Editor, FileEdit
10
+ from forgecli.builder.formatter import Formatter
11
+ from forgecli.core.service import Service
12
+
13
+
14
+ @dataclass
15
+ class BuildResult:
16
+ """The outcome of a build run."""
17
+
18
+ success: bool
19
+ touched: list[Path] = field(default_factory=list)
20
+ notes: list[str] = field(default_factory=list)
21
+
22
+
23
+ class Builder(Service):
24
+ """Coordinates editing, formatting, and verification of a change set."""
25
+
26
+ name = "builder"
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ editor: Editor | None = None,
32
+ formatter: Formatter | None = None,
33
+ max_iterations: int = 3,
34
+ ) -> None:
35
+ super().__init__()
36
+ self._editor = editor or Editor()
37
+ self._formatter = formatter or Formatter()
38
+ self._max_iterations = max_iterations
39
+
40
+ @property
41
+ def editor(self) -> Editor:
42
+ return self._editor
43
+
44
+ @property
45
+ def formatter(self) -> Formatter:
46
+ return self._formatter
47
+
48
+ def build(self, edits: Iterable[FileEdit]) -> BuildResult:
49
+ """Apply ``edits`` and run post-processing; placeholder for now."""
50
+ touched = self._editor.apply(edits)
51
+ if touched:
52
+ self._formatter.format(touched)
53
+ return BuildResult(success=True, touched=touched)
@@ -0,0 +1,36 @@
1
+ """File editing primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from forgecli.core.service import Service
10
+ from forgecli.utils.fs import atomic_write, read_text
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FileEdit:
15
+ """A single replacement operation targeting ``path``."""
16
+
17
+ path: Path
18
+ new_content: str
19
+
20
+
21
+ class Editor(Service):
22
+ """Apply a batch of file edits atomically (best effort)."""
23
+
24
+ name = "builder.editor"
25
+
26
+ def apply(self, edits: Iterable[FileEdit]) -> list[Path]:
27
+ """Write each edit to disk; return the list of touched paths."""
28
+ touched: list[Path] = []
29
+ for edit in edits:
30
+ atomic_write(edit.path, edit.new_content)
31
+ touched.append(Path(edit.path))
32
+ return touched
33
+
34
+ @staticmethod
35
+ def read(path: Path) -> str:
36
+ return read_text(path)