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.
Files changed (131) hide show
  1. atomadic_forge/__init__.py +12 -0
  2. atomadic_forge/__main__.py +5 -0
  3. atomadic_forge/a0_qk_constants/__init__.py +1 -0
  4. atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
  5. atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
  6. atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
  7. atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
  8. atomadic_forge/a0_qk_constants/error_codes.py +296 -0
  9. atomadic_forge/a0_qk_constants/forge_types.py +89 -0
  10. atomadic_forge/a0_qk_constants/gen_language.py +116 -0
  11. atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
  12. atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
  13. atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
  14. atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
  15. atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
  16. atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
  17. atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
  18. atomadic_forge/a0_qk_constants/tier_names.py +47 -0
  19. atomadic_forge/a1_at_functions/__init__.py +1 -0
  20. atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
  21. atomadic_forge/a1_at_functions/agent_memory.py +139 -0
  22. atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
  23. atomadic_forge/a1_at_functions/agent_summary.py +277 -0
  24. atomadic_forge/a1_at_functions/body_extractor.py +306 -0
  25. atomadic_forge/a1_at_functions/card_renderer.py +210 -0
  26. atomadic_forge/a1_at_functions/certify_checks.py +445 -0
  27. atomadic_forge/a1_at_functions/chat_context.py +170 -0
  28. atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
  29. atomadic_forge/a1_at_functions/classify_tier.py +115 -0
  30. atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
  31. atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
  32. atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
  33. atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
  34. atomadic_forge/a1_at_functions/config_io.py +68 -0
  35. atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
  36. atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
  37. atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
  38. atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
  39. atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
  40. atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
  41. atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
  42. atomadic_forge/a1_at_functions/error_hints.py +105 -0
  43. atomadic_forge/a1_at_functions/evolution_log.py +94 -0
  44. atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
  45. atomadic_forge/a1_at_functions/generation_quality.py +322 -0
  46. atomadic_forge/a1_at_functions/import_repair.py +211 -0
  47. atomadic_forge/a1_at_functions/import_smoke.py +102 -0
  48. atomadic_forge/a1_at_functions/js_parser.py +539 -0
  49. atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
  50. atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
  51. atomadic_forge/a1_at_functions/llm_client.py +554 -0
  52. atomadic_forge/a1_at_functions/local_signer.py +134 -0
  53. atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
  54. atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
  55. atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
  56. atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
  57. atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
  58. atomadic_forge/a1_at_functions/policy_loader.py +107 -0
  59. atomadic_forge/a1_at_functions/preflight_change.py +227 -0
  60. atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
  61. atomadic_forge/a1_at_functions/provider_detect.py +157 -0
  62. atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
  63. atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
  64. atomadic_forge/a1_at_functions/recipes.py +186 -0
  65. atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
  66. atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
  67. atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
  68. atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
  69. atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
  70. atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
  71. atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
  72. atomadic_forge/a1_at_functions/scout_walk.py +309 -0
  73. atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
  74. atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
  75. atomadic_forge/a1_at_functions/stub_detector.py +158 -0
  76. atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
  77. atomadic_forge/a1_at_functions/synergy_render.py +252 -0
  78. atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
  79. atomadic_forge/a1_at_functions/test_runner.py +196 -0
  80. atomadic_forge/a1_at_functions/test_selector.py +122 -0
  81. atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
  82. atomadic_forge/a1_at_functions/tool_composer.py +130 -0
  83. atomadic_forge/a1_at_functions/transcript_log.py +70 -0
  84. atomadic_forge/a1_at_functions/wire_check.py +260 -0
  85. atomadic_forge/a2_mo_composites/__init__.py +1 -0
  86. atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
  87. atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
  88. atomadic_forge/a2_mo_composites/plan_store.py +164 -0
  89. atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
  90. atomadic_forge/a3_og_features/__init__.py +1 -0
  91. atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
  92. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
  93. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
  94. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
  95. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
  96. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
  97. atomadic_forge/a3_og_features/demo_runner.py +502 -0
  98. atomadic_forge/a3_og_features/emergent_feature.py +95 -0
  99. atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
  100. atomadic_forge/a3_og_features/forge_enforce.py +107 -0
  101. atomadic_forge/a3_og_features/forge_evolve.py +176 -0
  102. atomadic_forge/a3_og_features/forge_loop.py +528 -0
  103. atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
  104. atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
  105. atomadic_forge/a3_og_features/lsp_server.py +98 -0
  106. atomadic_forge/a3_og_features/mcp_server.py +160 -0
  107. atomadic_forge/a3_og_features/setup_wizard.py +337 -0
  108. atomadic_forge/a3_og_features/synergy_feature.py +65 -0
  109. atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
  110. atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
  111. atomadic_forge/commands/__init__.py +1 -0
  112. atomadic_forge/commands/_registry.py +36 -0
  113. atomadic_forge/commands/audit.py +142 -0
  114. atomadic_forge/commands/chat.py +133 -0
  115. atomadic_forge/commands/commandsmith.py +178 -0
  116. atomadic_forge/commands/config_cmd.py +145 -0
  117. atomadic_forge/commands/demo.py +142 -0
  118. atomadic_forge/commands/emergent.py +124 -0
  119. atomadic_forge/commands/emergent_then_synergy.py +70 -0
  120. atomadic_forge/commands/evolve.py +122 -0
  121. atomadic_forge/commands/evolve_then_iterate.py +70 -0
  122. atomadic_forge/commands/feature_then_emergent.py +111 -0
  123. atomadic_forge/commands/iterate.py +140 -0
  124. atomadic_forge/commands/synergy.py +96 -0
  125. atomadic_forge/commands/synergy_then_emergent.py +70 -0
  126. atomadic_forge-0.3.2.dist-info/METADATA +471 -0
  127. atomadic_forge-0.3.2.dist-info/RECORD +131 -0
  128. atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
  129. atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
  130. atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
  131. 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."""