atomadic-forge 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atomadic_forge/__init__.py +12 -0
- atomadic_forge/__main__.py +5 -0
- atomadic_forge/a0_qk_constants/__init__.py +1 -0
- atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
- atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
- atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
- atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
- atomadic_forge/a0_qk_constants/error_codes.py +296 -0
- atomadic_forge/a0_qk_constants/forge_types.py +89 -0
- atomadic_forge/a0_qk_constants/gen_language.py +116 -0
- atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
- atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
- atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
- atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
- atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
- atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
- atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
- atomadic_forge/a0_qk_constants/tier_names.py +47 -0
- atomadic_forge/a1_at_functions/__init__.py +1 -0
- atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
- atomadic_forge/a1_at_functions/agent_memory.py +139 -0
- atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
- atomadic_forge/a1_at_functions/agent_summary.py +277 -0
- atomadic_forge/a1_at_functions/body_extractor.py +306 -0
- atomadic_forge/a1_at_functions/card_renderer.py +210 -0
- atomadic_forge/a1_at_functions/certify_checks.py +445 -0
- atomadic_forge/a1_at_functions/chat_context.py +170 -0
- atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
- atomadic_forge/a1_at_functions/classify_tier.py +115 -0
- atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
- atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
- atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
- atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
- atomadic_forge/a1_at_functions/config_io.py +68 -0
- atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
- atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
- atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
- atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
- atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
- atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
- atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
- atomadic_forge/a1_at_functions/error_hints.py +105 -0
- atomadic_forge/a1_at_functions/evolution_log.py +94 -0
- atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
- atomadic_forge/a1_at_functions/generation_quality.py +322 -0
- atomadic_forge/a1_at_functions/import_repair.py +211 -0
- atomadic_forge/a1_at_functions/import_smoke.py +102 -0
- atomadic_forge/a1_at_functions/js_parser.py +539 -0
- atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
- atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
- atomadic_forge/a1_at_functions/llm_client.py +554 -0
- atomadic_forge/a1_at_functions/local_signer.py +134 -0
- atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
- atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
- atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
- atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
- atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
- atomadic_forge/a1_at_functions/policy_loader.py +107 -0
- atomadic_forge/a1_at_functions/preflight_change.py +227 -0
- atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
- atomadic_forge/a1_at_functions/provider_detect.py +157 -0
- atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
- atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
- atomadic_forge/a1_at_functions/recipes.py +186 -0
- atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
- atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
- atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
- atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
- atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
- atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
- atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
- atomadic_forge/a1_at_functions/scout_walk.py +309 -0
- atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
- atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
- atomadic_forge/a1_at_functions/stub_detector.py +158 -0
- atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
- atomadic_forge/a1_at_functions/synergy_render.py +252 -0
- atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
- atomadic_forge/a1_at_functions/test_runner.py +196 -0
- atomadic_forge/a1_at_functions/test_selector.py +122 -0
- atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
- atomadic_forge/a1_at_functions/tool_composer.py +130 -0
- atomadic_forge/a1_at_functions/transcript_log.py +70 -0
- atomadic_forge/a1_at_functions/wire_check.py +260 -0
- atomadic_forge/a2_mo_composites/__init__.py +1 -0
- atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
- atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
- atomadic_forge/a2_mo_composites/plan_store.py +164 -0
- atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
- atomadic_forge/a3_og_features/__init__.py +1 -0
- atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
- atomadic_forge/a3_og_features/demo_runner.py +502 -0
- atomadic_forge/a3_og_features/emergent_feature.py +95 -0
- atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
- atomadic_forge/a3_og_features/forge_enforce.py +107 -0
- atomadic_forge/a3_og_features/forge_evolve.py +176 -0
- atomadic_forge/a3_og_features/forge_loop.py +528 -0
- atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
- atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
- atomadic_forge/a3_og_features/lsp_server.py +98 -0
- atomadic_forge/a3_og_features/mcp_server.py +160 -0
- atomadic_forge/a3_og_features/setup_wizard.py +337 -0
- atomadic_forge/a3_og_features/synergy_feature.py +65 -0
- atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
- atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
- atomadic_forge/commands/__init__.py +1 -0
- atomadic_forge/commands/_registry.py +36 -0
- atomadic_forge/commands/audit.py +142 -0
- atomadic_forge/commands/chat.py +133 -0
- atomadic_forge/commands/commandsmith.py +178 -0
- atomadic_forge/commands/config_cmd.py +145 -0
- atomadic_forge/commands/demo.py +142 -0
- atomadic_forge/commands/emergent.py +124 -0
- atomadic_forge/commands/emergent_then_synergy.py +70 -0
- atomadic_forge/commands/evolve.py +122 -0
- atomadic_forge/commands/evolve_then_iterate.py +70 -0
- atomadic_forge/commands/feature_then_emergent.py +111 -0
- atomadic_forge/commands/iterate.py +140 -0
- atomadic_forge/commands/synergy.py +96 -0
- atomadic_forge/commands/synergy_then_emergent.py +70 -0
- atomadic_forge-0.3.2.dist-info/METADATA +471 -0
- atomadic_forge-0.3.2.dist-info/RECORD +131 -0
- atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
- atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
- atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
- atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|