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,164 @@
1
+ """Tier a2 — append-only persistence for agent_plan/v1 documents.
2
+
3
+ Codex's follow-up: 'forge auto step <card-id>' / 'forge auto apply
4
+ <plan-id>' need plans to be addressable. This store gives every plan
5
+ a stable id (SHA-256 prefix of the plan's structural content) and
6
+ persists it under ``.atomadic-forge/plans/<id>.json``. Per-card
7
+ state (applied / rejected / skipped) lives in a sibling
8
+ ``.atomadic-forge/plans/<id>.state.json`` so the plan doc itself
9
+ stays append-only and re-emit-safe.
10
+
11
+ Pure-ish: file I/O scoped under one project_root; no network. The
12
+ pure id-derivation lives in a1 (``a1.lineage_chain.canonical_receipt_hash``-style)
13
+ but for plans the canonical fields are different — declared inline
14
+ here to keep the dependency surface tight.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import datetime as _dt
19
+ import hashlib
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ _DIRNAME = ".atomadic-forge"
25
+ _PLANS_SUBDIR = "plans"
26
+
27
+
28
+ # Fields that participate in the plan id hash. Mutable / volatile
29
+ # fields are excluded so re-emitting the same plan yields the same id.
30
+ _PLAN_HASH_INCLUDE: tuple[str, ...] = (
31
+ "schema_version",
32
+ "goal",
33
+ "mode",
34
+ "project_root",
35
+ "top_actions",
36
+ )
37
+
38
+
39
+ def compute_plan_id(plan: dict) -> str:
40
+ """SHA-256 hex prefix derived from the plan's structural content."""
41
+ canonical = {k: plan.get(k) for k in _PLAN_HASH_INCLUDE}
42
+ blob = json.dumps(canonical, sort_keys=True, default=str)
43
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
44
+
45
+
46
+ def _now_utc_iso() -> str:
47
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
48
+
49
+
50
+ class PlanStore:
51
+ """Append-only plan + per-card-state persistence."""
52
+
53
+ def __init__(self, project_root: Path) -> None:
54
+ self.project_root = Path(project_root).resolve()
55
+ self.dir = self.project_root / _DIRNAME / _PLANS_SUBDIR
56
+
57
+ # ---- write paths ---------------------------------------------------
58
+
59
+ def save_plan(self, plan: dict) -> str:
60
+ """Persist a plan dict and return its id (idempotent)."""
61
+ plan_id = plan.get("id") or compute_plan_id(plan)
62
+ self.dir.mkdir(parents=True, exist_ok=True)
63
+ # Persist the plan with its id baked in so repeat saves are safe.
64
+ out = dict(plan)
65
+ out.setdefault("id", plan_id)
66
+ out.setdefault("saved_at_utc", _now_utc_iso())
67
+ target = self.dir / f"{plan_id}.json"
68
+ target.write_text(
69
+ json.dumps(out, indent=2, default=str), encoding="utf-8")
70
+ # Initialize state file if missing.
71
+ state_path = self._state_path(plan_id)
72
+ if not state_path.exists():
73
+ state_path.write_text(
74
+ json.dumps({
75
+ "schema_version": "atomadic-forge.plan_state/v1",
76
+ "plan_id": plan_id,
77
+ "events": [],
78
+ }, indent=2),
79
+ encoding="utf-8",
80
+ )
81
+ return plan_id
82
+
83
+ def record_card_event(
84
+ self,
85
+ plan_id: str,
86
+ *,
87
+ card_id: str,
88
+ status: str,
89
+ detail: dict[str, Any] | None = None,
90
+ ) -> None:
91
+ """Append a per-card outcome (applied / rejected / skipped /
92
+ rolled_back). Pure append; never rewrites prior events."""
93
+ state = self.load_state(plan_id) or {
94
+ "schema_version": "atomadic-forge.plan_state/v1",
95
+ "plan_id": plan_id, "events": [],
96
+ }
97
+ state["events"].append({
98
+ "ts_utc": _now_utc_iso(),
99
+ "card_id": card_id,
100
+ "status": status,
101
+ "detail": detail or {},
102
+ })
103
+ self._state_path(plan_id).write_text(
104
+ json.dumps(state, indent=2, default=str), encoding="utf-8")
105
+
106
+ # ---- read paths ----------------------------------------------------
107
+
108
+ def load_plan(self, plan_id: str) -> dict | None:
109
+ target = self.dir / f"{plan_id}.json"
110
+ if not target.exists():
111
+ return None
112
+ try:
113
+ return json.loads(target.read_text(encoding="utf-8"))
114
+ except (OSError, json.JSONDecodeError):
115
+ return None
116
+
117
+ def load_state(self, plan_id: str) -> dict | None:
118
+ target = self._state_path(plan_id)
119
+ if not target.exists():
120
+ return None
121
+ try:
122
+ return json.loads(target.read_text(encoding="utf-8"))
123
+ except (OSError, json.JSONDecodeError):
124
+ return None
125
+
126
+ def list_plans(self) -> list[dict]:
127
+ """Return summaries newest-first by saved_at_utc."""
128
+ if not self.dir.exists():
129
+ return []
130
+ out: list[dict] = []
131
+ for f in self.dir.glob("*.json"):
132
+ if f.name.endswith(".state.json"):
133
+ continue
134
+ try:
135
+ doc = json.loads(f.read_text(encoding="utf-8"))
136
+ except (OSError, json.JSONDecodeError):
137
+ continue
138
+ out.append({
139
+ "plan_id": doc.get("id", f.stem),
140
+ "verdict": doc.get("verdict", "?"),
141
+ "goal": doc.get("goal", ""),
142
+ "mode": doc.get("mode", ""),
143
+ "action_count": doc.get("action_count", 0),
144
+ "applyable_count": doc.get("applyable_count", 0),
145
+ "saved_at_utc": doc.get("saved_at_utc", ""),
146
+ })
147
+ out.sort(key=lambda d: d["saved_at_utc"], reverse=True)
148
+ return out
149
+
150
+ def card_status(self, plan_id: str, card_id: str) -> str:
151
+ """Return the latest status for ``card_id`` in plan, or
152
+ 'unapplied' when no event has been recorded."""
153
+ state = self.load_state(plan_id)
154
+ if not state:
155
+ return "unapplied"
156
+ for ev in reversed(state.get("events", [])):
157
+ if ev.get("card_id") == card_id:
158
+ return ev.get("status", "unapplied")
159
+ return "unapplied"
160
+
161
+ # ---- helpers -------------------------------------------------------
162
+
163
+ def _state_path(self, plan_id: str) -> Path:
164
+ return self.dir / f"{plan_id}.state.json"
@@ -0,0 +1,231 @@
1
+ """Tier a2 — Receipt signing client.
2
+
3
+ Golden Path Lane A W2. Calls AAAA-Nexus ``/v1/verify/forge-receipt`` to
4
+ obtain Sigstore Rekor metadata + AAAA-Nexus signature, mutates the
5
+ Receipt in place, and returns it.
6
+
7
+ Stateful by design: holds base URL + auth env name + last-known
8
+ endpoint health (so a single CLI session probing once doesn't probe
9
+ again on every Receipt). Fits a2 cleanly (composes a1 emitter output
10
+ + a0 schema constants + std-lib I/O); never imports a3+.
11
+
12
+ Graceful-degradation contract:
13
+ * If ``AAAA_NEXUS_API_KEY`` is unset → return the Receipt unchanged
14
+ with a ``notes`` entry explaining why. No exception.
15
+ * If the endpoint returns 4xx (trial-tier rate limit, key invalid,
16
+ feature not yet shipped) → same: return unsigned with a note.
17
+ * Network / DNS / timeout error → same: return unsigned with a note
18
+ that includes the underlying error class.
19
+ * 5xx → caller's choice: ``strict=True`` raises ``RuntimeError``;
20
+ ``strict=False`` (default) returns unsigned + note.
21
+
22
+ Why the soft-fail default: per the Golden Path, "unsigned Receipts
23
+ are valid. The signature fields default to None." A local-development
24
+ ``forge certify --sign`` invocation should never crash because the
25
+ prod endpoint is having a bad day. CI gates that REQUIRE a signature
26
+ should pass ``--sign --require-signed`` (Lane G future work).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import urllib.error
33
+ import urllib.request
34
+ from copy import deepcopy
35
+ from typing import Any
36
+
37
+ from ..a0_qk_constants.receipt_schema import (
38
+ ForgeReceiptV1,
39
+ ReceiptAAAANexusSignature,
40
+ ReceiptSigstoreSignature,
41
+ )
42
+
43
+
44
+ class ReceiptSigner:
45
+ """Stateful signer that wraps the AAAA-Nexus verify endpoint."""
46
+
47
+ DEFAULT_BASE_URL = "https://aaaa-nexus.atomadictech.workers.dev"
48
+ DEFAULT_PATH = "/v1/verify/forge-receipt"
49
+ DEFAULT_TIMEOUT_SECONDS = 30
50
+ USER_AGENT = "atomadic-forge/0.2 (ReceiptSigner)"
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ base_url: str | None = None,
56
+ api_key_env: str = "AAAA_NEXUS_API_KEY",
57
+ path: str | None = None,
58
+ timeout_seconds: int | None = None,
59
+ ) -> None:
60
+ self.base_url = (
61
+ base_url
62
+ or os.environ.get("AAAA_NEXUS_URL")
63
+ or self.DEFAULT_BASE_URL
64
+ ).rstrip("/")
65
+ self.api_key_env = api_key_env
66
+ self.path = path or self.DEFAULT_PATH
67
+ self.timeout_seconds = (
68
+ timeout_seconds
69
+ if timeout_seconds is not None
70
+ else self.DEFAULT_TIMEOUT_SECONDS
71
+ )
72
+ # Soft cache of last-known reachability so a session that
73
+ # probed once and got 404 doesn't re-probe on every receipt.
74
+ self._endpoint_known_unavailable = False
75
+
76
+ # ---- public surface ------------------------------------------------
77
+
78
+ def sign(
79
+ self,
80
+ receipt: ForgeReceiptV1,
81
+ *,
82
+ strict: bool = False,
83
+ ) -> ForgeReceiptV1:
84
+ """Return a new Receipt with the ``signatures`` block populated.
85
+
86
+ The input Receipt is not mutated. The output is a deep copy
87
+ with ``signatures.sigstore`` and ``signatures.aaaa_nexus``
88
+ either populated (success) or left at None with a ``notes``
89
+ entry (soft-fail).
90
+ """
91
+ out = deepcopy(receipt)
92
+ api_key = os.environ.get(self.api_key_env, "")
93
+ if not api_key:
94
+ self._note(out, f"{self.api_key_env} not set — receipt left unsigned")
95
+ return out
96
+ if self._endpoint_known_unavailable:
97
+ self._note(out, "AAAA-Nexus signing endpoint unavailable this session")
98
+ return out
99
+ try:
100
+ response = self._post_for_signature(receipt, api_key)
101
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError,
102
+ ValueError) as exc:
103
+ return self._handle_request_failure(out, exc, strict=strict)
104
+ self._apply_signature(out, response)
105
+ return out
106
+
107
+ # ---- request helpers ----------------------------------------------
108
+
109
+ def _post_for_signature(
110
+ self,
111
+ receipt: ForgeReceiptV1,
112
+ api_key: str,
113
+ ) -> dict[str, Any]:
114
+ body = json.dumps({"receipt": receipt}, default=str).encode("utf-8")
115
+ endpoint = self.base_url + self.path
116
+ req = urllib.request.Request(
117
+ endpoint,
118
+ data=body,
119
+ headers={
120
+ "X-API-Key": api_key,
121
+ "Authorization": f"Bearer {api_key}",
122
+ "Content-Type": "application/json",
123
+ "User-Agent": self.USER_AGENT,
124
+ "Accept": "application/json",
125
+ },
126
+ method="POST",
127
+ )
128
+ with urllib.request.urlopen(req, timeout=self.timeout_seconds) as resp:
129
+ payload = resp.read()
130
+ decoded = json.loads(payload.decode("utf-8") or "{}")
131
+ if not isinstance(decoded, dict):
132
+ raise ValueError("AAAA-Nexus returned non-object body")
133
+ return decoded
134
+
135
+ def _apply_signature(
136
+ self,
137
+ receipt: ForgeReceiptV1,
138
+ response: dict[str, Any],
139
+ ) -> None:
140
+ sigstore = response.get("sigstore")
141
+ nexus = response.get("aaaa_nexus") or response.get("aaaa-nexus")
142
+ sigs = receipt.get("signatures") or {}
143
+ if isinstance(sigstore, dict):
144
+ sigs["sigstore"] = ReceiptSigstoreSignature( # type: ignore[typeddict-item]
145
+ rekor_uuid=str(sigstore.get("rekor_uuid", "")),
146
+ log_index=int(sigstore.get("log_index", 0)),
147
+ bundle_path=str(sigstore.get("bundle_path", "")),
148
+ )
149
+ else:
150
+ sigs["sigstore"] = None # type: ignore[typeddict-item]
151
+ if isinstance(nexus, dict):
152
+ sigs["aaaa_nexus"] = ReceiptAAAANexusSignature( # type: ignore[typeddict-item]
153
+ signature=str(nexus.get("signature", "")),
154
+ key_id=str(nexus.get("key_id", "")),
155
+ issuer=str(nexus.get("issuer", "")),
156
+ issued_at_utc=str(nexus.get("issued_at_utc", "")),
157
+ verify_endpoint=str(nexus.get("verify_endpoint", self.path)),
158
+ )
159
+ else:
160
+ sigs["aaaa_nexus"] = None # type: ignore[typeddict-item]
161
+ receipt["signatures"] = sigs # type: ignore[typeddict-item]
162
+
163
+ # ---- failure handling ---------------------------------------------
164
+
165
+ def _handle_request_failure(
166
+ self,
167
+ receipt: ForgeReceiptV1,
168
+ exc: BaseException,
169
+ *,
170
+ strict: bool,
171
+ ) -> ForgeReceiptV1:
172
+ # 4xx: known bad request / auth / not-yet-shipped — never retry,
173
+ # mark endpoint unavailable for the session so subsequent calls
174
+ # in this run skip the round trip.
175
+ if isinstance(exc, urllib.error.HTTPError):
176
+ if 400 <= exc.code < 500:
177
+ self._endpoint_known_unavailable = True
178
+ self._note(
179
+ receipt,
180
+ f"AAAA-Nexus signing returned HTTP {exc.code}; "
181
+ "receipt left unsigned",
182
+ )
183
+ return receipt
184
+ if strict:
185
+ raise RuntimeError(
186
+ f"AAAA-Nexus signing failed with HTTP {exc.code}: {exc}"
187
+ ) from exc
188
+ self._note(
189
+ receipt,
190
+ f"AAAA-Nexus signing failed with HTTP {exc.code}; "
191
+ "soft-fail, receipt left unsigned",
192
+ )
193
+ return receipt
194
+ if strict:
195
+ raise RuntimeError(
196
+ f"AAAA-Nexus signing transport error: "
197
+ f"{type(exc).__name__}: {exc}"
198
+ ) from exc
199
+ self._note(
200
+ receipt,
201
+ f"AAAA-Nexus signing transport error "
202
+ f"({type(exc).__name__}); receipt left unsigned",
203
+ )
204
+ return receipt
205
+
206
+ # ---- helpers -------------------------------------------------------
207
+
208
+ @staticmethod
209
+ def _note(receipt: ForgeReceiptV1, message: str) -> None:
210
+ notes = list(receipt.get("notes") or [])
211
+ notes.append(message)
212
+ receipt["notes"] = notes # type: ignore[typeddict-item]
213
+
214
+
215
+ def sign_receipt(
216
+ receipt: ForgeReceiptV1,
217
+ *,
218
+ base_url: str | None = None,
219
+ api_key_env: str = "AAAA_NEXUS_API_KEY",
220
+ strict: bool = False,
221
+ ) -> ForgeReceiptV1:
222
+ """Module-level convenience: instantiate a signer and call ``sign``.
223
+
224
+ For one-shot CLI calls. Long-running processes should construct a
225
+ ``ReceiptSigner`` once and reuse it (the endpoint-availability
226
+ cache is per-instance).
227
+ """
228
+ return ReceiptSigner(
229
+ base_url=base_url,
230
+ api_key_env=api_key_env,
231
+ ).sign(receipt, strict=strict)
@@ -0,0 +1 @@
1
+ """Tier a3 — features compose a2 composites + a1 helpers into capabilities."""
@@ -0,0 +1,267 @@
1
+ """Tier a3 — Commandsmith feature: discover, register, document, smoke-test.
2
+
3
+ The Commandsmith feature glues together the a1 discoverer + a1 renderers and
4
+ manages on-disk persistence for the registry. It is the only module the a4
5
+ CLI surface depends on.
6
+
7
+ Pipelines exposed:
8
+
9
+ * :meth:`sync` — full pipeline (discover → render registry → write docs).
10
+ * :meth:`wrap_class` — generate a Typer wrapper around a single class
11
+ (used to lift a freshly assimilated symbol into a CLI command).
12
+ * :meth:`smoke` — invoke ``--help`` on every registered command and record
13
+ exit codes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime as _dt
19
+ import importlib
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from ..a0_qk_constants.commandsmith_types import (
26
+ CommandSignatureCard,
27
+ RegisteredCommandCard,
28
+ RegistryManifestCard,
29
+ )
30
+ from ..a1_at_functions.commandsmith_discover import discover_command_modules
31
+ from ..a1_at_functions.commandsmith_render import (
32
+ render_command_doc,
33
+ render_command_index,
34
+ render_registry_module,
35
+ render_wrapper_module,
36
+ )
37
+
38
+ _REGISTRY_FILE = "_registry.py"
39
+ _MANIFEST_FILE = "commandsmith_manifest.json"
40
+
41
+
42
+ class Commandsmith:
43
+ """Manage the CLI registry for an ASS-ADE-style package.
44
+
45
+ ``package_root``: directory containing the ``commands/`` subfolder.
46
+ ``docs_root``: where to write per-command Markdown.
47
+ ``manifest_dir``: where to persist ``commandsmith_manifest.json``.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ package_root: Path,
54
+ package_name: str = "atomadic_forge",
55
+ docs_root: Path | None = None,
56
+ manifest_dir: Path | None = None,
57
+ ) -> None:
58
+ self.package_root = Path(package_root)
59
+ self.package_name = package_name
60
+ self.commands_dir = self.package_root / "commands"
61
+ self.docs_root = Path(docs_root) if docs_root else self.package_root.parent.parent / "docs" / "commands"
62
+ self.manifest_dir = Path(manifest_dir) if manifest_dir else self.package_root.parent.parent / ".atomadic-forge"
63
+
64
+ # ----- discovery --------------------------------------------------
65
+
66
+ def discover(self) -> list[RegisteredCommandCard]:
67
+ return discover_command_modules(
68
+ self.package_root,
69
+ package=self.package_name,
70
+ sub_dirs=("commands",),
71
+ source_root="atomadic_forge_seed",
72
+ )
73
+
74
+ # ----- registry / docs / manifest --------------------------------
75
+
76
+ def write_registry(self, cards: list[RegisteredCommandCard]) -> Path:
77
+ """Emit ``commands/_registry.py``."""
78
+ target = self.commands_dir / _REGISTRY_FILE
79
+ target.write_text(render_registry_module(cards), encoding="utf-8")
80
+ return target
81
+
82
+ def write_docs(self, cards: list[RegisteredCommandCard]) -> list[Path]:
83
+ """Write per-command Markdown plus an INDEX."""
84
+ self.docs_root.mkdir(parents=True, exist_ok=True)
85
+ out: list[Path] = []
86
+ for card in cards:
87
+ f = self.docs_root / f"{card['name']}.md"
88
+ f.write_text(render_command_doc(card), encoding="utf-8")
89
+ out.append(f)
90
+ idx = self.docs_root / "INDEX.md"
91
+ idx.write_text(render_command_index(cards), encoding="utf-8")
92
+ out.append(idx)
93
+ return out
94
+
95
+ def write_manifest(self, cards: list[RegisteredCommandCard],
96
+ smoke_results: dict[str, bool] | None = None) -> Path:
97
+ manifest = RegistryManifestCard(
98
+ schema_version="atomadic-forge.commandsmith.registry/v1",
99
+ generated_at_utc=_dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
100
+ commands=cards,
101
+ smoke_results=smoke_results or {},
102
+ )
103
+ self.manifest_dir.mkdir(parents=True, exist_ok=True)
104
+ target = self.manifest_dir / _MANIFEST_FILE
105
+ target.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
106
+ return target
107
+
108
+ # ----- generator wrapping a class --------------------------------
109
+
110
+ def wrap_class(
111
+ self,
112
+ *,
113
+ target_module: str,
114
+ target_class: str,
115
+ command_name: str,
116
+ out_dir: Path | None = None,
117
+ help_text: str = "",
118
+ auto_scan: str | None = None,
119
+ ) -> Path:
120
+ """Generate ``commands/<command_name>_cli.py`` wrapping a class.
121
+
122
+ ``auto_scan`` (optional): name of a method to call inside the Typer
123
+ callback (e.g. ``scan_repo`` for CherryPicker) so subcommands see a
124
+ populated instance state. If omitted, a sensible default is picked:
125
+ any method whose name is exactly ``scan`` or starts with ``scan_``.
126
+ """
127
+ sub_cmds = self._inspect_class_methods(target_module, target_class)
128
+ init_params = self._inspect_init_params(target_module, target_class)
129
+ if auto_scan is None:
130
+ auto_scan = next(
131
+ (s["name"] for s in sub_cmds
132
+ if s["name"] == "scan" or s["name"].startswith("scan_")),
133
+ None,
134
+ )
135
+ src = render_wrapper_module(
136
+ target_module=target_module,
137
+ target_class=target_class,
138
+ command_name=command_name,
139
+ sub_commands=sub_cmds,
140
+ help_text=help_text or f"Auto-wrapped {target_class}",
141
+ init_params=init_params,
142
+ auto_scan=auto_scan,
143
+ )
144
+ out = (out_dir or self.commands_dir) / f"{command_name.replace('-', '_')}_cli.py"
145
+ out.parent.mkdir(parents=True, exist_ok=True)
146
+ out.write_text(src, encoding="utf-8")
147
+ return out
148
+
149
+ def _inspect_init_params(self, target_module: str,
150
+ target_class: str) -> list[str]:
151
+ """Extract __init__ parameter strings (``name: type``)."""
152
+ import inspect
153
+
154
+ try:
155
+ module = importlib.import_module(target_module)
156
+ cls = getattr(module, target_class)
157
+ sig = inspect.signature(cls.__init__)
158
+ params: list[str] = []
159
+ for pname, p in sig.parameters.items():
160
+ if pname == "self":
161
+ continue
162
+ if p.kind in (inspect.Parameter.VAR_POSITIONAL,
163
+ inspect.Parameter.VAR_KEYWORD):
164
+ continue
165
+ ann = (
166
+ p.annotation.__name__
167
+ if p.annotation is not inspect.Parameter.empty
168
+ and hasattr(p.annotation, "__name__")
169
+ else str(p.annotation)
170
+ if p.annotation is not inspect.Parameter.empty
171
+ else "str"
172
+ )
173
+ params.append(f"{pname}: {ann}")
174
+ return params
175
+ except Exception:
176
+ return []
177
+
178
+ def _inspect_class_methods(
179
+ self, target_module: str, target_class: str,
180
+ ) -> list[CommandSignatureCard]:
181
+ """Live-inspect a class via importlib and return method signatures.
182
+
183
+ Falls back to AST scanning if import fails.
184
+ """
185
+ import inspect
186
+
187
+ try:
188
+ module = importlib.import_module(target_module)
189
+ cls = getattr(module, target_class)
190
+ cards: list[CommandSignatureCard] = []
191
+ for name, member in inspect.getmembers(cls, predicate=inspect.isfunction):
192
+ if name.startswith("_"):
193
+ continue
194
+ sig = inspect.signature(member)
195
+ params: list[str] = []
196
+ for pname, p in sig.parameters.items():
197
+ if pname == "self":
198
+ continue
199
+ ann = (
200
+ p.annotation.__name__
201
+ if p.annotation is not inspect.Parameter.empty
202
+ and hasattr(p.annotation, "__name__")
203
+ else str(p.annotation)
204
+ if p.annotation is not inspect.Parameter.empty
205
+ else "Any"
206
+ )
207
+ params.append(f"{pname}: {ann}")
208
+ ret = (
209
+ sig.return_annotation.__name__
210
+ if sig.return_annotation is not inspect.Signature.empty
211
+ and hasattr(sig.return_annotation, "__name__")
212
+ else "Any"
213
+ )
214
+ cards.append(CommandSignatureCard(
215
+ name=name,
216
+ parameters=params,
217
+ return_type=ret,
218
+ docstring=(inspect.getdoc(member) or "").split("\n")[0],
219
+ ))
220
+ return cards
221
+ except Exception:
222
+ return []
223
+
224
+ # ----- smoke test -------------------------------------------------
225
+
226
+ def smoke(self, cards: list[RegisteredCommandCard],
227
+ cli_invocation: list[str] | None = None) -> dict[str, bool]:
228
+ """Run ``<cli> <verb> --help`` for each card; record exit code == 0.
229
+
230
+ Subprocesses inherit ``PYTHONIOENCODING=utf-8`` so rich/Typer help
231
+ rendering doesn't blow up on Windows ``cp1252`` consoles.
232
+ """
233
+ import os
234
+ if cli_invocation is None:
235
+ # Use the forge CLI entry point.
236
+ cli_invocation = [sys.executable, "-m",
237
+ "atomadic_forge.a4_sy_orchestration.cli"]
238
+ env = os.environ.copy()
239
+ env["PYTHONIOENCODING"] = "utf-8"
240
+ env["PYTHONUTF8"] = "1"
241
+ results: dict[str, bool] = {}
242
+ for card in cards:
243
+ try:
244
+ rc = subprocess.run(
245
+ cli_invocation + [card["name"], "--help"],
246
+ capture_output=True, text=True, timeout=20,
247
+ env=env, encoding="utf-8", errors="replace",
248
+ ).returncode
249
+ except (subprocess.TimeoutExpired, FileNotFoundError):
250
+ rc = 1
251
+ results[card["name"]] = rc == 0
252
+ return results
253
+
254
+ # ----- one-shot pipeline -----------------------------------------
255
+
256
+ def sync(self, *, smoke: bool = False) -> RegistryManifestCard:
257
+ cards = self.discover()
258
+ self.write_registry(cards)
259
+ self.write_docs(cards)
260
+ smoke_results = self.smoke(cards) if smoke else {}
261
+ self.write_manifest(cards, smoke_results)
262
+ return RegistryManifestCard(
263
+ schema_version="atomadic-forge.commandsmith.registry/v1",
264
+ generated_at_utc=_dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
265
+ commands=cards,
266
+ smoke_results=smoke_results,
267
+ )
@@ -0,0 +1,3 @@
1
+ """mixed_pkg — Python side of the polyglot Forge demo."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Tier a0 — constants for mixed_pkg."""
2
+
3
+ GREETING_PREFIX: str = "hello"
4
+ MAX_NAME_LEN: int = 64
@@ -0,0 +1,14 @@
1
+ """Tier a1 — pure helpers for mixed_pkg. May import a0 only."""
2
+
3
+ from mixed_pkg.a0_qk_constants import GREETING_PREFIX, MAX_NAME_LEN
4
+
5
+
6
+ def greet(name: str) -> str:
7
+ """Return ``"hello, <name>"`` with a length cap. Pure."""
8
+ safe = name[:MAX_NAME_LEN].strip() or "world"
9
+ return f"{GREETING_PREFIX}, {safe}"
10
+
11
+
12
+ def length_within(name: str) -> bool:
13
+ """Return True if the name is at most MAX_NAME_LEN characters. Pure."""
14
+ return len(name) <= MAX_NAME_LEN