atomadic-forge 0.3.2__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.
- atomadic_forge/__init__.py +12 -0
- atomadic_forge/__main__.py +5 -0
- atomadic_forge/a0_qk_constants/__init__.py +1 -0
- atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
- atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
- atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
- atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
- atomadic_forge/a0_qk_constants/error_codes.py +296 -0
- atomadic_forge/a0_qk_constants/forge_types.py +89 -0
- atomadic_forge/a0_qk_constants/gen_language.py +116 -0
- atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
- atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
- atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
- atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
- atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
- atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
- atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
- atomadic_forge/a0_qk_constants/tier_names.py +47 -0
- atomadic_forge/a1_at_functions/__init__.py +1 -0
- atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
- atomadic_forge/a1_at_functions/agent_memory.py +139 -0
- atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
- atomadic_forge/a1_at_functions/agent_summary.py +277 -0
- atomadic_forge/a1_at_functions/body_extractor.py +306 -0
- atomadic_forge/a1_at_functions/card_renderer.py +210 -0
- atomadic_forge/a1_at_functions/certify_checks.py +445 -0
- atomadic_forge/a1_at_functions/chat_context.py +170 -0
- atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
- atomadic_forge/a1_at_functions/classify_tier.py +115 -0
- atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
- atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
- atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
- atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
- atomadic_forge/a1_at_functions/config_io.py +68 -0
- atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
- atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
- atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
- atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
- atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
- atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
- atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
- atomadic_forge/a1_at_functions/error_hints.py +105 -0
- atomadic_forge/a1_at_functions/evolution_log.py +94 -0
- atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
- atomadic_forge/a1_at_functions/generation_quality.py +322 -0
- atomadic_forge/a1_at_functions/import_repair.py +211 -0
- atomadic_forge/a1_at_functions/import_smoke.py +102 -0
- atomadic_forge/a1_at_functions/js_parser.py +539 -0
- atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
- atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
- atomadic_forge/a1_at_functions/llm_client.py +554 -0
- atomadic_forge/a1_at_functions/local_signer.py +134 -0
- atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
- atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
- atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
- atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
- atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
- atomadic_forge/a1_at_functions/policy_loader.py +107 -0
- atomadic_forge/a1_at_functions/preflight_change.py +227 -0
- atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
- atomadic_forge/a1_at_functions/provider_detect.py +157 -0
- atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
- atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
- atomadic_forge/a1_at_functions/recipes.py +186 -0
- atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
- atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
- atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
- atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
- atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
- atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
- atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
- atomadic_forge/a1_at_functions/scout_walk.py +309 -0
- atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
- atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
- atomadic_forge/a1_at_functions/stub_detector.py +158 -0
- atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
- atomadic_forge/a1_at_functions/synergy_render.py +252 -0
- atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
- atomadic_forge/a1_at_functions/test_runner.py +196 -0
- atomadic_forge/a1_at_functions/test_selector.py +122 -0
- atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
- atomadic_forge/a1_at_functions/tool_composer.py +130 -0
- atomadic_forge/a1_at_functions/transcript_log.py +70 -0
- atomadic_forge/a1_at_functions/wire_check.py +260 -0
- atomadic_forge/a2_mo_composites/__init__.py +1 -0
- atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
- atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
- atomadic_forge/a2_mo_composites/plan_store.py +164 -0
- atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
- atomadic_forge/a3_og_features/__init__.py +1 -0
- atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
- atomadic_forge/a3_og_features/demo_runner.py +502 -0
- atomadic_forge/a3_og_features/emergent_feature.py +95 -0
- atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
- atomadic_forge/a3_og_features/forge_enforce.py +107 -0
- atomadic_forge/a3_og_features/forge_evolve.py +176 -0
- atomadic_forge/a3_og_features/forge_loop.py +528 -0
- atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
- atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
- atomadic_forge/a3_og_features/lsp_server.py +98 -0
- atomadic_forge/a3_og_features/mcp_server.py +160 -0
- atomadic_forge/a3_og_features/setup_wizard.py +337 -0
- atomadic_forge/a3_og_features/synergy_feature.py +65 -0
- atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
- atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
- atomadic_forge/commands/__init__.py +1 -0
- atomadic_forge/commands/_registry.py +36 -0
- atomadic_forge/commands/audit.py +142 -0
- atomadic_forge/commands/chat.py +133 -0
- atomadic_forge/commands/commandsmith.py +178 -0
- atomadic_forge/commands/config_cmd.py +145 -0
- atomadic_forge/commands/demo.py +142 -0
- atomadic_forge/commands/emergent.py +124 -0
- atomadic_forge/commands/emergent_then_synergy.py +70 -0
- atomadic_forge/commands/evolve.py +122 -0
- atomadic_forge/commands/evolve_then_iterate.py +70 -0
- atomadic_forge/commands/feature_then_emergent.py +111 -0
- atomadic_forge/commands/iterate.py +140 -0
- atomadic_forge/commands/synergy.py +96 -0
- atomadic_forge/commands/synergy_then_emergent.py +70 -0
- atomadic_forge-0.3.2.dist-info/METADATA +471 -0
- atomadic_forge-0.3.2.dist-info/RECORD +131 -0
- atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
- atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
- atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
- atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tier a3 — stdio JSON-RPC loop wrapping the pure MCP dispatcher.
|
|
2
|
+
|
|
3
|
+
Golden Path Lane C W4 deliverable. The pure dispatcher lives in
|
|
4
|
+
``a1_at_functions.mcp_protocol``; this module owns the I/O concerns:
|
|
5
|
+
read a line from stdin, parse it as JSON, hand to dispatch, write the
|
|
6
|
+
response to stdout. No SSE / HTTP transport today — that's a future
|
|
7
|
+
iteration; stdio is what every coding agent (Cursor, Claude Code,
|
|
8
|
+
Aider, Devin) uses on initial connect, so it's the right v0.
|
|
9
|
+
|
|
10
|
+
The server runs forever until stdin closes (the client disconnects)
|
|
11
|
+
or it receives a ``shutdown`` JSON-RPC method (per MCP spec). Errors
|
|
12
|
+
in JSON parsing or method dispatch never kill the loop — they're
|
|
13
|
+
surfaced as JSON-RPC error responses and the next request is
|
|
14
|
+
processed normally.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from pathlib import Path as _Path
|
|
22
|
+
from typing import IO
|
|
23
|
+
|
|
24
|
+
from ..a1_at_functions.mcp_protocol import (
|
|
25
|
+
dispatch_request,
|
|
26
|
+
register_auto_apply_handler,
|
|
27
|
+
register_auto_plan_handler,
|
|
28
|
+
register_auto_step_handler,
|
|
29
|
+
register_enforce_handler,
|
|
30
|
+
)
|
|
31
|
+
from ..a2_mo_composites.plan_store import PlanStore as _PlanStore
|
|
32
|
+
from .forge_enforce import run_enforce as _run_enforce
|
|
33
|
+
from .forge_pipeline import run_auto_plan as _run_auto_plan
|
|
34
|
+
from .forge_plan_apply import (
|
|
35
|
+
apply_all_applyable as _apply_all_applyable,
|
|
36
|
+
)
|
|
37
|
+
from .forge_plan_apply import (
|
|
38
|
+
apply_card as _apply_card,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _bound_enforce(project_root, args):
|
|
43
|
+
"""a3-side enforce handler — bound into the a1 dispatcher at
|
|
44
|
+
module import time so the upward-import boundary stays clean."""
|
|
45
|
+
src = _Path(args.get("source", project_root)).resolve()
|
|
46
|
+
apply = bool(args.get("apply", False))
|
|
47
|
+
return _run_enforce(src, apply=apply)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _bound_auto_plan(project_root, args):
|
|
51
|
+
"""a3-side auto_plan handler — exposes the agent_plan/v1 emitter
|
|
52
|
+
through the MCP dispatcher with the same a1↔a3 injection pattern
|
|
53
|
+
used for enforce."""
|
|
54
|
+
target = _Path(args.get("target", project_root)).resolve()
|
|
55
|
+
plan = _run_auto_plan(
|
|
56
|
+
target=target,
|
|
57
|
+
goal=str(args.get("goal", "improve repo conformance")),
|
|
58
|
+
mode=str(args.get("mode", "improve")),
|
|
59
|
+
package=args.get("package"),
|
|
60
|
+
top_n=int(args.get("top_n", 7)),
|
|
61
|
+
)
|
|
62
|
+
if bool(args.get("save", False)):
|
|
63
|
+
plan_id = _PlanStore(target).save_plan(plan)
|
|
64
|
+
plan["id"] = plan_id
|
|
65
|
+
return plan
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _bound_auto_step(project_root, args):
|
|
69
|
+
project = _Path(args.get("project", project_root)).resolve()
|
|
70
|
+
plan_id = str(args["plan_id"])
|
|
71
|
+
card_id = str(args["card_id"])
|
|
72
|
+
apply = bool(args.get("apply", False))
|
|
73
|
+
plan = _PlanStore(project).load_plan(plan_id)
|
|
74
|
+
if plan is None:
|
|
75
|
+
return {
|
|
76
|
+
"schema_version": "atomadic-forge.plan_apply/v1",
|
|
77
|
+
"plan_id": plan_id, "card_id": card_id, "apply": apply,
|
|
78
|
+
"status": "failed",
|
|
79
|
+
"detail": {"reason": f"plan id {plan_id!r} not found"},
|
|
80
|
+
}
|
|
81
|
+
return _apply_card(project, plan, card_id, apply=apply)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _bound_auto_apply(project_root, args):
|
|
85
|
+
project = _Path(args.get("project", project_root)).resolve()
|
|
86
|
+
plan_id = str(args["plan_id"])
|
|
87
|
+
apply = bool(args.get("apply", False))
|
|
88
|
+
plan = _PlanStore(project).load_plan(plan_id)
|
|
89
|
+
if plan is None:
|
|
90
|
+
return {
|
|
91
|
+
"schema_version": "atomadic-forge.plan_apply_all/v1",
|
|
92
|
+
"plan_id": plan_id, "apply": apply,
|
|
93
|
+
"results": [], "halted_on": "failed",
|
|
94
|
+
"applied_count": 0, "skipped_count": 0,
|
|
95
|
+
"detail": {"reason": f"plan id {plan_id!r} not found"},
|
|
96
|
+
}
|
|
97
|
+
return _apply_all_applyable(project, plan, apply=apply)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
register_enforce_handler(_bound_enforce)
|
|
101
|
+
register_auto_plan_handler(_bound_auto_plan)
|
|
102
|
+
register_auto_step_handler(_bound_auto_step)
|
|
103
|
+
register_auto_apply_handler(_bound_auto_apply)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def serve_stdio(
|
|
107
|
+
*,
|
|
108
|
+
project_root: Path,
|
|
109
|
+
stdin: IO[str] | None = None,
|
|
110
|
+
stdout: IO[str] | None = None,
|
|
111
|
+
stderr: IO[str] | None = None,
|
|
112
|
+
) -> int:
|
|
113
|
+
"""Run the MCP stdio loop until stdin closes.
|
|
114
|
+
|
|
115
|
+
Returns the exit code: 0 for clean shutdown, 1 for unrecoverable
|
|
116
|
+
setup error. Per-request errors NEVER raise — they're returned to
|
|
117
|
+
the client as JSON-RPC error responses.
|
|
118
|
+
"""
|
|
119
|
+
src_in = stdin or sys.stdin
|
|
120
|
+
src_out = stdout or sys.stdout
|
|
121
|
+
src_err = stderr or sys.stderr
|
|
122
|
+
|
|
123
|
+
project_root = Path(project_root).resolve()
|
|
124
|
+
if not project_root.exists():
|
|
125
|
+
src_err.write(f"forge mcp serve: project_root not found: "
|
|
126
|
+
f"{project_root}\n")
|
|
127
|
+
return 1
|
|
128
|
+
src_err.write(f"forge mcp serve: ready (project_root={project_root})\n")
|
|
129
|
+
src_err.flush()
|
|
130
|
+
|
|
131
|
+
for raw in src_in:
|
|
132
|
+
line = raw.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
request = json.loads(line)
|
|
137
|
+
except json.JSONDecodeError as exc:
|
|
138
|
+
response = {
|
|
139
|
+
"jsonrpc": "2.0",
|
|
140
|
+
"id": None,
|
|
141
|
+
"error": {
|
|
142
|
+
"code": -32700,
|
|
143
|
+
"message": f"Parse error: {exc}",
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
_write(src_out, response)
|
|
147
|
+
continue
|
|
148
|
+
if isinstance(request, dict) and request.get("method") == "shutdown":
|
|
149
|
+
_write(src_out, {"jsonrpc": "2.0",
|
|
150
|
+
"id": request.get("id"), "result": {}})
|
|
151
|
+
break
|
|
152
|
+
response = dispatch_request(request, project_root=project_root)
|
|
153
|
+
if response is not None:
|
|
154
|
+
_write(src_out, response)
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write(stream: IO[str], payload: dict) -> None:
|
|
159
|
+
stream.write(json.dumps(payload, default=str) + "\n")
|
|
160
|
+
stream.flush()
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Tier a3 — interactive 5-step setup wizard for Atomadic Forge configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from atomadic_forge.a0_qk_constants.config_defaults import (
|
|
11
|
+
CONFIG_FILE_NAME,
|
|
12
|
+
DEFAULT_CONFIG,
|
|
13
|
+
LOCAL_CONFIG_DIR,
|
|
14
|
+
)
|
|
15
|
+
from atomadic_forge.a1_at_functions.config_io import (
|
|
16
|
+
load_config,
|
|
17
|
+
save_config,
|
|
18
|
+
)
|
|
19
|
+
from atomadic_forge.a1_at_functions.provider_detect import (
|
|
20
|
+
detect_ollama,
|
|
21
|
+
test_provider,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
28
|
+
from rich.table import Table
|
|
29
|
+
_RICH = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_RICH = False # type: ignore[assignment]
|
|
32
|
+
|
|
33
|
+
_con: Console | None = Console() if _RICH else None # type: ignore[type-arg]
|
|
34
|
+
|
|
35
|
+
_PROVIDER_MAP = {1: "ollama", 2: "gemini", 3: "anthropic", 4: "openai", 5: "auto"}
|
|
36
|
+
_PROVIDER_LABEL = {
|
|
37
|
+
"ollama": "Ollama", "gemini": "Gemini", "anthropic": "Claude (Anthropic)",
|
|
38
|
+
"openai": "OpenAI (GPT)", "auto": "Auto",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── display helpers ──────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def _panel(title: str, lines: list[str]) -> None:
|
|
45
|
+
if _RICH and _con:
|
|
46
|
+
_con.print(Panel("\n".join(lines),
|
|
47
|
+
title=f"[bold cyan]{title}[/bold cyan]", expand=False))
|
|
48
|
+
else:
|
|
49
|
+
print(f"\n--- {title} ---")
|
|
50
|
+
for line in lines:
|
|
51
|
+
print(line)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prompt_str(prompt: str, default: str = "") -> str:
|
|
55
|
+
if _RICH:
|
|
56
|
+
return Prompt.ask(prompt, default=default)
|
|
57
|
+
val = input(f"{prompt} [{default}]: ").strip()
|
|
58
|
+
return val or default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _prompt_int(prompt: str, default: int = 1) -> int:
|
|
62
|
+
if _RICH:
|
|
63
|
+
return IntPrompt.ask(prompt, default=default)
|
|
64
|
+
raw = input(f"{prompt} [{default}]: ").strip()
|
|
65
|
+
try:
|
|
66
|
+
return int(raw)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prompt_confirm(prompt: str, default: bool = True) -> bool:
|
|
72
|
+
if _RICH:
|
|
73
|
+
return Confirm.ask(prompt, default=default)
|
|
74
|
+
raw = input(f"{prompt} [{'Y/n' if default else 'y/N'}]: ").strip().lower()
|
|
75
|
+
if not raw:
|
|
76
|
+
return default
|
|
77
|
+
return raw in ("y", "yes")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _info(msg: str) -> None:
|
|
81
|
+
if _RICH and _con:
|
|
82
|
+
_con.print(f" {msg}")
|
|
83
|
+
else:
|
|
84
|
+
print(f" {msg}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ok(msg: str) -> None:
|
|
88
|
+
if _RICH and _con:
|
|
89
|
+
_con.print(f" [green]{msg}[/green]")
|
|
90
|
+
else:
|
|
91
|
+
print(f" OK: {msg}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _warn(msg: str) -> None:
|
|
95
|
+
if _RICH and _con:
|
|
96
|
+
_con.print(f" [yellow]{msg}[/yellow]")
|
|
97
|
+
else:
|
|
98
|
+
print(f" Warning: {msg}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── wizard steps ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def _step1_provider() -> str:
|
|
104
|
+
"""Step 1: LLM Provider selection."""
|
|
105
|
+
_panel(
|
|
106
|
+
"Atomadic Forge — Setup [Step 1/5: LLM Provider]",
|
|
107
|
+
[
|
|
108
|
+
"[1] Ollama (local, free, private)",
|
|
109
|
+
"[2] Gemini (Google)",
|
|
110
|
+
"[3] Claude (Anthropic)",
|
|
111
|
+
"[4] OpenAI (GPT)",
|
|
112
|
+
"[5] Auto (detect best available)",
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
choice = _prompt_int("Select provider", default=5)
|
|
116
|
+
return _PROVIDER_MAP.get(choice, "auto")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _step2_model(provider: str, config: dict) -> dict:
|
|
120
|
+
"""Step 2: Model selection; returns config key updates."""
|
|
121
|
+
updates: dict = {}
|
|
122
|
+
|
|
123
|
+
if provider == "ollama":
|
|
124
|
+
url = config.get("ollama_url", DEFAULT_CONFIG["ollama_url"])
|
|
125
|
+
_info(f"Checking Ollama at [cyan]{url}[/cyan]..." if _RICH else f"Checking Ollama at {url}...")
|
|
126
|
+
info = detect_ollama(url)
|
|
127
|
+
|
|
128
|
+
if not info["available"]:
|
|
129
|
+
new_url = _prompt_str(" Ollama URL", default=url)
|
|
130
|
+
updates["ollama_url"] = new_url
|
|
131
|
+
info = detect_ollama(new_url)
|
|
132
|
+
else:
|
|
133
|
+
updates["ollama_url"] = url
|
|
134
|
+
|
|
135
|
+
if info["available"] and info["models"]:
|
|
136
|
+
lines = [f" Ollama detected at {info['url']}", " Available models:"]
|
|
137
|
+
for i, m in enumerate(info["models"][:8], 1):
|
|
138
|
+
lines.append(f" [{i}] {m}")
|
|
139
|
+
_panel("Step 2/5: Model Selection", lines)
|
|
140
|
+
choice = _prompt_int(" Select model", default=1)
|
|
141
|
+
idx = max(0, min(choice - 1, len(info["models"]) - 1))
|
|
142
|
+
updates["ollama_model"] = info["models"][idx]
|
|
143
|
+
else:
|
|
144
|
+
_warn("Ollama not reachable — using default model")
|
|
145
|
+
updates["ollama_model"] = config.get("ollama_model", DEFAULT_CONFIG["ollama_model"])
|
|
146
|
+
|
|
147
|
+
elif provider == "gemini":
|
|
148
|
+
_panel("Step 2/5: Model Selection", [
|
|
149
|
+
" Gemini models:",
|
|
150
|
+
" [1] gemini-2.5-flash (recommended — free tier)",
|
|
151
|
+
" [2] gemini-2.5-pro",
|
|
152
|
+
])
|
|
153
|
+
choice = _prompt_int(" Select model", default=1)
|
|
154
|
+
updates["gemini_model"] = {1: "gemini-2.5-flash", 2: "gemini-2.5-pro"}.get(choice, "gemini-2.5-flash")
|
|
155
|
+
|
|
156
|
+
elif provider in ("anthropic", "claude"):
|
|
157
|
+
_panel("Step 2/5: Model Selection", [
|
|
158
|
+
" Claude models:",
|
|
159
|
+
" [1] claude-sonnet-4-6 (recommended)",
|
|
160
|
+
" [2] claude-opus-4-7",
|
|
161
|
+
" [3] claude-haiku-4-5-20251001 (fastest)",
|
|
162
|
+
])
|
|
163
|
+
choice = _prompt_int(" Select model", default=1)
|
|
164
|
+
updates["anthropic_model"] = {
|
|
165
|
+
1: "claude-sonnet-4-6",
|
|
166
|
+
2: "claude-opus-4-7",
|
|
167
|
+
3: "claude-haiku-4-5-20251001",
|
|
168
|
+
}.get(choice, "claude-sonnet-4-6")
|
|
169
|
+
|
|
170
|
+
elif provider in ("openai", "gpt"):
|
|
171
|
+
_panel("Step 2/5: Model Selection", [
|
|
172
|
+
" OpenAI models:",
|
|
173
|
+
" [1] gpt-4o-mini (recommended — cost-effective)",
|
|
174
|
+
" [2] gpt-4o",
|
|
175
|
+
])
|
|
176
|
+
choice = _prompt_int(" Select model", default=1)
|
|
177
|
+
updates["openai_model"] = {1: "gpt-4o-mini", 2: "gpt-4o"}.get(choice, "gpt-4o-mini")
|
|
178
|
+
|
|
179
|
+
else: # auto
|
|
180
|
+
_panel("Step 2/5: Model Selection", [
|
|
181
|
+
" Auto mode: Forge picks the best available provider automatically.",
|
|
182
|
+
" Priority: Ollama (local, free) → Gemini → Anthropic → OpenAI",
|
|
183
|
+
])
|
|
184
|
+
|
|
185
|
+
return updates
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _step3_api_key(provider: str, config: dict) -> dict:
|
|
189
|
+
"""Step 3: API key input for cloud providers (skipped for Ollama/auto)."""
|
|
190
|
+
cloud = {"gemini": ("gemini_key", "aistudio.google.com/app/apikey"),
|
|
191
|
+
"anthropic": ("anthropic_key", "console.anthropic.com"),
|
|
192
|
+
"openai": ("openai_key", "platform.openai.com/api-keys")}
|
|
193
|
+
if provider not in cloud:
|
|
194
|
+
return {}
|
|
195
|
+
|
|
196
|
+
key_field, url = cloud[provider]
|
|
197
|
+
existing = config.get(key_field) or ""
|
|
198
|
+
_panel(f"Step 3/5: API Key — {_PROVIDER_LABEL.get(provider, provider)}", [
|
|
199
|
+
f" Get your key at: {url}",
|
|
200
|
+
" (press Enter to keep existing key; leave blank to skip)",
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
if existing:
|
|
204
|
+
masked = existing[:8] + "..." + existing[-4:] if len(existing) > 12 else "***"
|
|
205
|
+
keep = _prompt_confirm(f" Keep existing key ({masked})?", default=True)
|
|
206
|
+
if keep:
|
|
207
|
+
return {}
|
|
208
|
+
|
|
209
|
+
raw = getpass.getpass(f" Paste your {_PROVIDER_LABEL.get(provider, provider)} API key: ")
|
|
210
|
+
# Strip control characters that Windows terminals inject on paste (e.g. \x16 = ^V)
|
|
211
|
+
raw = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", raw).strip()
|
|
212
|
+
if not raw:
|
|
213
|
+
return {}
|
|
214
|
+
|
|
215
|
+
updates = {key_field: raw}
|
|
216
|
+
_info("Validating key...")
|
|
217
|
+
result = test_provider(provider, {**config, **updates})
|
|
218
|
+
if result["ok"]:
|
|
219
|
+
_ok(f"Key valid ({result['latency_ms']}ms)")
|
|
220
|
+
else:
|
|
221
|
+
_warn(f"Validation warning: {result['error']}")
|
|
222
|
+
return updates
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _step4_project_defaults(config: dict) -> dict:
|
|
226
|
+
"""Step 4: Target score, auto-apply, and directory defaults."""
|
|
227
|
+
_panel("Step 4/5: Project Defaults", [
|
|
228
|
+
" Configure default behaviour for forge commands.",
|
|
229
|
+
])
|
|
230
|
+
updates: dict = {}
|
|
231
|
+
|
|
232
|
+
cur_score = config.get("default_target_score", DEFAULT_CONFIG["default_target_score"])
|
|
233
|
+
raw = _prompt_str(" Default target score (0–100)", default=str(cur_score))
|
|
234
|
+
try:
|
|
235
|
+
updates["default_target_score"] = float(raw)
|
|
236
|
+
except ValueError:
|
|
237
|
+
updates["default_target_score"] = float(cur_score)
|
|
238
|
+
|
|
239
|
+
cur_auto = bool(config.get("auto_apply", DEFAULT_CONFIG["auto_apply"]))
|
|
240
|
+
updates["auto_apply"] = _prompt_confirm(
|
|
241
|
+
" Auto-apply by default (skip --apply flag)?", default=cur_auto
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
cur_out = config.get("output_dir", DEFAULT_CONFIG["output_dir"])
|
|
245
|
+
updates["output_dir"] = _prompt_str(" Output directory", default=cur_out)
|
|
246
|
+
|
|
247
|
+
cur_src = config.get("sources_dir", DEFAULT_CONFIG["sources_dir"])
|
|
248
|
+
updates["sources_dir"] = _prompt_str(" Sources directory", default=cur_src)
|
|
249
|
+
|
|
250
|
+
cur_prefix = config.get("package_prefix", DEFAULT_CONFIG["package_prefix"])
|
|
251
|
+
updates["package_prefix"] = _prompt_str(" Default package prefix", default=cur_prefix)
|
|
252
|
+
|
|
253
|
+
return updates
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _step5_verify_and_save(final_config: dict, project_dir: Path, config_path: Path) -> dict:
|
|
257
|
+
"""Step 5: Test LLM connection, save config, print summary."""
|
|
258
|
+
_panel("Step 5/5: Verification", [" Testing LLM connection and saving config..."])
|
|
259
|
+
|
|
260
|
+
provider = final_config.get("provider", "auto")
|
|
261
|
+
test_result = test_provider(provider, final_config)
|
|
262
|
+
python_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
263
|
+
|
|
264
|
+
save_config(final_config, config_path)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
rel_path = config_path.relative_to(project_dir)
|
|
268
|
+
except ValueError:
|
|
269
|
+
rel_path = config_path # type: ignore[assignment]
|
|
270
|
+
|
|
271
|
+
model = test_result.get("model", "unknown")
|
|
272
|
+
target_score = final_config.get("default_target_score", DEFAULT_CONFIG["default_target_score"])
|
|
273
|
+
auto_apply = final_config.get("auto_apply", DEFAULT_CONFIG["auto_apply"])
|
|
274
|
+
output_dir = final_config.get("output_dir", DEFAULT_CONFIG["output_dir"])
|
|
275
|
+
sources_dir = final_config.get("sources_dir", DEFAULT_CONFIG["sources_dir"])
|
|
276
|
+
ollama_url = final_config.get("ollama_url", DEFAULT_CONFIG["ollama_url"])
|
|
277
|
+
|
|
278
|
+
llm_ok = test_result["ok"]
|
|
279
|
+
llm_line = (
|
|
280
|
+
f"✓ LLM connection tested — OK ({test_result['latency_ms']}ms)"
|
|
281
|
+
if llm_ok
|
|
282
|
+
else f"✗ LLM: {test_result.get('error', 'failed')}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if _RICH and _con:
|
|
286
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
287
|
+
table.add_column("Key", style="bold")
|
|
288
|
+
table.add_column("Value")
|
|
289
|
+
table.add_row("Provider:", _PROVIDER_LABEL.get(provider, provider))
|
|
290
|
+
table.add_row("Model:", model)
|
|
291
|
+
if provider == "ollama":
|
|
292
|
+
table.add_row("URL:", ollama_url)
|
|
293
|
+
table.add_row("Target Score:", f"{target_score}/100")
|
|
294
|
+
table.add_row("Auto-Apply:", "Yes" if auto_apply else "No (dry-run by default)")
|
|
295
|
+
table.add_row("Output Dir:", output_dir)
|
|
296
|
+
table.add_row("Sources Dir:", sources_dir)
|
|
297
|
+
table.add_row("Config File:", str(rel_path))
|
|
298
|
+
_con.print()
|
|
299
|
+
_con.print(Panel(table, title="[bold green]Configuration Summary[/bold green]",
|
|
300
|
+
expand=False))
|
|
301
|
+
color = "green" if llm_ok else "yellow"
|
|
302
|
+
_con.print(f"\n [{color}]{llm_line}[/{color}]")
|
|
303
|
+
_con.print(f" [green]✓ Python {python_ver} detected[/green]")
|
|
304
|
+
_con.print(f" [green]✓ Config saved to {rel_path}[/green]\n")
|
|
305
|
+
else:
|
|
306
|
+
print("\n--- Configuration Summary ---")
|
|
307
|
+
print(f" Provider: {_PROVIDER_LABEL.get(provider, provider)}")
|
|
308
|
+
print(f" Model: {model}")
|
|
309
|
+
if provider == "ollama":
|
|
310
|
+
print(f" URL: {ollama_url}")
|
|
311
|
+
print(f" Target Score: {target_score}/100")
|
|
312
|
+
print(f" Auto-Apply: {'Yes' if auto_apply else 'No (dry-run by default)'}")
|
|
313
|
+
print(f" Output Dir: {output_dir}")
|
|
314
|
+
print(f" Sources Dir: {sources_dir}")
|
|
315
|
+
print(f" Config File: {rel_path}")
|
|
316
|
+
print(f"\n {llm_line}")
|
|
317
|
+
print(f" Python {python_ver} detected")
|
|
318
|
+
print(f" Config saved to {rel_path}")
|
|
319
|
+
|
|
320
|
+
return {"test": test_result, "config_path": str(config_path)}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── public entry point ────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def run_wizard(project_dir: Path) -> dict:
|
|
326
|
+
"""Run all 5 wizard steps interactively and return the final saved config."""
|
|
327
|
+
config: dict = dict(load_config(project_dir))
|
|
328
|
+
|
|
329
|
+
config["provider"] = _step1_provider()
|
|
330
|
+
config.update(_step2_model(config["provider"], config))
|
|
331
|
+
config.update(_step3_api_key(config["provider"], config))
|
|
332
|
+
config.update(_step4_project_defaults(config))
|
|
333
|
+
|
|
334
|
+
config_path = project_dir / LOCAL_CONFIG_DIR / CONFIG_FILE_NAME
|
|
335
|
+
_step5_verify_and_save(config, project_dir, config_path)
|
|
336
|
+
|
|
337
|
+
return config
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tier a3 — Synergy Scan feature.
|
|
2
|
+
|
|
3
|
+
One pipeline that wires the a1 surface-extractor + detector + renderer:
|
|
4
|
+
|
|
5
|
+
SynergyScan(repo).scan() → SynergyScanReport
|
|
6
|
+
SynergyScan(repo).implement(candidate_id) → wrote commands/<name>.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..a0_qk_constants.synergy_types import (
|
|
16
|
+
SynergyScanReport,
|
|
17
|
+
)
|
|
18
|
+
from ..a1_at_functions.synergy_detect import detect_synergies
|
|
19
|
+
from ..a1_at_functions.synergy_render import render_synergy_adapter
|
|
20
|
+
from ..a1_at_functions.synergy_surface_extract import harvest_feature_surfaces
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SynergyScan:
|
|
24
|
+
"""Find feature-level synergies across an ASS-ADE-style package."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, src_root: Path, package: str = "atomadic_forge") -> None:
|
|
27
|
+
self.src_root = Path(src_root)
|
|
28
|
+
self.package = package
|
|
29
|
+
self._features = None # cached
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def features(self) -> list:
|
|
33
|
+
if self._features is None:
|
|
34
|
+
self._features = harvest_feature_surfaces(self.src_root, self.package)
|
|
35
|
+
return self._features
|
|
36
|
+
|
|
37
|
+
def scan(self, *, top_n: int = 25) -> SynergyScanReport:
|
|
38
|
+
candidates = detect_synergies(self.features)[:top_n]
|
|
39
|
+
return SynergyScanReport(
|
|
40
|
+
schema_version="atomadic-forge.synergy.scan/v1",
|
|
41
|
+
generated_at_utc=_dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
42
|
+
feature_count=len(self.features),
|
|
43
|
+
candidate_count=len(candidates),
|
|
44
|
+
candidates=candidates,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def implement(self, candidate_id: str, report: SynergyScanReport,
|
|
48
|
+
*, out_dir: Path | None = None) -> Path:
|
|
49
|
+
match = next((c for c in report["candidates"]
|
|
50
|
+
if c["candidate_id"] == candidate_id), None)
|
|
51
|
+
if match is None:
|
|
52
|
+
raise KeyError(f"candidate {candidate_id} not in report")
|
|
53
|
+
target_dir = out_dir or (self.src_root / self.package / "commands")
|
|
54
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
slug = match["proposed_adapter_name"].replace("-", "_") + ".py"
|
|
56
|
+
target = target_dir / slug
|
|
57
|
+
target.write_text(render_synergy_adapter(match), encoding="utf-8")
|
|
58
|
+
return target
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def save_report(report: SynergyScanReport, target: Path) -> Path:
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
target.write_text(json.dumps(report, indent=2, default=str),
|
|
64
|
+
encoding="utf-8")
|
|
65
|
+
return target
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tier a4 — orchestration: CLI surface, entry points."""
|