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,1284 @@
1
+ """Atomadic Forge — unified CLI.
2
+
3
+ Public verbs:
4
+ forge init — interactive setup wizard (configure LLM, defaults)
5
+ forge auto — flagship: scout + cherry + assimilate + wire + certify
6
+ forge recon — scout walk only (writes scout.json)
7
+ forge cherry — cherry-pick from latest scout (writes cherry.json)
8
+ forge finalize — assimilate + wire + certify (consumes cherry.json)
9
+ forge wire — upward-import scanner over a tier-organized package
10
+ forge certify — score documentation/tests/layout/imports
11
+ forge diff — compare two Forge JSON manifests
12
+ forge config — show / set / test configuration
13
+
14
+ Specialty verbs (advanced):
15
+ forge emergent — symbol-level composition discovery
16
+ forge synergy — feature-pair discovery + auto-implement adapters
17
+ forge commandsmith — auto-register/document/smoke CLI commands
18
+ forge doctor — environment diagnostic
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ import warnings
26
+ from pathlib import Path
27
+ from typing import Annotated
28
+
29
+ import typer
30
+
31
+ from .. import __version__
32
+ from ..a1_at_functions.agent_context_pack import emit_context_pack
33
+ from ..a1_at_functions.agent_summary import (
34
+ render_summary_text,
35
+ summarize_blockers,
36
+ )
37
+ from ..a1_at_functions.card_renderer import render_receipt_card
38
+ from ..a1_at_functions.certify_checks import certify as certify_checks
39
+ from ..a1_at_functions.error_hints import format_hint
40
+ from ..a1_at_functions.local_signer import sign_receipt_local
41
+ from ..a1_at_functions.manifest_diff import diff_manifests
42
+ from ..a1_at_functions.preflight_change import preflight_change
43
+ from ..a1_at_functions.progress_reporter import make_stderr_reporter
44
+ from ..a1_at_functions.receipt_emitter import build_receipt, receipt_to_json
45
+ from ..a1_at_functions.recipes import all_recipes, get_recipe, list_recipes
46
+ from ..a1_at_functions.sbom_emitter import emit_sbom
47
+ from ..a1_at_functions.scout_walk import harvest_repo
48
+ from ..a1_at_functions.sidecar_parser import (
49
+ find_sidecar_for,
50
+ parse_sidecar_file,
51
+ )
52
+ from ..a1_at_functions.sidecar_validator import validate_sidecar
53
+ from ..a1_at_functions.wire_check import scan_violations
54
+ from ..a2_mo_composites.lineage_chain_store import LineageChainStore
55
+ from ..a2_mo_composites.plan_store import PlanStore
56
+ from ..a2_mo_composites.receipt_signer import sign_receipt
57
+ from ..a3_og_features.forge_enforce import run_enforce
58
+ from ..a3_og_features.forge_pipeline import (
59
+ run_auto,
60
+ run_auto_plan,
61
+ run_cherry,
62
+ run_finalize,
63
+ run_recon,
64
+ )
65
+ from ..a3_og_features.forge_plan_apply import apply_all_applyable, apply_card
66
+ from ..a3_og_features.mcp_server import serve_stdio as mcp_serve_stdio
67
+
68
+ # Suppress SyntaxWarnings from third-party code in seed/forged directories.
69
+ warnings.filterwarnings("ignore", category=SyntaxWarning)
70
+
71
+
72
+ def _force_utf8() -> None:
73
+ for s in (sys.stdout, sys.stderr):
74
+ try:
75
+ s.reconfigure(encoding="utf-8", errors="replace")
76
+ except (AttributeError, ValueError):
77
+ pass
78
+
79
+
80
+ def _version_callback(value: bool) -> None:
81
+ if value:
82
+ typer.echo(f"atomadic-forge {__version__}")
83
+ raise typer.Exit()
84
+
85
+
86
+ app = typer.Typer(no_args_is_help=True,
87
+ help="Atomadic Forge — absorb · enforce · emerge.")
88
+
89
+
90
+ @app.callback()
91
+ def _root_callback(
92
+ version: Annotated[bool | None, typer.Option(
93
+ "--version", "-V",
94
+ help="Show the Forge version and exit.",
95
+ callback=_version_callback, is_eager=True,
96
+ )] = None,
97
+ ) -> None:
98
+ """Root callback so --version works at the top level (Codex
99
+ production-hardening: agents shouldn't get a usage error when
100
+ asking for the version)."""
101
+ return None
102
+
103
+
104
+ @app.command("init")
105
+ def init_cmd() -> None:
106
+ """Interactive setup wizard — configure LLM, defaults, and workspace."""
107
+ from atomadic_forge.a3_og_features.setup_wizard import run_wizard
108
+ run_wizard(Path.cwd())
109
+
110
+
111
+ @app.command("auto")
112
+ def auto_cmd(
113
+ target: Annotated[Path, typer.Argument(
114
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
115
+ help="Source repository to absorb.")],
116
+ output: Annotated[Path, typer.Argument(
117
+ file_okay=False, dir_okay=True, resolve_path=True,
118
+ help="Destination root for the materialized tier tree.")],
119
+ package: Annotated[str, typer.Option("--package",
120
+ help="Python package name to materialize under output/src/.")] = "absorbed",
121
+ apply: Annotated[bool, typer.Option("--apply",
122
+ help="Actually write files. Default is dry-run.")] = False,
123
+ on_conflict: Annotated[str, typer.Option("--on-conflict",
124
+ help="rename | first | last | fail")] = "rename",
125
+ json_out: Annotated[bool, typer.Option("--json")] = False,
126
+ progress: Annotated[bool | None, typer.Option(
127
+ "--progress/--no-progress",
128
+ help="Emit per-file scout progress to stderr. Default: auto "
129
+ "(on when stderr is a TTY, off in CI / pipes / --json).")] = None,
130
+ seed_determinism: Annotated[int | None, typer.Option(
131
+ "--seed-determinism",
132
+ help="Record a fixed RNG seed in the receipt for reproducibility audits (Lane G).",
133
+ )] = None,
134
+ ) -> None:
135
+ """Flagship: scout → cherry-pick → assimilate → wire → certify in one shot."""
136
+ output.mkdir(parents=True, exist_ok=True)
137
+ reporter = make_stderr_reporter(
138
+ enabled=False if json_out else progress, label="scout")
139
+ report = run_auto(target=target, output=output, package=package,
140
+ apply=apply, on_conflict=on_conflict,
141
+ progress=reporter)
142
+ if seed_determinism is not None:
143
+ report.setdefault("extra", {})["seed_determinism"] = seed_determinism
144
+ if json_out:
145
+ typer.echo(json.dumps(report, indent=2, default=str))
146
+ return
147
+ typer.echo(f"\nAtomadic Forge — auto pipeline ({'APPLY' if apply else 'DRY-RUN'})")
148
+ typer.echo("-" * 60)
149
+ typer.echo(f" source: {target}")
150
+ typer.echo(f" destination: {output}/{package}")
151
+ typer.echo(f" symbols: {report['scout']['symbol_count']}")
152
+ typer.echo(f" cherry-picked: {report['cherry']['items']}")
153
+ typer.echo(f" components: {report['finalize']['components_emitted']}")
154
+ typer.echo(f" tier_dist: {report['finalize']['tier_distribution']}")
155
+ typer.echo(f" wire verdict: {report['finalize']['wire'].get('verdict')}")
156
+ typer.echo(f" certify score: {report['finalize']['certify'].get('score', 0)}/100")
157
+ if not apply:
158
+ typer.echo("\n (re-run with --apply to write the materialized tree)")
159
+
160
+
161
+ @app.command("recon")
162
+ def recon_cmd(
163
+ target: Annotated[Path, typer.Argument(
164
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)],
165
+ json_out: Annotated[bool, typer.Option("--json")] = False,
166
+ progress: Annotated[bool | None, typer.Option(
167
+ "--progress/--no-progress",
168
+ help="Emit per-file scout progress to stderr. Default: auto "
169
+ "(on when stderr is a TTY, off in CI / pipes / --json).")] = None,
170
+ ) -> None:
171
+ """Walk a repo, classify every public symbol, surface tier/effect distributions."""
172
+ reporter = make_stderr_reporter(
173
+ enabled=False if json_out else progress, label="scout")
174
+ report = run_recon(target, progress=reporter)
175
+ if json_out:
176
+ typer.echo(json.dumps(report, indent=2, default=str))
177
+ return
178
+ typer.echo(f"\nRecon: {target}")
179
+ typer.echo("-" * 60)
180
+ typer.echo(f" python files: {report['python_file_count']}")
181
+ typer.echo(f" javascript files: {report.get('javascript_file_count', 0)}")
182
+ typer.echo(f" typescript files: {report.get('typescript_file_count', 0)}")
183
+ primary = report.get("primary_language")
184
+ if primary:
185
+ typer.echo(f" primary language: {primary}")
186
+ typer.echo(f" symbols: {report['symbol_count']}")
187
+ typer.echo(f" tier dist: {report['tier_distribution']}")
188
+ typer.echo(f" effect dist: {report['effect_distribution']}")
189
+ if report["recommendations"]:
190
+ typer.echo(" recommendations:")
191
+ for r in report["recommendations"]:
192
+ typer.echo(f" - {r}")
193
+
194
+
195
+ @app.command("cherry")
196
+ def cherry_cmd(
197
+ target: Annotated[Path, typer.Argument(
198
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)],
199
+ pick: Annotated[list[str] | None, typer.Option("--pick",
200
+ help="Explicit qualnames. Pass --pick all to take everything.")] = None,
201
+ only_tier: Annotated[str | None, typer.Option("--only-tier",
202
+ help="Restrict to one tier guess.")] = None,
203
+ json_out: Annotated[bool, typer.Option("--json")] = False,
204
+ ) -> None:
205
+ """Build a cherry-pick manifest from the latest scout report."""
206
+ pick_all = pick == ["all"]
207
+ names = None if (pick_all or not pick) else pick
208
+ manifest = run_cherry(target, names=names, pick_all=pick_all,
209
+ only_tier=only_tier)
210
+ if json_out:
211
+ typer.echo(json.dumps(manifest, indent=2, default=str))
212
+ return
213
+ typer.echo("\nCherry-pick manifest written to .atomadic-forge/cherry.json")
214
+ typer.echo(f" selected: {len(manifest['items'])}")
215
+
216
+
217
+ @app.command("finalize")
218
+ def finalize_cmd(
219
+ target: Annotated[Path, typer.Argument(
220
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)],
221
+ output: Annotated[Path, typer.Argument(
222
+ file_okay=False, dir_okay=True, resolve_path=True)],
223
+ package: Annotated[str, typer.Option("--package")] = "absorbed",
224
+ apply: Annotated[bool, typer.Option("--apply")] = False,
225
+ on_conflict: Annotated[str, typer.Option("--on-conflict")] = "rename",
226
+ json_out: Annotated[bool, typer.Option("--json")] = False,
227
+ ) -> None:
228
+ """Assimilate cherry-picked symbols + run wire + certify."""
229
+ output.mkdir(parents=True, exist_ok=True)
230
+ report = run_finalize(target=target, output=output, package=package,
231
+ apply=apply, on_conflict=on_conflict)
232
+ if json_out:
233
+ typer.echo(json.dumps(report, indent=2, default=str))
234
+ return
235
+ typer.echo(f"\nFinalize ({'APPLY' if apply else 'DRY-RUN'}): {output}/{package}")
236
+ typer.echo(f" components: {report['components_emitted']}")
237
+ typer.echo(f" tier dist: {report['tier_distribution']}")
238
+ typer.echo(f" wire: {report['wire'].get('verdict')}")
239
+ typer.echo(f" certify: {report['certify'].get('score', 0)}/100")
240
+
241
+
242
+ @app.command("wire")
243
+ def wire_cmd(
244
+ source: Annotated[Path, typer.Argument(
245
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
246
+ help="Tier-organized package root.")],
247
+ json_out: Annotated[bool, typer.Option("--json")] = False,
248
+ fail_on_violations: Annotated[bool, typer.Option(
249
+ "--fail-on-violations",
250
+ help="Exit 1 when any upward-import violations are found "
251
+ "(for use in CI gates).")] = False,
252
+ suggest_repairs: Annotated[bool, typer.Option(
253
+ "--suggest-repairs",
254
+ help="For every violation, propose a concrete mechanical fix "
255
+ "(target tier, sketch shell command). Heuristic, for review "
256
+ "before applying.")] = False,
257
+ summary: Annotated[bool, typer.Option(
258
+ "--summary",
259
+ help="Emit ONLY the compact agent-native blocker summary (top "
260
+ "5 actionable items + next-command) instead of the full "
261
+ "violation list. Pairs with --json for machine consumers.")] = False,
262
+ ) -> None:
263
+ """Scan a tier tree for upward-import violations."""
264
+ report = scan_violations(source, suggest_repairs=suggest_repairs)
265
+ has_violations = report["violation_count"] > 0
266
+ if summary:
267
+ s = summarize_blockers(wire_report=report, package_root=str(source))
268
+ if json_out:
269
+ typer.echo(json.dumps(s, indent=2, default=str))
270
+ else:
271
+ typer.echo(render_summary_text(s))
272
+ if fail_on_violations and has_violations:
273
+ raise typer.Exit(code=1)
274
+ return
275
+ if json_out:
276
+ typer.echo(json.dumps(report, indent=2, default=str))
277
+ if fail_on_violations and has_violations:
278
+ raise typer.Exit(code=1)
279
+ return
280
+ typer.echo(f"\nWire scan: {source}")
281
+ typer.echo(f" verdict: {report['verdict']}")
282
+ typer.echo(f" violations: {report['violation_count']}")
283
+ if suggest_repairs:
284
+ typer.echo(f" auto-fixable: {report['auto_fixable']}/{report['violation_count']}")
285
+ for v in report["violations"][:10]:
286
+ fcode = v.get("f_code", "")
287
+ prefix = f"[{fcode}] " if fcode else ""
288
+ line = (f" - {prefix}{v['file']}: "
289
+ f"{v['from_tier']} ⟵ {v['to_tier']}.{v['imported']}")
290
+ typer.echo(line)
291
+ if suggest_repairs and v.get("proposed_destination"):
292
+ typer.echo(f" → move to {v['proposed_destination']}/ "
293
+ f"({v.get('proposed_action', 'review_manually')})")
294
+ if suggest_repairs and report.get("repair_suggestions"):
295
+ typer.echo("\n Repair plan (one entry per file):")
296
+ for s in report["repair_suggestions"][:10]:
297
+ dest = s.get("proposed_destination") or "(review manually)"
298
+ typer.echo(
299
+ f" - {s['file']}: {s['violation_count']} violation(s) "
300
+ f"→ {dest}"
301
+ )
302
+ if fail_on_violations and has_violations:
303
+ typer.echo(" gate: FAIL (--fail-on-violations set)")
304
+ raise typer.Exit(code=1)
305
+ if has_violations and not suggest_repairs and not json_out:
306
+ typer.echo(
307
+ "\n"
308
+ + format_hint("wire_fail_with_violations",
309
+ count=report["violation_count"], path=source),
310
+ err=True,
311
+ )
312
+
313
+
314
+ @app.command("plan")
315
+ def plan_cmd(
316
+ target: Annotated[Path, typer.Argument(
317
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
318
+ help="Repo to inspect. The agent operates on it in-place "
319
+ "(mode='improve', default) — Forge does NOT mutate.")],
320
+ goal: Annotated[str, typer.Option("--goal",
321
+ help="One-line description of what the agent is trying to achieve. "
322
+ "Echoed back in the plan envelope.")] = "improve repo conformance",
323
+ mode: Annotated[str, typer.Option("--mode",
324
+ help="improve = operate in-place; absorb = scaffold a new "
325
+ "tier-organized package from a flat repo.")] = "improve",
326
+ package: Annotated[str | None, typer.Option("--package",
327
+ help="Forwarded to forge certify when relevant.")] = None,
328
+ top_n: Annotated[int, typer.Option("--top",
329
+ help="Cap the action card list at N (action_count remains "
330
+ "the full count).")] = 7,
331
+ json_out: Annotated[bool, typer.Option("--json")] = False,
332
+ save: Annotated[bool, typer.Option(
333
+ "--save",
334
+ help="Persist the plan under .atomadic-forge/plans/<id>.json "
335
+ "so it can be addressed by `forge plan-step` / "
336
+ "`forge plan-apply`.")] = False,
337
+ ) -> None:
338
+ """Codex-driven 'next best action card' generator (agent_plan/v1).
339
+
340
+ Runs scout + wire + certify (and the optional emergent / synergy
341
+ overlays when scans are present), ranks blockers and opportunities,
342
+ and emits one ordered ``agent_plan/v1`` document. Each action card
343
+ carries:
344
+
345
+ id, kind, title, why, write_scope, risk, applyable,
346
+ commands, related_fcodes, next_command, sample_path,
347
+ score_delta_estimate
348
+
349
+ The active agent inspects the cards, picks one (typically the
350
+ first applyable), and runs its ``next_command``. Forge does NOT
351
+ mutate the repo from this verb — the bounded write-step is
352
+ delegated to verbs the cards reference (forge enforce, forge
353
+ auto, forge synergy implement, forge emergent synthesize, etc.).
354
+ """
355
+ plan = run_auto_plan(target=target, goal=goal, mode=mode,
356
+ package=package, top_n=top_n)
357
+ plan_id = None
358
+ if save:
359
+ plan_id = PlanStore(target).save_plan(plan)
360
+ plan["id"] = plan_id # so JSON / human output reflect the id
361
+ if json_out:
362
+ typer.echo(json.dumps(plan, indent=2, default=str))
363
+ return
364
+ typer.echo(f"\nForge plan ({plan['mode']}): {target}")
365
+ typer.echo("-" * 60)
366
+ typer.echo(f" goal: {plan['goal']}")
367
+ typer.echo(f" verdict: {plan['verdict']}")
368
+ typer.echo(f" actions: {plan['action_count']} "
369
+ f"({plan['applyable_count']} applyable)")
370
+ typer.echo("")
371
+ for i, card in enumerate(plan["top_actions"], 1):
372
+ tag = "AUTO" if card.get("applyable") else "REVIEW"
373
+ risk = card.get("risk", "?")
374
+ typer.echo(f" {i}. [{tag}] [{risk}] [{card.get('kind', '?')}]"
375
+ f" {card.get('title', '')}")
376
+ typer.echo(f" id: {card.get('id', '')}")
377
+ why = card.get("why", "").strip()
378
+ if why:
379
+ typer.echo(f" why: {why[:120]}")
380
+ nc = card.get("next_command", "").strip()
381
+ if nc:
382
+ typer.echo(f" next: {nc[:120]}")
383
+ typer.echo("")
384
+ typer.echo(f" NEXT: {plan.get('next_command', '').strip()[:120]}")
385
+ if plan_id:
386
+ typer.echo(f" saved: plan_id={plan_id}")
387
+ typer.echo(f" forge plan-show {plan_id} --project {target}")
388
+
389
+
390
+ @app.command("context-pack")
391
+ def context_pack_cmd(
392
+ target: Annotated[Path, typer.Argument(
393
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
394
+ help="Project root.")] = Path("."),
395
+ json_out: Annotated[bool, typer.Option("--json")] = False,
396
+ ) -> None:
397
+ """Codex 'Copilot's Copilot' #1: first-call context bundle.
398
+
399
+ Returns repo purpose + tier law + tier map + blockers + best
400
+ next action + test commands + release gate + risky files +
401
+ recent lineage in one read. The single tool every coding agent
402
+ should call on first connect.
403
+ """
404
+ target = Path(target).resolve()
405
+ try:
406
+ scout = harvest_repo(target)
407
+ except (OSError, ValueError):
408
+ scout = None
409
+ try:
410
+ wire = scan_violations(target)
411
+ except (OSError, ValueError):
412
+ wire = None
413
+ try:
414
+ cert = certify_checks(target, project=target.name)
415
+ except (OSError, RuntimeError, ValueError):
416
+ cert = None
417
+ pack = emit_context_pack(
418
+ project_root=target,
419
+ scout_report=scout, wire_report=wire, certify_report=cert,
420
+ )
421
+ if json_out:
422
+ typer.echo(json.dumps(pack, indent=2, default=str))
423
+ return
424
+ typer.echo(f"\nForge context-pack: {target}")
425
+ typer.echo("-" * 60)
426
+ typer.echo(f" purpose: {pack['repo_purpose'][:200]}")
427
+ typer.echo(f" language: {pack['primary_language']}")
428
+ typer.echo(f" tiers: {pack['tier_map']}")
429
+ bs = pack["blockers_summary"]
430
+ typer.echo(f" verdict: {bs.get('verdict', '?')} "
431
+ f"({bs.get('blocker_count', 0)} blocker(s))")
432
+ if pack.get("best_next_action"):
433
+ n = pack["best_next_action"]
434
+ typer.echo(f" best next: {n.get('title', n.get('id', '?'))}")
435
+ nc = n.get("next_command", "").strip()
436
+ if nc:
437
+ typer.echo(f" {nc[:140]}")
438
+ typer.echo(f" tests: {' | '.join(pack['test_commands'][:3])}")
439
+ typer.echo(f" gate: {' && '.join(pack['release_gate'])}")
440
+ if pack["risky_files"]:
441
+ typer.echo(" risky files (most-edited):")
442
+ for f in pack["risky_files"][:5]:
443
+ typer.echo(f" - {f['path']} ({f['edit_count']}x)")
444
+
445
+
446
+ @app.command("preflight")
447
+ def preflight_cmd(
448
+ intent: Annotated[str, typer.Argument(
449
+ help="One-line description of what the agent intends to do.")],
450
+ files: Annotated[list[str], typer.Argument(
451
+ help="Proposed file paths the agent plans to write/modify.")],
452
+ project: Annotated[Path, typer.Option(
453
+ "--project",
454
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)] = Path("."),
455
+ scope_threshold: Annotated[int, typer.Option(
456
+ "--scope-threshold",
457
+ help="Warn when more than N files are in the write scope.")] = 8,
458
+ json_out: Annotated[bool, typer.Option("--json")] = False,
459
+ ) -> None:
460
+ """Codex 'Copilot's Copilot' #2: pre-edit guardrail.
461
+
462
+ For each proposed file, returns the detected tier, forbidden
463
+ imports, likely-affected tests, and sibling files to read first.
464
+ Surfaces 'write_scope too broad' before the agent commits to a
465
+ fragile multi-file patch.
466
+ """
467
+ report = preflight_change(
468
+ intent=intent, proposed_files=list(files),
469
+ project_root=project, scope_threshold=scope_threshold,
470
+ )
471
+ if json_out:
472
+ typer.echo(json.dumps(report, indent=2, default=str))
473
+ if report["write_scope_too_broad"]:
474
+ raise typer.Exit(code=1)
475
+ return
476
+ typer.echo(f"\nForge preflight ({len(files)} file(s))")
477
+ typer.echo("-" * 60)
478
+ typer.echo(f" intent: {intent[:200]}")
479
+ if report["write_scope_too_broad"]:
480
+ typer.echo(f" ⚠ write_scope: {report['write_scope_size']} files "
481
+ f"(> {report['write_scope_threshold']} threshold)")
482
+ for f in report["proposed_files"]:
483
+ tier = f.get("detected_tier") or "(none)"
484
+ typer.echo(f"\n {f['path']} [{tier}]")
485
+ if f.get("forbidden_imports"):
486
+ typer.echo(f" forbidden: {f['forbidden_imports']}")
487
+ if f.get("likely_tests"):
488
+ typer.echo(f" tests: {f['likely_tests'][:3]}")
489
+ if f.get("siblings_to_read"):
490
+ typer.echo(f" siblings: {f['siblings_to_read'][:3]}")
491
+ for note in f.get("notes", []):
492
+ typer.echo(f" note: {note}")
493
+ for note in report.get("overall_notes", []):
494
+ typer.echo(f"\n ! {note}")
495
+ if report["write_scope_too_broad"]:
496
+ raise typer.Exit(code=1)
497
+
498
+
499
+ sidecar_app = typer.Typer(
500
+ no_args_is_help=True,
501
+ help=".forge sidecar tools: parse + validate the per-symbol "
502
+ "effect / compose_with / proves contract (Lane D W8 / W11).",
503
+ )
504
+
505
+
506
+ @sidecar_app.command("parse")
507
+ def sidecar_parse_cmd(
508
+ sidecar_file: Annotated[Path, typer.Argument(
509
+ exists=True, file_okay=True, dir_okay=False, resolve_path=True)],
510
+ json_out: Annotated[bool, typer.Option("--json")] = False,
511
+ ) -> None:
512
+ """Parse a .forge sidecar YAML file."""
513
+ rep = parse_sidecar_file(sidecar_file)
514
+ if json_out:
515
+ typer.echo(json.dumps(rep, indent=2, default=str))
516
+ if rep["errors"]:
517
+ raise typer.Exit(code=1)
518
+ return
519
+ if rep["errors"]:
520
+ typer.echo(f"\nSidecar parse FAILED: {sidecar_file}")
521
+ for e in rep["errors"]:
522
+ typer.echo(f" ! {e}")
523
+ raise typer.Exit(code=1)
524
+ sc = rep["sidecar"]
525
+ typer.echo(f"\nSidecar OK: {sc['target']} "
526
+ f"({len(sc['symbols'])} symbol(s))")
527
+ for w in rep.get("warnings", []):
528
+ typer.echo(f" ⚠ {w}")
529
+ for s in sc["symbols"]:
530
+ typer.echo(f" - {s.get('name')} effect={s.get('effect')} "
531
+ f"tier={s.get('tier', '?')}")
532
+
533
+
534
+ @sidecar_app.command("validate")
535
+ def sidecar_validate_cmd(
536
+ source_file: Annotated[Path, typer.Argument(
537
+ exists=True, file_okay=True, dir_okay=False, resolve_path=True,
538
+ help="Source file to validate against (e.g. src/pkg/auth.py).")],
539
+ json_out: Annotated[bool, typer.Option("--json")] = False,
540
+ ) -> None:
541
+ """Cross-check a .forge sidecar against its source AST.
542
+
543
+ Looks for ``<source>.forge`` next to the source file. Reports
544
+ drift across S0000–S0007 finding classes; exits 1 on FAIL.
545
+ """
546
+ sidecar_path = find_sidecar_for(source_file)
547
+ parse = parse_sidecar_file(sidecar_path)
548
+ if parse["errors"]:
549
+ typer.echo(f"\nCould not parse sidecar at {sidecar_path}:")
550
+ for e in parse["errors"]:
551
+ typer.echo(f" ! {e}")
552
+ raise typer.Exit(code=1)
553
+ try:
554
+ source_text = source_file.read_text(encoding="utf-8")
555
+ except OSError as exc:
556
+ raise typer.BadParameter(f"could not read {source_file}: {exc}") from exc
557
+ rep = validate_sidecar(
558
+ parse["sidecar"], source_text=source_text, source_path=source_file,
559
+ )
560
+ if json_out:
561
+ typer.echo(json.dumps(rep, indent=2, default=str))
562
+ if rep["verdict"] == "FAIL":
563
+ raise typer.Exit(code=1)
564
+ return
565
+ typer.echo(f"\nSidecar validate: {source_file}")
566
+ typer.echo(f" verdict: {rep['verdict']}")
567
+ typer.echo(f" findings: {rep['finding_count']}")
568
+ for f in rep["findings"]:
569
+ sev = f.get("severity", "?").upper()
570
+ typer.echo(f" - [{f.get('code')}] [{sev:<5}] "
571
+ f"{f.get('symbol', '?')}: {f.get('message', '')}")
572
+ if rep["verdict"] == "FAIL":
573
+ raise typer.Exit(code=1)
574
+
575
+
576
+ app.add_typer(sidecar_app, name="sidecar",
577
+ help="Parse / validate .forge sidecar files.")
578
+
579
+
580
+ @app.command("recipes")
581
+ def recipes_cmd(
582
+ name: Annotated[str | None, typer.Argument(
583
+ help="Recipe name to show; omit to list all.")] = None,
584
+ json_out: Annotated[bool, typer.Option("--json")] = False,
585
+ ) -> None:
586
+ """Codex #12: golden-path recipes catalogue.
587
+
588
+ With no argument, lists every recipe. With a name, shows that
589
+ recipe's checklist + file_scope_hints + validation_gate. Same
590
+ surface as the MCP tools list_recipes / get_recipe.
591
+ """
592
+ if name is None:
593
+ names = list_recipes()
594
+ catalogue = all_recipes()
595
+ if json_out:
596
+ typer.echo(json.dumps(
597
+ {"schema_version": "atomadic-forge.recipe.list/v1",
598
+ "recipes": names,
599
+ "catalogue": {n: r["description"] for n, r in catalogue.items()}},
600
+ indent=2, default=str))
601
+ return
602
+ typer.echo("\nForge — golden-path recipes")
603
+ typer.echo("-" * 60)
604
+ for n in names:
605
+ typer.echo(f" {n:<22} {catalogue[n]['description'][:60]}")
606
+ typer.echo("\n forge recipes <name> — show one recipe")
607
+ return
608
+ recipe = get_recipe(name)
609
+ if recipe is None:
610
+ raise typer.BadParameter(
611
+ f"unknown recipe: {name!r}. "
612
+ f"Available: {', '.join(list_recipes())}"
613
+ )
614
+ if json_out:
615
+ typer.echo(json.dumps(recipe, indent=2, default=str))
616
+ return
617
+ typer.echo(f"\nForge recipe: {recipe['name']}")
618
+ typer.echo("-" * 60)
619
+ typer.echo(f" {recipe['description']}\n")
620
+ typer.echo(" Checklist:")
621
+ for i, step in enumerate(recipe.get("checklist", []), 1):
622
+ typer.echo(f" {i}. {step}")
623
+ if recipe.get("file_scope_hints"):
624
+ typer.echo("\n File scope:")
625
+ for f in recipe["file_scope_hints"]:
626
+ typer.echo(f" - {f}")
627
+ if recipe.get("validation_gate"):
628
+ typer.echo("\n Validation gate:")
629
+ for cmd in recipe["validation_gate"]:
630
+ typer.echo(f" $ {cmd}")
631
+ if recipe.get("notes"):
632
+ typer.echo("\n Notes:")
633
+ for n in recipe["notes"]:
634
+ typer.echo(f" {n}")
635
+
636
+
637
+ @app.command("plan-list")
638
+ def plan_list_cmd(
639
+ project: Annotated[Path, typer.Option(
640
+ "--project",
641
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
642
+ help="Project root the plans are stored under.")] = Path("."),
643
+ json_out: Annotated[bool, typer.Option("--json")] = False,
644
+ ) -> None:
645
+ """List saved agent_plan/v1 documents for ``--project``."""
646
+ plans = PlanStore(project).list_plans()
647
+ if json_out:
648
+ typer.echo(json.dumps(
649
+ {"schema_version": "atomadic-forge.plan.list/v1",
650
+ "project": str(project),
651
+ "plans": plans}, indent=2, default=str))
652
+ return
653
+ if not plans:
654
+ typer.echo(f"\nNo saved plans under {project}/.atomadic-forge/plans/")
655
+ typer.echo("Run `forge plan <target> --save` to persist one.")
656
+ return
657
+ typer.echo(f"\nForge — saved plans under {project}/.atomadic-forge/plans/")
658
+ typer.echo("-" * 60)
659
+ for p in plans:
660
+ typer.echo(f" {p['plan_id']} {p['verdict']:<6} "
661
+ f"actions={p['action_count']} "
662
+ f"applyable={p['applyable_count']} "
663
+ f"saved={p['saved_at_utc']}")
664
+ typer.echo(f" goal: {p['goal'][:80]}")
665
+
666
+
667
+ @app.command("plan-show")
668
+ def plan_show_cmd(
669
+ plan_id: Annotated[str, typer.Argument(help="Plan id from plan-list.")],
670
+ project: Annotated[Path, typer.Option(
671
+ "--project",
672
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)] = Path("."),
673
+ json_out: Annotated[bool, typer.Option("--json")] = False,
674
+ ) -> None:
675
+ """Pretty-print a saved agent_plan/v1 by id."""
676
+ store = PlanStore(project)
677
+ plan = store.load_plan(plan_id)
678
+ if plan is None:
679
+ raise typer.BadParameter(
680
+ f"plan id {plan_id!r} not found under "
681
+ f"{project}/.atomadic-forge/plans/"
682
+ )
683
+ state = store.load_state(plan_id) or {}
684
+ if json_out:
685
+ typer.echo(json.dumps({"plan": plan, "state": state},
686
+ indent=2, default=str))
687
+ return
688
+ typer.echo(f"\nForge plan {plan_id} ({plan.get('verdict', '?')})")
689
+ typer.echo("-" * 60)
690
+ typer.echo(f" goal: {plan.get('goal', '')}")
691
+ typer.echo(f" mode: {plan.get('mode', '')}")
692
+ typer.echo(f" actions: {plan.get('action_count', 0)} "
693
+ f"({plan.get('applyable_count', 0)} applyable)")
694
+ for i, card in enumerate(plan.get("top_actions", []), 1):
695
+ cid = card.get("id", "?")
696
+ status = store.card_status(plan_id, cid)
697
+ tag = "AUTO" if card.get("applyable") else "REVIEW"
698
+ typer.echo(f" {i}. [{tag}] [{status}] {card.get('title', '')}")
699
+ typer.echo(f" id: {cid}")
700
+
701
+
702
+ @app.command("plan-step")
703
+ def plan_step_cmd(
704
+ plan_id: Annotated[str, typer.Argument(help="Plan id from plan-list.")],
705
+ card_id: Annotated[str, typer.Argument(help="Card id from plan-show.")],
706
+ project: Annotated[Path, typer.Option(
707
+ "--project",
708
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)] = Path("."),
709
+ apply: Annotated[bool, typer.Option(
710
+ "--apply",
711
+ help="Actually execute the card. Default is dry-run.")] = False,
712
+ json_out: Annotated[bool, typer.Option("--json")] = False,
713
+ ) -> None:
714
+ """Apply ONE card from a saved plan (Codex's bounded-step verb)."""
715
+ store = PlanStore(project)
716
+ plan = store.load_plan(plan_id)
717
+ if plan is None:
718
+ raise typer.BadParameter(f"plan id {plan_id!r} not found")
719
+ result = apply_card(project, plan, card_id, apply=apply)
720
+ if json_out:
721
+ typer.echo(json.dumps(result, indent=2, default=str))
722
+ if result["status"] in {"failed", "rolled_back"}:
723
+ raise typer.Exit(code=1)
724
+ return
725
+ typer.echo(f"\nForge plan-step ({'APPLY' if apply else 'DRY-RUN'}) "
726
+ f"{plan_id}/{card_id}")
727
+ typer.echo(f" status: {result['status']}")
728
+ detail = result.get("detail") or {}
729
+ for key, value in detail.items():
730
+ if isinstance(value, dict | list):
731
+ value = json.dumps(value, default=str)[:120]
732
+ typer.echo(f" {key}: {value}")
733
+ if result["status"] in {"failed", "rolled_back"}:
734
+ raise typer.Exit(code=1)
735
+
736
+
737
+ @app.command("plan-apply")
738
+ def plan_apply_cmd(
739
+ plan_id: Annotated[str, typer.Argument(help="Plan id from plan-list.")],
740
+ project: Annotated[Path, typer.Option(
741
+ "--project",
742
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)] = Path("."),
743
+ apply: Annotated[bool, typer.Option(
744
+ "--apply",
745
+ help="Actually execute every applyable card. Default is dry-run.")] = False,
746
+ json_out: Annotated[bool, typer.Option("--json")] = False,
747
+ ) -> None:
748
+ """Apply ALL applyable cards from a saved plan in order.
749
+
750
+ Halts on the first ``rolled_back`` or ``failed`` outcome so the
751
+ agent inspects before cascading further mutations.
752
+ """
753
+ store = PlanStore(project)
754
+ plan = store.load_plan(plan_id)
755
+ if plan is None:
756
+ raise typer.BadParameter(f"plan id {plan_id!r} not found")
757
+ result = apply_all_applyable(project, plan, apply=apply)
758
+ if json_out:
759
+ typer.echo(json.dumps(result, indent=2, default=str))
760
+ if result.get("halted_on") in {"failed", "rolled_back"}:
761
+ raise typer.Exit(code=1)
762
+ return
763
+ typer.echo(f"\nForge plan-apply ({'APPLY' if apply else 'DRY-RUN'}) "
764
+ f"{plan_id}")
765
+ typer.echo("-" * 60)
766
+ typer.echo(f" applied: {result['applied_count']} "
767
+ f"skipped: {result['skipped_count']} "
768
+ f"halted_on: {result.get('halted_on') or '-'}")
769
+ for r in result["results"]:
770
+ typer.echo(f" - [{r['status']:<12s}] {r['card_id']}")
771
+ if result.get("halted_on") in {"failed", "rolled_back"}:
772
+ raise typer.Exit(code=1)
773
+
774
+
775
+ @app.command("enforce")
776
+ def enforce_cmd(
777
+ package_root: Annotated[Path, typer.Argument(
778
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
779
+ help="Tier-organized package root.")],
780
+ apply: Annotated[bool, typer.Option(
781
+ "--apply",
782
+ help="Actually execute file moves. Default is dry-run.")] = False,
783
+ json_out: Annotated[bool, typer.Option("--json")] = False,
784
+ ) -> None:
785
+ """Plan (and optionally apply) mechanical fixes for wire violations.
786
+
787
+ Routes by F-code (Lane A W5); rolls back any fix that increases the
788
+ violation count. Default mode is dry-run — pass --apply to execute.
789
+ """
790
+ report = run_enforce(package_root, apply=apply)
791
+ if json_out:
792
+ typer.echo(json.dumps(report, indent=2, default=str))
793
+ if apply and report["post_violations"] > report["pre_violations"]:
794
+ raise typer.Exit(code=1)
795
+ return
796
+ plan = report["plan"]
797
+ typer.echo(f"\nForge enforce ({'APPLY' if apply else 'DRY-RUN'}): "
798
+ f"{package_root}")
799
+ typer.echo("-" * 60)
800
+ typer.echo(f" pre violations: {report['pre_violations']}")
801
+ typer.echo(f" post violations: {report['post_violations']}")
802
+ typer.echo(f" actions: {plan['action_count']} "
803
+ f"({plan['auto_apply_count']} auto, "
804
+ f"{plan['review_count']} review)")
805
+ if plan["by_fcode"]:
806
+ typer.echo(f" by F-code: {plan['by_fcode']}")
807
+ if not apply and plan["action_count"] > 0:
808
+ typer.echo("\n Planned moves:")
809
+ for action in plan["actions"][:10]:
810
+ tag = "AUTO" if action.get("auto_apply") else "REVIEW"
811
+ if action.get("dest"):
812
+ typer.echo(
813
+ f" [{tag}] [{action['f_code']}] "
814
+ f"{action['src']} → {action['dest']}"
815
+ )
816
+ else:
817
+ typer.echo(
818
+ f" [{tag}] [{action['f_code']}] "
819
+ f"{action['src']} → (manual review)"
820
+ )
821
+ for w in action.get("warnings", [])[:2]:
822
+ typer.echo(f" ! {w}")
823
+ typer.echo("\n (re-run with --apply to execute the AUTO actions)")
824
+ if apply:
825
+ typer.echo("\n Apply results:")
826
+ for entry in report["applied"]:
827
+ a = entry["action"]
828
+ typer.echo(f" [{entry['status'].upper():12s}] "
829
+ f"[{a['f_code']}] {a['src']}")
830
+ if report["rollbacks"]:
831
+ typer.echo(f"\n Rolled back: {len(report['rollbacks'])} action(s) "
832
+ "(violations rose; reverted)")
833
+ if report["post_violations"] > report["pre_violations"]:
834
+ raise typer.Exit(code=1)
835
+
836
+
837
+ @app.command("certify")
838
+ def certify_cmd(
839
+ project_root: Annotated[Path, typer.Argument(
840
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True)],
841
+ package: Annotated[str | None, typer.Option("--package")] = None,
842
+ fail_under: Annotated[float | None, typer.Option("--fail-under",
843
+ help="Exit 1 when the certify score is below this threshold.")] = None,
844
+ json_out: Annotated[bool, typer.Option("--json")] = False,
845
+ emit_receipt: Annotated[Path | None, typer.Option(
846
+ "--emit-receipt",
847
+ help="Write a Forge Receipt v1 JSON to PATH "
848
+ "(see docs/RECEIPT.md for the schema).")] = None,
849
+ print_card: Annotated[bool, typer.Option(
850
+ "--print-card",
851
+ help="Print the receipt as a 60-wide box-drawing card to stdout. "
852
+ "Powers the '62 -> 5' viral demo.")] = False,
853
+ sign: Annotated[bool, typer.Option(
854
+ "--sign",
855
+ help="Send the receipt to AAAA-Nexus /v1/verify/forge-receipt "
856
+ "for Sigstore + AAAA-Nexus signing before emitting / "
857
+ "rendering. Soft-fails if the endpoint is unavailable; the "
858
+ "unsigned receipt is still emitted with a notes entry.")] = False,
859
+ local_sign: Annotated[bool, typer.Option(
860
+ "--local-sign/--no-local-sign",
861
+ help="Sign the receipt with a local Ed25519 key (Lane G W5). "
862
+ "Soft-fails if the key is absent or cryptography not installed.")] = False,
863
+ local_sign_key: Annotated[Path | None, typer.Option(
864
+ "--local-sign-key",
865
+ help="Path to the Ed25519 PEM private key used by --local-sign. "
866
+ "Defaults to forge-signing.pem in the project root.")] = None,
867
+ summary: Annotated[bool, typer.Option(
868
+ "--summary",
869
+ help="Emit ONLY the compact agent-native blocker summary (top "
870
+ "5 actionable items + next-command) instead of the full "
871
+ "certify report. Pairs with --json for machine consumers.")] = False,
872
+ ) -> None:
873
+ """Score documentation, tests, tier layout, import discipline."""
874
+ if fail_under is not None and not 0 <= fail_under <= 100:
875
+ raise typer.BadParameter(
876
+ format_hint("fail_under_out_of_range", value=fail_under)
877
+ )
878
+ report = certify_checks(project_root, project=project_root.name,
879
+ package=package)
880
+ failed_gate = fail_under is not None and float(report["score"]) < fail_under
881
+ if summary:
882
+ # Pair certify with a fresh wire scan so the summary covers
883
+ # both axes (Codex feedback: agents want one compact answer).
884
+ wire_for_summary = scan_violations(project_root)
885
+ s = summarize_blockers(
886
+ wire_report=wire_for_summary,
887
+ certify_report=report,
888
+ package_root=package or project_root.name,
889
+ )
890
+ if json_out:
891
+ typer.echo(json.dumps(s, indent=2, default=str))
892
+ else:
893
+ typer.echo(render_summary_text(s))
894
+ if failed_gate:
895
+ raise typer.Exit(code=1)
896
+ return
897
+ if json_out:
898
+ typer.echo(json.dumps(report, indent=2, default=str))
899
+ if failed_gate:
900
+ raise typer.Exit(code=1)
901
+ return
902
+ typer.echo(f"\nCertify: {project_root}")
903
+ typer.echo(f" score: {report['score']}/100")
904
+ typer.echo(f" docs: {'PASS' if report['documentation_complete'] else 'FAIL'}")
905
+ typer.echo(f" tests: {'PASS' if report['tests_present'] else 'FAIL'}")
906
+ typer.echo(f" layout:{'PASS' if report['tier_layout_present'] else 'FAIL'}")
907
+ typer.echo(f" wire: {'PASS' if report['no_upward_imports'] else 'FAIL'}")
908
+ for issue in report["issues"]:
909
+ typer.echo(f" - {issue}")
910
+ if emit_receipt is not None or print_card or sign or local_sign:
911
+ # The Receipt needs a scout summary; if scout didn't already
912
+ # run via forge auto, harvest a cheap one now (no symbol dump
913
+ # written; we only need counts + tier_distribution).
914
+ scout_for_receipt = harvest_repo(project_root)
915
+ wire_for_receipt = scan_violations(project_root)
916
+ receipt = build_receipt(
917
+ certify_result=report,
918
+ wire_report=wire_for_receipt,
919
+ scout_report=scout_for_receipt,
920
+ project_name=project_root.name,
921
+ project_root=project_root,
922
+ forge_version=__version__,
923
+ package=package,
924
+ certify_threshold=fail_under or 100.0,
925
+ )
926
+ # Lane A W4: append a local lineage-chain link before signing
927
+ # so signatures.aaaa_nexus can carry the lineage_path the
928
+ # Vanguard endpoint sees. Skip on --no-lineage (future flag).
929
+ receipt = LineageChainStore(project_root).link_and_append(receipt)
930
+ if sign:
931
+ receipt = sign_receipt(receipt)
932
+ if local_sign:
933
+ key = local_sign_key or (project_root / "forge-signing.pem")
934
+ receipt = sign_receipt_local(receipt, key_path=key)
935
+ if emit_receipt is not None:
936
+ emit_receipt.parent.mkdir(parents=True, exist_ok=True)
937
+ emit_receipt.write_text(receipt_to_json(receipt), encoding="utf-8")
938
+ if print_card:
939
+ typer.echo("")
940
+ typer.echo(render_receipt_card(receipt))
941
+ if failed_gate:
942
+ typer.echo(f" gate: FAIL (score below --fail-under {fail_under:g})")
943
+ typer.echo(
944
+ "\n"
945
+ + format_hint("certify_below_threshold",
946
+ score=report["score"],
947
+ threshold=int(fail_under) if float(fail_under).is_integer() else fail_under,
948
+ path=project_root),
949
+ err=True,
950
+ )
951
+ raise typer.Exit(code=1)
952
+
953
+
954
+ @app.command("status")
955
+ def status_cmd(
956
+ project_root: Annotated[Path, typer.Argument(
957
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
958
+ help="Project root.")] = Path("."),
959
+ json_out: Annotated[bool, typer.Option("--json")] = False,
960
+ fail_under: Annotated[float | None, typer.Option(
961
+ "--fail-under",
962
+ help="Exit 1 when the certify score is below this threshold.")] = None,
963
+ ) -> None:
964
+ """Quick health snapshot: wire violations + certify score in one call.
965
+
966
+ The go-to command when you want a fast answer to 'is my project clean?'
967
+ without running the full auto pipeline.
968
+ """
969
+ wire_report = scan_violations(project_root)
970
+ certify_report = certify_checks(project_root, project=project_root.name)
971
+ violations = wire_report["violation_count"]
972
+ score = certify_report["score"]
973
+ overall = "PASS" if (violations == 0 and score >= (fail_under or 75)) else "FAIL"
974
+ if json_out:
975
+ typer.echo(json.dumps({
976
+ "schema_version": "atomadic-forge.status/v1",
977
+ "project": str(project_root),
978
+ "verdict": overall,
979
+ "wire": {"violations": violations, "verdict": wire_report.get("verdict")},
980
+ "certify": {"score": score, "issues": certify_report.get("issues", [])},
981
+ }, indent=2, default=str))
982
+ if overall == "FAIL" and fail_under is not None:
983
+ raise typer.Exit(code=1)
984
+ return
985
+ typer.echo(f"\nForge status: {project_root}")
986
+ typer.echo("-" * 60)
987
+ wire_ok = violations == 0
988
+ score_ok = score >= (fail_under or 75)
989
+ typer.echo(f" wire: {'PASS' if wire_ok else 'FAIL':5s} {violations} violation(s)")
990
+ typer.echo(f" certify: {'PASS' if score_ok else 'FAIL':5s} {score}/100")
991
+ for issue in certify_report.get("issues", []):
992
+ typer.echo(f" - {issue}")
993
+ typer.echo(f"\n {'✓ CLEAN' if overall == 'PASS' else '✗ NEEDS WORK'}")
994
+ if overall == "FAIL" and fail_under is not None:
995
+ raise typer.Exit(code=1)
996
+
997
+
998
+ @app.command("sbom")
999
+ def sbom_cmd(
1000
+ project: Annotated[Path, typer.Argument(
1001
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
1002
+ help="Project root (must contain pyproject.toml).")] = Path("."),
1003
+ out: Annotated[Path | None, typer.Option(
1004
+ "--out",
1005
+ help="Write the SBOM JSON to this path instead of stdout.")] = None,
1006
+ json_out: Annotated[bool, typer.Option(
1007
+ "--json", help="Pretty-print the SBOM JSON to stdout.")] = False,
1008
+ ) -> None:
1009
+ """Generate a CycloneDX 1.5 SBOM from pyproject.toml (Lane G G3)."""
1010
+ try:
1011
+ scout = harvest_repo(project)
1012
+ except Exception: # noqa: BLE001
1013
+ scout = None
1014
+ sbom = emit_sbom(project_root=project, scout_report=scout)
1015
+ sbom_json = json.dumps(sbom, indent=2, default=str)
1016
+ if out is not None:
1017
+ out.parent.mkdir(parents=True, exist_ok=True)
1018
+ out.write_text(sbom_json, encoding="utf-8")
1019
+ typer.echo(f"SBOM written to {out}")
1020
+ elif json_out:
1021
+ typer.echo(sbom_json)
1022
+ else:
1023
+ typer.echo(f"SBOM: {project.name}")
1024
+ typer.echo(f" format: CycloneDX {sbom.get('specVersion', '1.5')}")
1025
+ typer.echo(f" components: {len(sbom.get('components', []))}")
1026
+ typer.echo(f" schema: {sbom.get('schema_version', '')}")
1027
+ typer.echo(" (use --json or --out to emit the full CycloneDX JSON)")
1028
+
1029
+
1030
+ @app.command("diff")
1031
+ def diff_cmd(
1032
+ left: Annotated[Path, typer.Argument(
1033
+ exists=True, file_okay=True, dir_okay=False, resolve_path=True,
1034
+ help="Left (baseline) Forge JSON manifest.")],
1035
+ right: Annotated[Path, typer.Argument(
1036
+ exists=True, file_okay=True, dir_okay=False, resolve_path=True,
1037
+ help="Right (candidate) Forge JSON manifest.")],
1038
+ json_out: Annotated[bool, typer.Option("--json")] = False,
1039
+ ) -> None:
1040
+ """Compare two Forge JSON manifests (scout/cherry/assimilate/wire/certify/synergy/emergent)."""
1041
+ def _load(p: Path) -> dict:
1042
+ try:
1043
+ data = json.loads(p.read_text(encoding="utf-8"))
1044
+ except (OSError, json.JSONDecodeError) as exc:
1045
+ raise typer.BadParameter(
1046
+ format_hint("not_a_forge_manifest", path=p)
1047
+ + f"\n\nUnderlying parse error: {exc}"
1048
+ ) from exc
1049
+ if not isinstance(data, dict) or not isinstance(
1050
+ data.get("schema_version"), str) or not data["schema_version"].startswith(
1051
+ "atomadic-forge."):
1052
+ raise typer.BadParameter(
1053
+ format_hint("not_a_forge_manifest", path=p)
1054
+ )
1055
+ return data
1056
+
1057
+ left_doc = _load(left)
1058
+ right_doc = _load(right)
1059
+ diff = diff_manifests(left_doc, right_doc)
1060
+
1061
+ if json_out:
1062
+ typer.echo(json.dumps(diff, indent=2, default=str))
1063
+ return
1064
+
1065
+ typer.echo(f"\nForge diff: {left.name} → {right.name}")
1066
+ typer.echo("-" * 60)
1067
+ typer.echo(f" left: {diff['left_schema']}")
1068
+ typer.echo(f" right: {diff['right_schema']}")
1069
+ typer.echo(f" compatible: {diff['compatible']}")
1070
+ if diff["summary"]:
1071
+ typer.echo(" summary:")
1072
+ for k, v in diff["summary"].items():
1073
+ if isinstance(v, str | int | float | bool) or v is None:
1074
+ typer.echo(f" {k}: {v}")
1075
+ elif isinstance(v, dict):
1076
+ typer.echo(f" {k}: {v}")
1077
+ elif isinstance(v, list):
1078
+ typer.echo(f" {k}: {len(v)} item(s)")
1079
+ typer.echo(
1080
+ f" +{len(diff['added'])} added / "
1081
+ f"-{len(diff['removed'])} removed / "
1082
+ f"~{len(diff['changed'])} changed"
1083
+ )
1084
+
1085
+
1086
+ mcp_app = typer.Typer(no_args_is_help=True,
1087
+ help="MCP server surface — speak Forge to coding agents.")
1088
+
1089
+
1090
+ @mcp_app.command("serve")
1091
+ def mcp_serve_cmd(
1092
+ project: Annotated[Path, typer.Option(
1093
+ "--project",
1094
+ exists=True, file_okay=False, dir_okay=True, resolve_path=True,
1095
+ help="Project root the MCP tools will operate against. "
1096
+ "Defaults to the current working directory.")] = Path("."),
1097
+ ) -> None:
1098
+ """Run the MCP stdio JSON-RPC server (Cursor / Claude Code / Aider / Devin).
1099
+
1100
+ Add to your client's MCP config:
1101
+
1102
+ {
1103
+ "mcpServers": {
1104
+ "atomadic-forge": {
1105
+ "command": "forge",
1106
+ "args": ["mcp", "serve", "--project", "/path/to/your/repo"]
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ Tools exposed (21):
1112
+ recon — scout the repo + classify symbols
1113
+ wire — upward-import scan; --suggest-repairs
1114
+ certify — 4-axis score; --emit-receipt / --print-card
1115
+ enforce — F-coded mechanical fixes; rollback-safe
1116
+ audit_list — .atomadic-forge lineage summaries
1117
+ auto_plan — agent_plan/v1 ranked action cards
1118
+ auto_step — apply ONE card from a saved plan
1119
+ auto_apply — apply ALL applyable cards (halts on regression)
1120
+ context_pack — Codex 'first call' orientation bundle
1121
+ preflight_change — pre-edit guardrail (forbidden imports etc.)
1122
+ score_patch — patch risk scorer (architecture/api/release)
1123
+ select_tests — minimum + full-confidence test sets
1124
+ rollback_plan — files to remove + caches to clean
1125
+ explain_repo — humane operational orientation
1126
+ adapt_plan — capability-aware card filtering
1127
+ compose_tools — tool-use planner per goal keyword
1128
+ load_policy — read [tool.forge.agent] from pyproject.toml
1129
+ why_did_this_change — agent memory: lineage + plan-event lookup
1130
+ what_failed_last_time — agent memory: failed/rolled_back events
1131
+ list_recipes — golden-path recipes catalogue
1132
+ get_recipe — fetch one named recipe
1133
+
1134
+ Resources exposed (5):
1135
+ forge://docs/receipt — Receipt v1 schema docs
1136
+ forge://docs/formalization — AAM + BEP theorem citations
1137
+ forge://lineage/chain — local Vanguard lineage chain
1138
+ forge://schema/receipt — verdict enum + version constants
1139
+ forge://summary/blockers — one-call 'what's blocking?' summary
1140
+ """
1141
+ rc = mcp_serve_stdio(project_root=project)
1142
+ if rc != 0:
1143
+ raise typer.Exit(code=rc)
1144
+
1145
+
1146
+ app.add_typer(mcp_app, name="mcp",
1147
+ help="MCP server surface — speak Forge to coding agents.")
1148
+
1149
+
1150
+ def _check_optional_dep(module: str) -> str:
1151
+ try:
1152
+ __import__(module)
1153
+ return "ok"
1154
+ except ImportError:
1155
+ return "missing"
1156
+
1157
+
1158
+ @app.command("doctor")
1159
+ def doctor_cmd(
1160
+ json_out: Annotated[bool, typer.Option("--json")] = False,
1161
+ ) -> None:
1162
+ """Environment diagnostic."""
1163
+ optional = {
1164
+ "complexipy": _check_optional_dep("complexipy"),
1165
+ "cryptography": _check_optional_dep("cryptography"),
1166
+ "tomli": _check_optional_dep("tomli"),
1167
+ }
1168
+ info = {
1169
+ "atomadic_forge_version": __version__,
1170
+ "python": sys.version.split()[0],
1171
+ "executable": sys.executable,
1172
+ "platform": sys.platform,
1173
+ "stdout_encoding": getattr(sys.stdout, "encoding", "?"),
1174
+ "optional_deps": optional,
1175
+ }
1176
+ if json_out:
1177
+ typer.echo(json.dumps(info, indent=2))
1178
+ return
1179
+ typer.echo("\nAtomadic Forge — doctor")
1180
+ for k, v in info.items():
1181
+ if k == "optional_deps":
1182
+ continue
1183
+ typer.echo(f" {k:24s} {v}")
1184
+ typer.echo(" optional dependencies:")
1185
+ for dep, status in optional.items():
1186
+ mark = "✓" if status == "ok" else "✗"
1187
+ note = "" if status == "ok" else f" (pip install {dep})"
1188
+ typer.echo(f" {mark} {dep}{note}")
1189
+
1190
+
1191
+ @app.command("cs1")
1192
+ def cs1_cmd(
1193
+ project: Annotated[str, typer.Argument(help="Project root path.")] = ".",
1194
+ receipt: Annotated[Path | None, typer.Option("--receipt", help="Path to receipt.json.")] = None,
1195
+ out: Annotated[Path | None, typer.Option("--out", help="Output path for CS-1.md.")] = None,
1196
+ json_out: Annotated[bool, typer.Option("--json", help="Emit CS-1 as JSON instead of Markdown.")] = False,
1197
+ ) -> None:
1198
+ """Generate a Conformity Statement CS-1 v1 (EU AI Act / SR 11-7 / FDA PCCP / CMMC-AI)."""
1199
+ from ..a1_at_functions.cs1_renderer import render_cs1, render_cs1_markdown
1200
+
1201
+ project_path = Path(project).resolve()
1202
+ if receipt is None:
1203
+ receipt = project_path / ".atomadic-forge" / "receipt.json"
1204
+ if not receipt.exists():
1205
+ typer.secho(
1206
+ f"Receipt not found at {receipt}.\n"
1207
+ f"Generate one first:\n"
1208
+ f" forge certify {project_path} --emit-receipt {receipt}",
1209
+ fg="red", err=True,
1210
+ )
1211
+ raise typer.Exit(code=1)
1212
+ try:
1213
+ receipt_data = json.loads(receipt.read_text(encoding="utf-8"))
1214
+ except (OSError, json.JSONDecodeError) as exc:
1215
+ typer.secho(f"Failed to load receipt: {exc}", fg="red", err=True)
1216
+ raise typer.Exit(code=1) from exc
1217
+ try:
1218
+ cs1 = render_cs1(receipt_data)
1219
+ except ValueError as exc:
1220
+ typer.secho(f"Receipt validation failed: {exc}", fg="red", err=True)
1221
+ raise typer.Exit(code=1) from exc
1222
+ if json_out:
1223
+ typer.echo(json.dumps(cs1, indent=2, default=str))
1224
+ return
1225
+ md = render_cs1_markdown(cs1)
1226
+ if out is None:
1227
+ out = project_path / ".atomadic-forge" / "CS-1.md"
1228
+ out.parent.mkdir(parents=True, exist_ok=True)
1229
+ out.write_text(md, encoding="utf-8")
1230
+ typer.secho(f"\nForge CS-1 — Conformity Statement written to {out}", fg="green")
1231
+
1232
+
1233
+ # Specialty sub-apps — registered lazily so any import error in one doesn't
1234
+ # break the others.
1235
+ def _register_specialty_apps() -> None:
1236
+ for module_path, name, help_text in (
1237
+ ("atomadic_forge.commands.demo", "demo",
1238
+ "One-shot launch-video verb: preset evolve + DEMO.md artifact."),
1239
+ ("atomadic_forge.commands.iterate", "iterate",
1240
+ "LLM ↔ Forge loop: intent → architecturally-coherent code."),
1241
+ ("atomadic_forge.commands.evolve", "evolve",
1242
+ "Recursive self-improvement: iterate N times, growing catalog."),
1243
+ ("atomadic_forge.commands.chat", "chat",
1244
+ "Chat with a Forge-aware AI copilot over optional repo context."),
1245
+ ("atomadic_forge.commands.emergent", "emergent",
1246
+ "Symbol-level composition discovery."),
1247
+ ("atomadic_forge.commands.synergy", "synergy",
1248
+ "Feature-pair detection + auto-implement adapters."),
1249
+ ("atomadic_forge.commands.commandsmith", "commandsmith",
1250
+ "Auto-register / document / smoke CLI commands."),
1251
+ ("atomadic_forge.commands.feature_then_emergent", "feature-then-emergent",
1252
+ "Run any feature → fan its output into emergent scan."),
1253
+ ("atomadic_forge.commands.config_cmd", "config",
1254
+ "Configure Atomadic Forge — show / set / test config + wizard."),
1255
+ ("atomadic_forge.commands.audit", "audit",
1256
+ "Surface .atomadic-forge lineage: list / show / log."),
1257
+ ("atomadic_forge.commands.emergent_then_synergy", "emergent-then-synergy",
1258
+ "Run emergent → pipe JSON artifact to synergy."),
1259
+ ("atomadic_forge.commands.synergy_then_emergent", "synergy-then-emergent",
1260
+ "Run synergy → pipe JSON artifact to emergent."),
1261
+ ("atomadic_forge.commands.evolve_then_iterate", "evolve-then-iterate",
1262
+ "Run evolve → pipe JSON artifact to iterate."),
1263
+ ):
1264
+ try:
1265
+ mod = __import__(module_path, fromlist=["app"])
1266
+ sub_app = getattr(mod, "app", None)
1267
+ if sub_app is not None:
1268
+ app.add_typer(sub_app, name=name, help=help_text)
1269
+ except Exception as exc: # noqa: BLE001
1270
+ typer.secho(f"[forge] could not register {name}: {exc}",
1271
+ fg="yellow", err=True)
1272
+
1273
+
1274
+ _register_specialty_apps()
1275
+
1276
+
1277
+ def main() -> None:
1278
+ """Console-script entry."""
1279
+ _force_utf8()
1280
+ app()
1281
+
1282
+
1283
+ if __name__ == "__main__":
1284
+ main()