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,94 @@
1
+ """Tier a1 — append-only evolution log.
2
+
3
+ Every successful evolve / demo run appends a row to a shared
4
+ ``.atomadic-forge/EVOLVE_LOG.md`` markdown table at the project root,
5
+ plus a JSONL line for machine consumption. Forge documents its own
6
+ history so operators can see every artifact that has ever been produced.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ _TABLE_HEADER = (
17
+ "| When (UTC) | Preset / Intent | Package | LLM | Rounds | Trajectory | Final | Verdict |\n"
18
+ "|------------|-----------------|---------|-----|-------:|------------|------:|---------|"
19
+ )
20
+ _LOG_FILENAME = "EVOLVE_LOG.md"
21
+ _JSONL_FILENAME = "evolve_log.jsonl"
22
+
23
+
24
+ def _ensure_log_dir(project_root: Path) -> Path:
25
+ d = project_root / ".atomadic-forge"
26
+ d.mkdir(parents=True, exist_ok=True)
27
+ return d
28
+
29
+
30
+ def _intent_short(intent: str, max_len: int = 70) -> str:
31
+ one_line = " ".join(intent.split())
32
+ if len(one_line) <= max_len:
33
+ return one_line
34
+ return one_line[:max_len - 1] + "…"
35
+
36
+
37
+ def append_evolve_run(
38
+ *,
39
+ project_root: Path,
40
+ package: str,
41
+ intent: str,
42
+ llm_name: str,
43
+ rounds_completed: int,
44
+ score_trajectory: list[float],
45
+ final_score: float,
46
+ converged: bool,
47
+ halt_reason: str = "",
48
+ extra: dict[str, Any] | None = None,
49
+ ) -> Path:
50
+ """Append a row to the evolve log markdown + jsonl."""
51
+ log_dir = _ensure_log_dir(project_root)
52
+ md_path = log_dir / _LOG_FILENAME
53
+ jsonl_path = log_dir / _JSONL_FILENAME
54
+ ts = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
55
+ arc = " → ".join(f"{s:.0f}" for s in score_trajectory)
56
+ verdict = (
57
+ "PASS" if (converged and final_score >= 75)
58
+ else "REFINE" if final_score >= 50
59
+ else "STAGNATED" if halt_reason == "stagnation"
60
+ else "FAIL"
61
+ )
62
+
63
+ if not md_path.exists():
64
+ md_path.write_text(
65
+ "# Atomadic Forge — Evolution Log\n\n"
66
+ "Auto-appended by every `forge evolve` / `forge demo` run.\n\n"
67
+ f"{_TABLE_HEADER}\n",
68
+ encoding="utf-8",
69
+ )
70
+ row = (
71
+ f"| {ts} | {_intent_short(intent)} | `{package}` | `{llm_name}` "
72
+ f"| {rounds_completed} | `{arc}` | **{final_score:.0f}** | {verdict} |\n"
73
+ )
74
+ with md_path.open("a", encoding="utf-8") as f:
75
+ f.write(row)
76
+
77
+ entry = {
78
+ "schema_version": "atomadic-forge.evolve_log/v1",
79
+ "ts_utc": ts,
80
+ "package": package,
81
+ "intent": intent,
82
+ "llm": llm_name,
83
+ "rounds_completed": rounds_completed,
84
+ "score_trajectory": score_trajectory,
85
+ "final_score": final_score,
86
+ "converged": converged,
87
+ "halt_reason": halt_reason,
88
+ "verdict": verdict,
89
+ "extra": extra or {},
90
+ }
91
+ with jsonl_path.open("a", encoding="utf-8") as f:
92
+ f.write(json.dumps(entry, default=str) + "\n")
93
+
94
+ return md_path
@@ -0,0 +1,433 @@
1
+ """Tier a1 — pure feedback packer.
2
+
3
+ Takes the structured output of wire / certify / emergent / scout and packs
4
+ it into a markdown-shaped string that an LLM can act on. Forge feeds this
5
+ back to the LLM each iteration of the generate-enforce loop.
6
+
7
+ The packing is designed to make the LLM's job easy:
8
+ * concrete file paths
9
+ * exact line content of violating imports
10
+ * explicit "fix this by …" hints when the rule is mechanical
11
+ * a single ``CHANGE_REQUEST`` block at the bottom describing what to emit next
12
+
13
+ System prompts come in language-specific variants (Python / JavaScript /
14
+ TypeScript) — the structural feedback (wire violations, score gaps, reuse
15
+ ratio) is language-agnostic, but the emit-format and import-style
16
+ instructions differ per language.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ _SYSTEM_PROMPT_PYTHON = """\
24
+ You are a code-generation engine constrained by Atomadic Forge's 5-tier
25
+ monadic architecture.
26
+
27
+ Forge has ALREADY scaffolded the surrounding package for you:
28
+ - ``pyproject.toml`` (PEP-621, with a console_script entry pointing at
29
+ ``a4_sy_orchestration.cli:main``)
30
+ - ``README.md`` describing the intent and layout
31
+ - ``.gitignore``
32
+ - ``tests/`` directory with ``conftest.py`` adding ``src/`` to ``sys.path``
33
+ - All five tier directories with ``__init__.py``
34
+
35
+ You should focus on emitting the actual ``.py`` files (and ``test_*.py``
36
+ files in ``tests/``). DO NOT re-emit pyproject.toml or README.md — they
37
+ are already correct.
38
+
39
+ The 5-tier law (compose UPWARD only):
40
+ a0_qk_constants — constants, enums, TypedDicts. Imports nothing.
41
+ a1_at_functions — pure stateless functions. Imports a0 only.
42
+ a2_mo_composites — stateful classes. Imports a0, a1.
43
+ a3_og_features — feature orchestrators. Imports a0, a1, a2.
44
+ a4_sy_orchestration — CLI / entry points. Imports a0–a3.
45
+
46
+ When you emit code, you MUST:
47
+ 1. Place each new file under the correct ``a{N}_*/`` directory.
48
+ 2. Never import upward (lower tier importing from higher tier).
49
+ 3. One responsibility per file.
50
+ 4. Module docstrings of the form: \"\"\"Tier aN — <one-line>.\"\"\"
51
+ 5. Write COMPLETE function bodies — no `pass`, no `NotImplementedError`,
52
+ no `# TODO`, no `# Implement me!`. If you don't know how to implement
53
+ something, choose a simpler design rather than emit a stub.
54
+ 6. Use ABSOLUTE imports rooted at the package: `from <pkg>.a1_at_functions.foo
55
+ import foo` — not relative imports, not bare names.
56
+ 7. Output as a JSON array of file objects ONLY — no prose, no fences:
57
+ [{"path": "src/<pkg>/aN_…/foo.py", "content": "…"}, ...]
58
+ 8. SUBSTITUTE the actual package name into every `<pkg>` placeholder.
59
+ 9. Emit at least one ``test_*.py`` file under ``tests/`` exercising the
60
+ public callables you just wrote. Use simple ``assert`` statements
61
+ and import via the absolute package path.
62
+
63
+ Example (intent: "calc with add() at a1, CLI at a4", package: "calc"):
64
+
65
+ [
66
+ {"path": "src/calc/a1_at_functions/add.py",
67
+ "content": "\\"\\"\\"Tier a1 — pure addition.\\"\\"\\"\\n\\ndef add(a: int, b: int) -> int:\\n return a + b\\n"},
68
+ {"path": "src/calc/a4_sy_orchestration/cli.py",
69
+ "content": "\\"\\"\\"Tier a4 — CLI entry.\\"\\"\\"\\nimport argparse\\nfrom calc.a1_at_functions.add import add\\n\\ndef main():\\n p = argparse.ArgumentParser()\\n p.add_argument('a', type=int); p.add_argument('b', type=int)\\n args = p.parse_args()\\n print(add(args.a, args.b))\\n"}
70
+ ]
71
+
72
+ Forge then materialises your output, runs wire + certify + import-smoke,
73
+ and feeds any violations back to you on the next turn. An importable
74
+ package with real bodies scores high; stubs and broken imports lose points.
75
+ """
76
+
77
+
78
+ _SYSTEM_PROMPT_JS = """\
79
+ You are a code-generation engine constrained by Atomadic Forge's 5-tier
80
+ monadic architecture, emitting JavaScript (ES modules) for Cloudflare
81
+ Workers / Node 20+ / browsers.
82
+
83
+ Forge has ALREADY scaffolded the surrounding package for you:
84
+ - ``package.json`` with ``"type": "module"`` so ES6 imports resolve
85
+ - ``README.md`` describing the intent and layout
86
+ - ``.gitignore``
87
+ - All five tier directories (no ``__init__.py`` — ES modules don't need them)
88
+
89
+ You should focus on emitting the actual ``.js`` files. DO NOT re-emit
90
+ ``package.json`` or ``README.md`` — they are already correct.
91
+
92
+ The 5-tier law (compose UPWARD only):
93
+ a0_qk_constants — exported constants, enums, type-shape JSDoc. Imports nothing.
94
+ a1_at_functions — pure stateless functions. Imports a0 only.
95
+ a2_mo_composites — stateful classes / clients / stores. Imports a0, a1.
96
+ a3_og_features — feature orchestrators. Imports a0, a1, a2.
97
+ a4_sy_orchestration — Worker entry / CLI / top-level dispatch. Imports a0–a3.
98
+
99
+ When you emit code, you MUST:
100
+ 1. Place each new file under the correct ``a{N}_*/`` directory.
101
+ 2. Never import upward (lower tier importing from higher tier).
102
+ 3. One responsibility per file.
103
+ 4. File-leading docstring comment of the form: ``// Tier aN — <one-line>.``
104
+ 5. Write COMPLETE function bodies — no `throw new Error("not impl")`,
105
+ no `// TODO`, no empty bodies that just return undefined. If you
106
+ don't know how to implement something, choose a simpler design.
107
+ 6. Use ES module syntax — ``import { x } from "./other.js"`` and
108
+ ``export function …`` / ``export const …`` / ``export default …``.
109
+ Cross-tier imports MUST include the full relative path with the
110
+ ``.js`` extension: ``import { foo } from "../a1_at_functions/foo.js"``.
111
+ No bare specifiers, no CommonJS ``require()``, no default-only exports
112
+ for libraries (default-export the Worker handler at a4 ONLY).
113
+ 7. Output as a JSON array of file objects ONLY — no prose, no fences:
114
+ [{"path": "<pkg>/aN_…/foo.js", "content": "…"}, ...]
115
+ 8. SUBSTITUTE the actual package name into every `<pkg>` placeholder.
116
+ Note: there is NO ``src/`` prefix — the package directory sits at
117
+ the output root.
118
+ 9. For a Cloudflare Worker, the a4 entry file exports a ``default``
119
+ object with ``fetch(request, env, ctx)`` and/or ``scheduled(event,
120
+ env, ctx)``. Helpers it calls live in a1–a3.
121
+
122
+ Example (intent: "counter with increment() at a1, Worker at a4", package: "counter"):
123
+
124
+ [
125
+ {"path": "counter/a1_at_functions/increment.js",
126
+ "content": "// Tier a1 — pure increment.\\nexport function increment(n) { return n + 1; }\\n"},
127
+ {"path": "counter/a4_sy_orchestration/worker.js",
128
+ "content": "// Tier a4 — Worker entry.\\nimport { increment } from \\"../a1_at_functions/increment.js\\";\\nexport default {\\n async fetch(request) {\\n const url = new URL(request.url);\\n const n = Number(url.searchParams.get(\\"n\\")) || 0;\\n return new Response(String(increment(n)));\\n }\\n};\\n"}
129
+ ]
130
+
131
+ Forge then materialises your output, runs wire + certify, and feeds any
132
+ violations back to you on the next turn. Clean ES-module imports score
133
+ high; broken imports and upward-tier violations lose points.
134
+ """
135
+
136
+
137
+ def system_prompt(language: str = "python") -> str:
138
+ """Return the language-appropriate system prompt for the LLM.
139
+
140
+ ``language`` accepts ``"python"`` (default), ``"javascript"``, or
141
+ ``"typescript"`` (typescript reuses the JS prompt today; tsconfig
142
+ polish is on the 0.3 roadmap).
143
+ """
144
+ if language in ("javascript", "typescript"):
145
+ return _SYSTEM_PROMPT_JS
146
+ return _SYSTEM_PROMPT_PYTHON
147
+
148
+
149
+ def pack_initial_intent(intent: str, *, package: str = "absorbed",
150
+ seed_catalog: list[dict] | None = None,
151
+ language: str = "python") -> str:
152
+ """Build the first-turn prompt: the user's intent + (optional) seed material."""
153
+ if language in ("javascript", "typescript"):
154
+ target_path = f"`{package}/` — emit files under the 5-tier layout below."
155
+ else:
156
+ target_path = f"`src/{package}/` — emit files under the 5-tier layout below."
157
+ parts = [
158
+ "# Intent",
159
+ "",
160
+ intent.strip(),
161
+ "",
162
+ "# Target package",
163
+ target_path,
164
+ "",
165
+ ]
166
+ if seed_catalog:
167
+ # Deduplicate by qualname and prefer top-level symbols (no dots except class.method).
168
+ seen: set[str] = set()
169
+ unique_catalog: list[dict] = []
170
+ for s in seed_catalog:
171
+ qn = s.get("qualname", "")
172
+ # Skip method-level duplicates; prefer class-level or module-level.
173
+ base = qn.split(".")[0] if "." in qn else qn
174
+ if base not in seen:
175
+ seen.add(base)
176
+ unique_catalog.append(s)
177
+ MAX_SEEDS = 30
178
+ parts.append(f"# Available building blocks ({len(seed_catalog)} symbols — {len(unique_catalog)} unique top-level; showing {min(MAX_SEEDS, len(unique_catalog))})")
179
+ parts.append("")
180
+ for s in unique_catalog[:MAX_SEEDS]:
181
+ parts.append(
182
+ f"- `{s.get('qualname','?')}` "
183
+ f"(tier `{s.get('tier_guess','?')}`, "
184
+ f"effects {s.get('effects', [])})"
185
+ )
186
+ if len(unique_catalog) > MAX_SEEDS:
187
+ parts.append(f"- … {len(unique_catalog) - MAX_SEEDS} more unique symbols available")
188
+ parts.append("")
189
+ if language in ("javascript", "typescript"):
190
+ parts.append("Reuse these where possible — import from the "
191
+ f"corresponding `{package}/aN_*/<file>.js` paths "
192
+ "with relative ES-module specifiers.")
193
+ else:
194
+ parts.append("Reuse these where possible by importing from "
195
+ f"`{package}.aN_…` paths.")
196
+ parts.append("")
197
+ parts.extend([
198
+ "# CHANGE_REQUEST",
199
+ "",
200
+ "Emit the initial set of files needed to satisfy the intent. Output",
201
+ "a JSON array of `{path, content}` objects only — no prose around it.",
202
+ ])
203
+ return "\n".join(parts)
204
+
205
+
206
+ def compute_reuse_stats(scout_report: dict[str, Any] | None,
207
+ seed_catalog: list[dict] | None) -> dict[str, Any]:
208
+ """Compute how much of the LLM's emitted symbols overlap with the seed catalog.
209
+
210
+ A high reuse ratio = the LLM correctly composed existing pieces. A low
211
+ reuse ratio = it generated from scratch when it should have been
212
+ importing. This is the soft signal that distinguishes Forge's loop
213
+ from Cursor / Devin / etc. — none of them feed reuse stats back to the
214
+ generator.
215
+ """
216
+ if not scout_report or not seed_catalog:
217
+ return {"reuse_ratio": 0.0, "novel_symbols": [],
218
+ "reused_symbols": [], "available_unused": []}
219
+ available = {s["qualname"]: s for s in seed_catalog}
220
+ emitted = {s["qualname"]: s for s in scout_report.get("symbols", [])}
221
+ reused = sorted(emitted.keys() & available.keys())
222
+ novel = sorted(set(emitted.keys()) - set(available.keys()))
223
+ available_unused = sorted(set(available.keys()) - set(emitted.keys()))
224
+ total_emitted = max(1, len(emitted))
225
+ return {
226
+ "reuse_ratio": round(len(reused) / total_emitted, 3),
227
+ "reused_symbols": reused[:20],
228
+ "novel_symbols": novel[:20],
229
+ "available_unused": available_unused[:20],
230
+ }
231
+
232
+
233
+ def pack_feedback(*, wire_report: dict[str, Any] | None = None,
234
+ certify_report: dict[str, Any] | None = None,
235
+ emergent_overlay: dict[str, Any] | None = None,
236
+ reuse_stats: dict[str, Any] | None = None,
237
+ iteration: int = 0) -> str:
238
+ """Pack the next-turn prompt: violations + score gaps + emergent + reuse.
239
+
240
+ The 3-way constraint-satisfaction signal that distinguishes Forge's loop
241
+ from Cursor / Devin / Cognition: hard constraint (wire), score gap
242
+ (certify), and compositional signal (emergent + reuse).
243
+ """
244
+ parts = [f"# Forge feedback (iteration {iteration})", ""]
245
+
246
+ if wire_report:
247
+ v_count = wire_report.get("violation_count", 0)
248
+ verdict = wire_report.get("verdict", "?")
249
+ parts.append(f"## Wire scan: **{verdict}** ({v_count} violation(s))")
250
+ parts.append("")
251
+ for v in wire_report.get("violations", [])[:20]:
252
+ parts.append(
253
+ f"- `{v['file']}` imports `{v['imported']}` "
254
+ f"from tier `{v['to_tier']}` while sitting at tier "
255
+ f"`{v['from_tier']}` — **upward import, illegal**."
256
+ )
257
+ if v_count == 0:
258
+ parts.append("All imports compose upward only — no fixes needed here.")
259
+ parts.append("")
260
+
261
+ if certify_report:
262
+ score = certify_report.get("score", 0)
263
+ parts.append(f"## Certify score: **{score}/100**")
264
+ parts.append("")
265
+ for issue in certify_report.get("issues", []):
266
+ parts.append(f"- {issue}")
267
+ for rec in certify_report.get("recommendations", []):
268
+ parts.append(f" → {rec}")
269
+ # Per-file stub callouts — actionable feedback the LLM can target.
270
+ stubs = (certify_report.get("detail") or {}).get("stubs") or {}
271
+ findings = stubs.get("findings") or []
272
+ if findings:
273
+ parts.append("")
274
+ parts.append("**Stub bodies — replace with real implementations:**")
275
+ for f in findings[:10]:
276
+ parts.append(
277
+ f" · `{f['file']}` line {f['lineno']} → "
278
+ f"`{f['qualname']}` ({f['kind']}): `{f['excerpt']}`"
279
+ )
280
+ # Runtime import error — paste the actual traceback so the LLM
281
+ # can fix the exact failing import / syntax / path issue.
282
+ smoke = (certify_report.get("detail") or {}).get("import_smoke")
283
+ if smoke and not smoke.get("importable", True):
284
+ parts.append("")
285
+ parts.append("**Runtime import FAILED — package does not load:**")
286
+ parts.append(f" · error: `{smoke.get('error_kind')}` — "
287
+ f"{smoke.get('error_message')}")
288
+ tb = smoke.get("traceback_excerpt") or ""
289
+ if tb:
290
+ parts.append("")
291
+ parts.append("```text")
292
+ parts.append(tb.strip())
293
+ parts.append("```")
294
+ parts.append("")
295
+ parts.append("Fix the import error. Common causes:")
296
+ parts.append(" - missing module file or wrong tier directory")
297
+ parts.append(" - relative import path uses old/wrong package name")
298
+ parts.append(" - syntax error in an emitted file")
299
+
300
+ # Behavioral test failures — the breakthrough signal. Identity-
301
+ # function stubs pass wire+import but fail tests. Pasting the
302
+ # pytest output lets the LLM see exactly which assertion broke.
303
+ test_run = (certify_report.get("detail") or {}).get("test_run")
304
+ if test_run and test_run.get("ran") and test_run.get("failed"):
305
+ parts.append("")
306
+ parts.append(
307
+ f"**Tests FAILED: {test_run['failed']} of "
308
+ f"{test_run['total']} "
309
+ f"({test_run.get('pass_ratio', 0):.0%} pass-ratio)**"
310
+ )
311
+ for fid in (test_run.get("failure_excerpts") or [])[:5]:
312
+ parts.append(f" · `{fid}`")
313
+ summary = (test_run.get("pytest_summary") or "").strip()
314
+ if summary:
315
+ parts.append("")
316
+ parts.append("```text")
317
+ parts.append(summary[:1500])
318
+ parts.append("```")
319
+ parts.append("")
320
+ parts.append("Tests are the behavior gate. An identity-function "
321
+ "implementation (e.g. `def f(x): return x`) passes "
322
+ "wire and import but fails its own tests. Replace "
323
+ "stubs with implementations that satisfy the "
324
+ "assertions.")
325
+ parts.append("")
326
+
327
+ if reuse_stats:
328
+ ratio = reuse_stats.get("reuse_ratio", 0.0)
329
+ unused = reuse_stats.get("available_unused", [])
330
+ parts.append(f"## Reuse signal: **{ratio:.0%}** of emitted symbols "
331
+ "match the seed catalog")
332
+ if ratio < 0.3 and unused:
333
+ parts.append("")
334
+ parts.append("**Low reuse — these existing symbols would likely "
335
+ "satisfy your needs without re-implementation:**")
336
+ for q in unused[:8]:
337
+ parts.append(f"- `{q}`")
338
+ parts.append("")
339
+ parts.append("Compose existing symbols by importing them; only "
340
+ "emit new code where no existing symbol fits.")
341
+ parts.append("")
342
+
343
+ if emergent_overlay and emergent_overlay.get("candidates"):
344
+ parts.append("## Emergent compositions (chains you could wire)")
345
+ parts.append("")
346
+ for c in emergent_overlay["candidates"][:5]:
347
+ chain_str = " → ".join(c["chain"]["chain"][:4])
348
+ parts.append(
349
+ f"- **{c['name']}** (score {c['score']:.0f}): `{chain_str}`"
350
+ )
351
+ parts.append("")
352
+
353
+ parts.extend([
354
+ "# CHANGE_REQUEST",
355
+ "",
356
+ "Fix the violations and score gaps above. Output a JSON array of",
357
+ "`{path, content}` objects representing files to write or overwrite.",
358
+ "Prefer composing existing symbols over emitting new ones. Empty",
359
+ "array means you have finished.",
360
+ ])
361
+ return "\n".join(parts)
362
+
363
+
364
+ def parse_files_from_response(response: str) -> list[dict[str, str]]:
365
+ """Best-effort: pull ``[{"path": …, "content": …}, …]`` out of an LLM reply.
366
+
367
+ Tolerant of:
368
+ - Triple-backtick fences (```json ... ```)
369
+ - Prose before/after the JSON array
370
+ - Truncated responses (extracts complete objects before the cutoff)
371
+ Returns [] when no valid file dicts are found.
372
+ """
373
+ import json as _json
374
+ import re
375
+
376
+ text = response.strip()
377
+
378
+ # Strategy 1: find JSON inside a code fence (greedy match for full array).
379
+ fence = re.search(r"```(?:json|python)?\s*(\[[\s\S]*?\])\s*```", text, re.DOTALL)
380
+ if fence:
381
+ candidate = fence.group(1)
382
+ try:
383
+ data = _json.loads(candidate)
384
+ if isinstance(data, list):
385
+ return [e for e in data
386
+ if isinstance(e, dict)
387
+ and isinstance(e.get("path"), str)
388
+ and isinstance(e.get("content"), str)]
389
+ except _json.JSONDecodeError:
390
+ pass
391
+
392
+ # Strategy 2: balanced-bracket scan starting at first '['.
393
+ start = text.find("[")
394
+ if start != -1:
395
+ depth = 0
396
+ end = -1
397
+ for i, ch in enumerate(text[start:], start=start):
398
+ if ch == "[":
399
+ depth += 1
400
+ elif ch == "]":
401
+ depth -= 1
402
+ if depth == 0:
403
+ end = i
404
+ break
405
+ if end != -1:
406
+ try:
407
+ data = _json.loads(text[start:end + 1])
408
+ if isinstance(data, list):
409
+ out = [e for e in data
410
+ if isinstance(e, dict)
411
+ and isinstance(e.get("path"), str)
412
+ and isinstance(e.get("content"), str)]
413
+ if out:
414
+ return out
415
+ except _json.JSONDecodeError:
416
+ pass
417
+
418
+ # Strategy 3: tolerant extraction — grab every complete {"path":…,"content":…} object
419
+ # even from a truncated/malformed array. Uses a regex to find object boundaries.
420
+ objects = []
421
+ # Match objects that have at least "path" and "content" keys.
422
+ obj_pattern = re.compile(
423
+ r'\{\s*"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"((?:[^"\\]|\\.)*?)"\s*\}',
424
+ re.DOTALL,
425
+ )
426
+ for m in obj_pattern.finditer(text):
427
+ try:
428
+ path = _json.loads(f'"{m.group(1)}"')
429
+ content = _json.loads(f'"{m.group(2)}"')
430
+ objects.append({"path": path, "content": content})
431
+ except _json.JSONDecodeError:
432
+ continue
433
+ return objects