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,295 @@
1
+ """Tier a3 — Forge feature orchestrators.
2
+
3
+ These wire the a1 helpers + a2 composites into the user-facing verbs:
4
+ ``recon`` (scout + emergent overlay), ``cherry`` (cherry-pick),
5
+ ``finalize`` (assimilate + wire + certify), and ``auto`` (the whole chain).
6
+
7
+ Every entry point returns a structured dict so downstream tools can pipe
8
+ output reliably. When ``apply=False`` the pipeline runs in dry-run mode —
9
+ nothing is written to disk except into the project's ``.atomadic-forge/``
10
+ manifest store.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import shutil
18
+ from collections.abc import Callable, Iterable
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .. import __version__
23
+ from ..a0_qk_constants.tier_names import TIER_NAMES
24
+ from ..a1_at_functions.agent_plan_emitter import emit_agent_plan
25
+ from ..a1_at_functions.certify_checks import certify
26
+ from ..a1_at_functions.cherry_pick import select_items
27
+ from ..a1_at_functions.scout_walk import harvest_repo
28
+ from ..a1_at_functions.wire_check import scan_violations
29
+ from ..a2_mo_composites.manifest_store import ManifestStore
30
+
31
+ _STATUS_TEMPLATE = """# Atomadic Forge — Assimilation Status
32
+
33
+ This directory was produced by ``forge auto`` / ``forge finalize``. It is
34
+ **bootstrapped material**, not a finished product.
35
+
36
+ ## What's here
37
+ - 5-tier monadic layout (``a0_qk_constants/`` … ``a4_sy_orchestration/``)
38
+ - Symbols ingested from {source_count} source repo(s)
39
+ - {component_count} components emitted
40
+ - Digest: ``{digest}``
41
+
42
+ ## What's still required before shipping
43
+ 1. **Integration tests** against real inputs — the bootstrap copies code
44
+ verbatim where possible, but cross-symbol semantics (two ``User`` classes,
45
+ two ``authenticate`` flows) need human reconciliation.
46
+ 2. **Runtime configuration** — secrets, environment variables, DB URLs.
47
+ 3. **Observability** — logging, metrics, error reporting.
48
+ 4. **Wire enforcement** — run ``forge wire`` and address any violations.
49
+ 5. **Certification** — ``forge certify`` should hit ≥ 75 before public use.
50
+
51
+ ## Provenance
52
+ Source repos: {source_list}
53
+
54
+ Generated by atomadic-forge {version}.
55
+ """
56
+
57
+
58
+ def _current_version() -> str:
59
+ """Return the source version for generated artifacts."""
60
+ return __version__
61
+
62
+
63
+ def _target_file_for_symbol(target_dir: Path, slug: str, source_file: Path) -> Path:
64
+ """Keep absorbed source in its original language extension."""
65
+ suffix = source_file.suffix.lower() or ".py"
66
+ return target_dir / f"{slug}{suffix}"
67
+
68
+
69
+ def run_recon(
70
+ target: Path,
71
+ *,
72
+ store_manifest: bool = True,
73
+ progress: Callable[[int, int, str], None] | None = None,
74
+ ) -> dict[str, Any]:
75
+ """``forge recon`` — scout walk + persist scout.json under .atomadic-forge/.
76
+
77
+ ``progress`` (optional): forwarded to ``harvest_repo`` for per-file
78
+ reporting on big repos. The CLI builds a stderr reporter; library
79
+ callers can pass any callback or ``None``.
80
+ """
81
+ target = Path(target).resolve()
82
+ report = harvest_repo(target, progress=progress)
83
+ if store_manifest:
84
+ ManifestStore(target).save("scout", report)
85
+ return report
86
+
87
+
88
+ def run_cherry(target: Path, *, names: Iterable[str] | None = None,
89
+ pick_all: bool = False, only_tier: str | None = None,
90
+ store_manifest: bool = True,
91
+ progress: Callable[[int, int, str], None] | None = None,
92
+ ) -> dict[str, Any]:
93
+ """``forge cherry`` — derive a cherry-pick manifest from the latest scout."""
94
+ target = Path(target).resolve()
95
+ store = ManifestStore(target)
96
+ scout = store.load("scout") or harvest_repo(target, progress=progress)
97
+ if not store.load("scout") and store_manifest:
98
+ store.save("scout", scout)
99
+ manifest = select_items(scout, names=names, pick_all=pick_all,
100
+ only_tier=only_tier)
101
+ if store_manifest:
102
+ store.save("cherry", manifest)
103
+ return manifest
104
+
105
+
106
+ def run_finalize(*, target: Path, output: Path, package: str = "absorbed",
107
+ cherry_manifest: dict[str, Any] | None = None,
108
+ apply: bool = False,
109
+ on_conflict: str = "rename") -> dict[str, Any]:
110
+ """``forge finalize`` — assimilate + wire + certify.
111
+
112
+ ``on_conflict`` resolves duplicate qualnames across roots:
113
+ ``rename`` (default) appends a suffix
114
+ ``first`` keep first-seen
115
+ ``last`` keep last-seen
116
+ ``fail`` raise ValueError
117
+ """
118
+ target = Path(target).resolve()
119
+ output = Path(output).resolve()
120
+ if cherry_manifest is None:
121
+ cherry_manifest = run_cherry(target, pick_all=True)
122
+
123
+ pkg_root = output / "src" / package
124
+ if apply:
125
+ for tier in TIER_NAMES:
126
+ (pkg_root / tier).mkdir(parents=True, exist_ok=True)
127
+ (pkg_root / tier / "__init__.py").write_text(
128
+ f'"""{tier}."""\n', encoding="utf-8",
129
+ )
130
+ (pkg_root / "__init__.py").write_text(
131
+ '"""Absorbed package — bootstrapped by atomadic-forge."""\n'
132
+ '__version__ = "0.0.1"\n',
133
+ encoding="utf-8",
134
+ )
135
+
136
+ items = cherry_manifest.get("items", [])
137
+ seen_targets: dict[Path, dict] = {} # target file → originating item
138
+ components_emitted = 0
139
+ tier_dist: dict[str, int] = {}
140
+ skipped: list[dict] = []
141
+
142
+ for it in items:
143
+ qual = it["qualname"]
144
+ tier = it["target_tier"]
145
+ # Find the source file from the scout report
146
+ scout = ManifestStore(target).load("scout") or {}
147
+ sym = next((s for s in scout.get("symbols", []) if s["qualname"] == qual),
148
+ None)
149
+ if sym is None:
150
+ skipped.append({"qualname": qual, "reason": "not in scout"})
151
+ continue
152
+ src_file = target / sym["file"]
153
+ if not src_file.exists():
154
+ skipped.append({"qualname": qual, "reason": "source file gone"})
155
+ continue
156
+ slug = qual.replace(".", "_").lower()
157
+ target_dir = pkg_root / tier
158
+ target_file = _target_file_for_symbol(target_dir, slug, src_file)
159
+
160
+ if target_file in seen_targets:
161
+ if on_conflict == "fail":
162
+ raise ValueError(f"conflict on {target_file}: {qual} vs "
163
+ f"{seen_targets[target_file]['qualname']}")
164
+ if on_conflict == "first":
165
+ skipped.append({"qualname": qual, "reason": "first-wins"})
166
+ continue
167
+ if on_conflict == "last":
168
+ pass # fall through, overwrite
169
+ if on_conflict == "rename":
170
+ target_file = target_dir / f"{slug}__alt{target_file.suffix}"
171
+
172
+ if apply:
173
+ target_dir.mkdir(parents=True, exist_ok=True)
174
+ try:
175
+ shutil.copyfile(src_file, target_file)
176
+ except OSError as exc:
177
+ skipped.append({"qualname": qual, "reason": f"copy failed: {exc}"})
178
+ continue
179
+ seen_targets[target_file] = it
180
+ components_emitted += 1
181
+ tier_dist[tier] = tier_dist.get(tier, 0) + 1
182
+
183
+ digest = hashlib.sha256(
184
+ json.dumps([{**it, "_": "x"} for it in items], sort_keys=True).encode()
185
+ ).hexdigest()[:16]
186
+
187
+ if apply:
188
+ status = _STATUS_TEMPLATE.format(
189
+ source_count=1,
190
+ component_count=components_emitted,
191
+ digest=digest,
192
+ source_list=str(target),
193
+ version=_current_version(),
194
+ )
195
+ (output / "STATUS.md").write_text(status, encoding="utf-8")
196
+
197
+ wire_report = scan_violations(pkg_root) if apply else {
198
+ "verdict": "DRY_RUN", "violation_count": 0, "violations": []
199
+ }
200
+ cert = certify(output, project=package, package=package) if apply else {
201
+ "score": 0, "issues": ["dry-run — re-run with --apply to certify"],
202
+ "schema_version": "atomadic-forge.certify/v1",
203
+ }
204
+
205
+ report = {
206
+ "schema_version": "atomadic-forge.assimilate/v1",
207
+ "target_root": str(output),
208
+ "source_repos": [str(target)],
209
+ "components_emitted": components_emitted,
210
+ "tier_distribution": tier_dist,
211
+ "skipped": skipped,
212
+ "digest": digest,
213
+ "wire": wire_report,
214
+ "certify": cert,
215
+ "applied": apply,
216
+ "on_conflict": on_conflict,
217
+ }
218
+ if apply:
219
+ ManifestStore(output).save("assimilate", report)
220
+ return report
221
+
222
+
223
+ def run_auto(*, target: Path, output: Path, package: str = "absorbed",
224
+ apply: bool = False, on_conflict: str = "rename",
225
+ progress: Callable[[int, int, str], None] | None = None,
226
+ ) -> dict[str, Any]:
227
+ """``forge auto`` — the flagship single-command pipeline.
228
+
229
+ Runs: scout → cherry-pick (all) → finalize (assimilate + wire + certify).
230
+ Always returns a structured report. Set ``apply=True`` to actually write
231
+ files; otherwise it's a deterministic dry-run.
232
+
233
+ ``progress`` (optional): per-file scout-phase reporter. Cherry/finalize
234
+ are typically fast enough to skip; only the scout walk is plumbed.
235
+ """
236
+ scout = run_recon(target, progress=progress)
237
+ cherry = run_cherry(target, pick_all=True)
238
+ final = run_finalize(target=target, output=output, package=package,
239
+ cherry_manifest=cherry, apply=apply,
240
+ on_conflict=on_conflict)
241
+ return {
242
+ "schema_version": "atomadic-forge.auto/v1",
243
+ "scout": {
244
+ "symbol_count": scout["symbol_count"],
245
+ "tier_distribution": scout["tier_distribution"],
246
+ "recommendations": scout["recommendations"],
247
+ },
248
+ "cherry": {"items": len(cherry["items"])},
249
+ "finalize": final,
250
+ "applied": apply,
251
+ }
252
+
253
+
254
+ def run_auto_plan(
255
+ *,
256
+ target: Path,
257
+ goal: str = "improve repo conformance",
258
+ mode: str = "improve",
259
+ package: str | None = None,
260
+ top_n: int = 7,
261
+ ) -> dict[str, Any]:
262
+ """Codex-driven 'observe → propose cards' orchestrator.
263
+
264
+ Runs scout + wire + certify (and, when available, emergent +
265
+ synergy) and emits a single ``agent_plan/v1`` with top-N action
266
+ cards. Does NOT mutate the filesystem — handoff is the agent's
267
+ responsibility.
268
+
269
+ Mode 'improve' (default): the agent operates on the repo at
270
+ ``target`` in-place; certify runs against ``target`` and the
271
+ generated cards reference its layout.
272
+ Mode 'absorb': used by the legacy ``forge auto`` flow when the
273
+ target is a flat repo and the next action is to scaffold a new
274
+ tier-organized output.
275
+
276
+ The returned dict matches AgentPlan exactly (schema_version
277
+ 'atomadic-forge.agent_plan/v1') so MCP clients can round-trip
278
+ it without unwrapping.
279
+ """
280
+ target = Path(target).resolve()
281
+ wire = scan_violations(target, suggest_repairs=True)
282
+ try:
283
+ cert = certify(target, project=target.name, package=package)
284
+ except (OSError, RuntimeError, ValueError):
285
+ cert = None
286
+ # emergent / synergy reports are optional; the emitter degrades
287
+ # gracefully when they're absent. Wiring them in here would
288
+ # require a3→a3 imports (allowed) but would slow the plan call.
289
+ plan = emit_agent_plan(
290
+ project_root=str(target),
291
+ goal=goal, mode=mode,
292
+ wire_report=wire, certify_report=cert,
293
+ package=package, top_n=top_n,
294
+ )
295
+ return plan
@@ -0,0 +1,222 @@
1
+ """Tier a3 — apply ONE card / ALL applyable cards from an agent_plan.
2
+
3
+ Codex's prescription: 'agent picks one card, runs its next_command'.
4
+ This module is the bounded-execution helper Forge supplies so the
5
+ agent doesn't have to reimplement card-routing.
6
+
7
+ Routing strategy:
8
+ * architectural cards with auto_fixable F-codes → forge enforce
9
+ --apply against the card's write_scope; Forge enforce already
10
+ rolls back if violations rise.
11
+ * operational F0050 (docs missing) → write a minimal README.md
12
+ using the card's write_scope[0].
13
+ * operational F0051 (tests missing) → not yet implemented; the
14
+ apply call records 'skipped' with a 'manual_required' reason.
15
+ * synthesis / composition cards → not yet implemented; require
16
+ forge synergy implement / forge emergent synthesize integration.
17
+
18
+ Every apply attempt records a per-card event in the plan store
19
+ (applied / skipped / rolled_back / failed) so the agent (and Lane F
20
+ W26 audit trail) can reason about what was done.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from ..a0_qk_constants.agent_plan_schema import AgentActionCard
28
+ from ..a2_mo_composites.plan_store import PlanStore
29
+ from .forge_enforce import run_enforce
30
+
31
+ _ARCHITECTURAL_AUTO_FIX_FCODES: frozenset[str] = frozenset({
32
+ "F0041", "F0042", "F0043", "F0044", "F0045", "F0046",
33
+ })
34
+
35
+
36
+ def _find_card(plan: dict, card_id: str) -> AgentActionCard | None:
37
+ for card in plan.get("top_actions", []) or []:
38
+ if card.get("id") == card_id:
39
+ return card
40
+ return None
41
+
42
+
43
+ def _apply_architectural_card(
44
+ project_root: Path,
45
+ card: AgentActionCard,
46
+ *,
47
+ apply: bool,
48
+ ) -> dict[str, Any]:
49
+ """Route an F0041..F0046 card through forge enforce.
50
+
51
+ The card's write_scope identifies the file under threat; we run
52
+ enforce against its package root so the rollback-safe orchestrator
53
+ handles it. apply=False -> dry-run; apply=True -> actually move.
54
+ """
55
+ write_scope = card.get("write_scope") or []
56
+ if not write_scope:
57
+ return {"status": "failed",
58
+ "detail": {"reason": "card has empty write_scope"}}
59
+ rel = write_scope[0]
60
+ # Walk up from the file to the nearest tier-organized package.
61
+ file_path = (project_root / rel).resolve()
62
+ candidate = file_path.parent
63
+ pkg_root: Path | None = None
64
+ while candidate != candidate.parent:
65
+ # A package root contains tier directories.
66
+ if any((candidate / t).is_dir() for t in (
67
+ "a0_qk_constants", "a1_at_functions", "a2_mo_composites",
68
+ "a3_og_features", "a4_sy_orchestration",
69
+ )):
70
+ pkg_root = candidate
71
+ break
72
+ candidate = candidate.parent
73
+ if pkg_root is None:
74
+ return {"status": "failed",
75
+ "detail": {"reason": "no tier-organized package root above "
76
+ f"{rel}"}}
77
+ report = run_enforce(pkg_root, apply=apply)
78
+ pre, post = report["pre_violations"], report["post_violations"]
79
+ if not apply:
80
+ return {"status": "dry_run",
81
+ "detail": {"pre_violations": pre, "post_violations": post,
82
+ "plan": report["plan"]}}
83
+ if post < pre:
84
+ return {"status": "applied",
85
+ "detail": {"pre_violations": pre, "post_violations": post,
86
+ "applied": report["applied"]}}
87
+ if post > pre:
88
+ return {"status": "rolled_back",
89
+ "detail": {"pre_violations": pre, "post_violations": post,
90
+ "rollbacks": report["rollbacks"]}}
91
+ return {"status": "noop",
92
+ "detail": {"pre_violations": pre, "post_violations": post}}
93
+
94
+
95
+ def _apply_docs_card(
96
+ project_root: Path,
97
+ card: AgentActionCard,
98
+ *,
99
+ apply: bool,
100
+ ) -> dict[str, Any]:
101
+ """F0050 minimal-README writer.
102
+
103
+ The card's write_scope[0] tells us where the README belongs.
104
+ apply=True writes a one-line H1 + paragraph; apply=False is a
105
+ no-op dry-run.
106
+ """
107
+ rel = (card.get("write_scope") or ["README.md"])[0]
108
+ target = (project_root / rel).resolve()
109
+ if target.exists():
110
+ return {"status": "skipped",
111
+ "detail": {"reason": f"{rel} already exists"}}
112
+ if not apply:
113
+ return {"status": "dry_run",
114
+ "detail": {"would_write": str(target.relative_to(project_root))}}
115
+ pkg = card.get("sample_path") or project_root.name
116
+ body = (
117
+ f"# {pkg}\n\n"
118
+ f"_Stub README written by `forge plan-apply` "
119
+ f"({card.get('id')})._\n\n"
120
+ f"Replace this with a real overview before shipping.\n"
121
+ )
122
+ target.parent.mkdir(parents=True, exist_ok=True)
123
+ target.write_text(body, encoding="utf-8")
124
+ return {"status": "applied",
125
+ "detail": {"written": str(target.relative_to(project_root))}}
126
+
127
+
128
+ def _route_card(
129
+ project_root: Path,
130
+ card: AgentActionCard,
131
+ *,
132
+ apply: bool,
133
+ ) -> dict[str, Any]:
134
+ if not card.get("applyable"):
135
+ return {"status": "skipped",
136
+ "detail": {"reason": "card.applyable=False (review_manually)"}}
137
+ related = set(card.get("related_fcodes") or [])
138
+ if related & _ARCHITECTURAL_AUTO_FIX_FCODES:
139
+ return _apply_architectural_card(project_root, card, apply=apply)
140
+ if "F0050" in related:
141
+ return _apply_docs_card(project_root, card, apply=apply)
142
+ # F0051 / synthesis / composition: not yet implementable.
143
+ return {
144
+ "status": "skipped",
145
+ "detail": {
146
+ "reason": "card kind not yet implementable in plan-apply v1",
147
+ "kind": card.get("kind"),
148
+ "related_fcodes": list(related),
149
+ },
150
+ }
151
+
152
+
153
+ def apply_card(
154
+ project_root: Path,
155
+ plan: dict,
156
+ card_id: str,
157
+ *,
158
+ apply: bool = False,
159
+ ) -> dict[str, Any]:
160
+ """Apply a single card from a plan.
161
+
162
+ Returns a result dict with shape:
163
+ {schema_version, plan_id, card_id, apply, status, detail}
164
+
165
+ Always records the event to the plan store regardless of apply
166
+ flag (dry runs included) so the audit trail is complete.
167
+ """
168
+ project_root = Path(project_root).resolve()
169
+ plan_id = plan.get("id") or "<unsaved>"
170
+ card = _find_card(plan, card_id)
171
+ if card is None:
172
+ return {
173
+ "schema_version": "atomadic-forge.plan_apply/v1",
174
+ "plan_id": plan_id, "card_id": card_id, "apply": apply,
175
+ "status": "failed",
176
+ "detail": {"reason": f"card_id {card_id!r} not in plan"},
177
+ }
178
+ outcome = _route_card(project_root, card, apply=apply)
179
+ PlanStore(project_root).record_card_event(
180
+ plan_id, card_id=card_id,
181
+ status=outcome["status"],
182
+ detail={"apply": apply, **outcome.get("detail", {})},
183
+ )
184
+ return {
185
+ "schema_version": "atomadic-forge.plan_apply/v1",
186
+ "plan_id": plan_id, "card_id": card_id, "apply": apply,
187
+ **outcome,
188
+ }
189
+
190
+
191
+ def apply_all_applyable(
192
+ project_root: Path,
193
+ plan: dict,
194
+ *,
195
+ apply: bool = False,
196
+ ) -> dict[str, Any]:
197
+ """Iterate applyable cards in plan order; collect results.
198
+
199
+ Stops on the first ``rolled_back`` or ``failed`` outcome — the
200
+ agent should inspect that result before proceeding rather than
201
+ cascading further mutations against a now-suspect repo.
202
+ """
203
+ project_root = Path(project_root).resolve()
204
+ plan_id = plan.get("id") or "<unsaved>"
205
+ results: list[dict] = []
206
+ halted = None
207
+ for card in plan.get("top_actions", []) or []:
208
+ if not card.get("applyable"):
209
+ continue
210
+ outcome = apply_card(project_root, plan, card["id"], apply=apply)
211
+ results.append(outcome)
212
+ if outcome["status"] in {"rolled_back", "failed"}:
213
+ halted = outcome["status"]
214
+ break
215
+ return {
216
+ "schema_version": "atomadic-forge.plan_apply_all/v1",
217
+ "plan_id": plan_id, "apply": apply,
218
+ "results": results,
219
+ "halted_on": halted,
220
+ "applied_count": sum(1 for r in results if r["status"] == "applied"),
221
+ "skipped_count": sum(1 for r in results if r["status"] == "skipped"),
222
+ }
@@ -0,0 +1,98 @@
1
+ """Tier a3 — LSP stdio loop wrapping the pure dispatcher.
2
+
3
+ Golden Path Lane D W12 deliverable. The pure dispatcher lives at
4
+ ``a1_at_functions.lsp_protocol``; this module owns the LSP framing
5
+ (Content-Length headers + JSON body) over stdin/stdout, exactly the
6
+ shape every LSP client (VS Code, Neovim, Helix, Sublime, IntelliJ)
7
+ expects on first connect.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import IO
14
+
15
+ from ..a1_at_functions.lsp_protocol import (
16
+ LspState,
17
+ dispatch_request,
18
+ new_state,
19
+ )
20
+
21
+
22
+ def serve_stdio(
23
+ *,
24
+ stdin: IO[bytes] | None = None,
25
+ stdout: IO[bytes] | None = None,
26
+ stderr: IO[str] | None = None,
27
+ ) -> int:
28
+ """Read LSP messages from stdin (Content-Length framed) and write
29
+ responses + notifications to stdout. Exits 0 on clean shutdown."""
30
+ src_in = stdin if stdin is not None else sys.stdin.buffer
31
+ src_out = stdout if stdout is not None else sys.stdout.buffer
32
+ src_err = stderr if stderr is not None else sys.stderr
33
+
34
+ state: LspState = new_state()
35
+ src_err.write("forge-lsp: ready (Content-Length framed JSON-RPC)\n")
36
+ src_err.flush()
37
+
38
+ while True:
39
+ msg = _read_message(src_in)
40
+ if msg is None:
41
+ break # client closed stdin
42
+ try:
43
+ request = json.loads(msg.decode("utf-8"))
44
+ except json.JSONDecodeError:
45
+ _write_message(src_out, {
46
+ "jsonrpc": "2.0", "id": None,
47
+ "error": {"code": -32700, "message": "Parse error"},
48
+ })
49
+ continue
50
+ responses, notifications = dispatch_request(request, state=state)
51
+ for resp in responses:
52
+ _write_message(src_out, resp)
53
+ for note in notifications:
54
+ _write_message(src_out, note)
55
+ if request.get("method") == "exit":
56
+ return 0 if state.get("shutdown_requested") else 1
57
+ return 0
58
+
59
+
60
+ def _read_message(stream: IO[bytes]) -> bytes | None:
61
+ """Read one LSP message frame: headers terminated by \\r\\n\\r\\n
62
+ followed by Content-Length bytes of body."""
63
+ headers: dict[str, str] = {}
64
+ while True:
65
+ line = stream.readline()
66
+ if not line:
67
+ return None # EOF
68
+ s = line.decode("ascii").rstrip("\r\n")
69
+ if s == "":
70
+ break
71
+ if ":" not in s:
72
+ continue
73
+ key, _, value = s.partition(":")
74
+ headers[key.strip().lower()] = value.strip()
75
+ length_str = headers.get("content-length", "0")
76
+ try:
77
+ length = int(length_str)
78
+ except ValueError:
79
+ return None
80
+ if length <= 0:
81
+ return b""
82
+ body = b""
83
+ remaining = length
84
+ while remaining > 0:
85
+ chunk = stream.read(remaining)
86
+ if not chunk:
87
+ return None # EOF mid-message
88
+ body += chunk
89
+ remaining -= len(chunk)
90
+ return body
91
+
92
+
93
+ def _write_message(stream: IO[bytes], payload: dict) -> None:
94
+ body = json.dumps(payload, default=str).encode("utf-8")
95
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
96
+ stream.write(header)
97
+ stream.write(body)
98
+ stream.flush()