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,445 @@
1
+ """Tier a1 — pure certification checks for a Forge-shaped repo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from ..a0_qk_constants.lang_extensions import (
9
+ ALL_SOURCE_EXTS,
10
+ path_parts_contain_ignored_dir,
11
+ )
12
+ from ..a0_qk_constants.tier_names import TIER_NAMES
13
+ from .import_smoke import import_smoke
14
+ from .stub_detector import detect_stubs, stub_penalty
15
+ from .test_runner import run_pytest
16
+ from .wire_check import scan_violations
17
+
18
+
19
+ def _is_under_ignored(rel_parts: tuple[str, ...]) -> bool:
20
+ """Path-segment check against IGNORED_DIRS (used by every walk)."""
21
+ return path_parts_contain_ignored_dir(rel_parts)
22
+
23
+
24
+ def check_documentation(root: Path) -> tuple[bool, dict]:
25
+ """Documentation signal — markdown anywhere meaningful counts.
26
+
27
+ Recognises:
28
+ * README at root (``.md`` / ``.rst`` / no extension)
29
+ * Any ``.md`` / ``.markdown`` / ``.mdx`` under ``docs/``, ``doc/``,
30
+ ``documentation/``, ``guides/``, ``guide/``
31
+ * The repo passes if README exists, OR there are ≥2 doc files
32
+ anywhere in the recognised doc directories.
33
+ """
34
+ readme_names = {"README.md", "README.rst", "README.markdown",
35
+ "README", "readme.md"}
36
+ readme = any((root / n).exists() for n in readme_names)
37
+
38
+ # Find every directory anywhere in the tree whose basename matches a
39
+ # known doc-folder convention. Catches both top-level (./docs/) and
40
+ # nested (./cognition/guides/) layouts. IGNORED_DIRS still apply.
41
+ DOC_DIR_NAMES = {"docs", "doc", "documentation", "guides", "guide"}
42
+ doc_dirs_found: list[str] = []
43
+ doc_files: set[Path] = set()
44
+ samples: list[str] = []
45
+ seen_dirs: set[Path] = set()
46
+ for d in root.rglob("*"):
47
+ if not d.is_dir() or d.name not in DOC_DIR_NAMES:
48
+ continue
49
+ rel_parts = d.relative_to(root).parts
50
+ if _is_under_ignored(rel_parts):
51
+ continue
52
+ if d in seen_dirs:
53
+ continue
54
+ seen_dirs.add(d)
55
+ doc_dirs_found.append(d.relative_to(root).as_posix())
56
+ for p in d.rglob("*"):
57
+ if not p.is_file():
58
+ continue
59
+ rel_parts_p = p.relative_to(root).parts
60
+ if _is_under_ignored(rel_parts_p):
61
+ continue
62
+ if p.suffix.lower() in {".md", ".markdown", ".mdx", ".rst"}:
63
+ doc_files.add(p)
64
+ if len(samples) < 5:
65
+ samples.append(p.relative_to(root).as_posix())
66
+
67
+ doc_count = len(doc_files)
68
+ ok = readme or doc_count >= 2
69
+ return ok, {
70
+ "readme": readme,
71
+ "docs_md_count": doc_count,
72
+ "doc_dirs_found": doc_dirs_found,
73
+ "doc_samples": samples,
74
+ }
75
+
76
+
77
+ def check_tests_present(root: Path) -> tuple[bool, dict]:
78
+ """Return (ok, detail). Recognises Python AND JS/TS test conventions.
79
+
80
+ Counts:
81
+ Python — ``tests/test_*.py`` or ``tests/*_test.py``
82
+ JS/TS — ``tests/*.test.{js,mjs,jsx,ts,tsx}`` or ``*.spec.{js,…}``,
83
+ plus the ``__tests__/`` directory convention.
84
+ """
85
+ py_tests: set[Path] = set()
86
+ js_tests: set[Path] = set()
87
+ seen_dirs: set[Path] = set()
88
+ for d in root.rglob("tests"):
89
+ if not d.is_dir():
90
+ continue
91
+ rel_parts = d.relative_to(root).parts
92
+ if _is_under_ignored(rel_parts):
93
+ continue
94
+ if d in seen_dirs:
95
+ continue
96
+ seen_dirs.add(d)
97
+ py_tests.update(
98
+ p for p in d.rglob("test_*.py")
99
+ if not _is_under_ignored(p.relative_to(root).parts)
100
+ )
101
+ py_tests.update(
102
+ p for p in d.rglob("*_test.py")
103
+ if not _is_under_ignored(p.relative_to(root).parts)
104
+ )
105
+ for ext in (".js", ".mjs", ".jsx", ".cjs", ".ts", ".tsx"):
106
+ js_tests.update(
107
+ p for p in d.rglob(f"*.test{ext}")
108
+ if not _is_under_ignored(p.relative_to(root).parts)
109
+ )
110
+ js_tests.update(
111
+ p for p in d.rglob(f"*.spec{ext}")
112
+ if not _is_under_ignored(p.relative_to(root).parts)
113
+ )
114
+ # __tests__ convention (Jest etc.)
115
+ for d in root.rglob("__tests__"):
116
+ if not d.is_dir():
117
+ continue
118
+ rel_parts = d.relative_to(root).parts
119
+ if _is_under_ignored(rel_parts):
120
+ continue
121
+ for ext in (".js", ".mjs", ".jsx", ".cjs", ".ts", ".tsx"):
122
+ js_tests.update(
123
+ p for p in d.rglob(f"*{ext}")
124
+ if not _is_under_ignored(p.relative_to(root).parts)
125
+ )
126
+ total = len(py_tests) + len(js_tests)
127
+ return total > 0, {
128
+ "test_files_found": total,
129
+ "python_tests": len(py_tests),
130
+ "javascript_tests": len(js_tests),
131
+ }
132
+
133
+
134
+ def _collect_tier_dirs(root: Path) -> list[str]:
135
+ """Return tier directories present anywhere under ``root``.
136
+
137
+ Polyglot-aware: a tier-named directory anywhere in the tree (Python
138
+ ``src/<pkg>/aN_*/`` OR JS-style top-level / nested ``aN_*/``) counts.
139
+ Each tier name is reported at most once. Honours IGNORED_DIRS so
140
+ vendored / cached / tooling folders never leak into the scan.
141
+ """
142
+ found: set[str] = set()
143
+ for d in root.rglob("*"):
144
+ if not d.is_dir():
145
+ continue
146
+ rel_parts = d.relative_to(root).parts
147
+ if _is_under_ignored(rel_parts):
148
+ continue
149
+ if d.name in TIER_NAMES:
150
+ found.add(d.name)
151
+ if len(found) == len(TIER_NAMES):
152
+ break
153
+ return sorted(found)
154
+
155
+
156
+ def count_untiered_source_files(root: Path) -> dict:
157
+ """How many SOURCE files (Python/JS/TS) live outside any tier directory?
158
+
159
+ Documentation, config, asset, and other-classed files are deliberately
160
+ excluded — markdown placed in ``cognition/guides/`` doesn't have a tier
161
+ identity and shouldn't be treated as code-out-of-place. Scoring code
162
+ (e.g. future stricter layout penalties) should base its judgement on
163
+ this count, not the raw file count.
164
+ """
165
+ untiered: list[str] = []
166
+ tiered: list[str] = []
167
+ for p in root.rglob("*"):
168
+ if not p.is_file():
169
+ continue
170
+ rel_parts = p.relative_to(root).parts
171
+ if _is_under_ignored(rel_parts):
172
+ continue
173
+ if p.suffix.lower() not in ALL_SOURCE_EXTS:
174
+ continue
175
+ # Source file — check if any of its path segments is a tier dir.
176
+ in_tier = any(seg in TIER_NAMES for seg in rel_parts)
177
+ rel_path = p.relative_to(root).as_posix()
178
+ if in_tier:
179
+ tiered.append(rel_path)
180
+ else:
181
+ untiered.append(rel_path)
182
+ return {
183
+ "untiered_source_count": len(untiered),
184
+ "tiered_source_count": len(tiered),
185
+ "untiered_samples": untiered[:10],
186
+ }
187
+
188
+
189
+ def check_tier_layout(root: Path, package: str | None = None) -> tuple[bool, dict]:
190
+ src = root / "src"
191
+ base = src if src.exists() else root
192
+ if package:
193
+ candidate = base / package
194
+ if candidate.exists():
195
+ base = candidate
196
+ present = [t for t in TIER_NAMES if (base / t).exists()]
197
+ polyglot_present: list[str] = []
198
+ if len(present) < 3:
199
+ polyglot_present = _collect_tier_dirs(root)
200
+ if len(polyglot_present) > len(present):
201
+ present = polyglot_present
202
+ ok = len(present) >= 3
203
+ return ok, {
204
+ "tiers_present": present,
205
+ "tiers_present_count": len(present),
206
+ "tiers_required": 3,
207
+ }
208
+
209
+
210
+ def check_no_upward_imports(root: Path, package: str | None = None) -> tuple[bool, dict]:
211
+ src = root / "src"
212
+ base = src if src.exists() else root
213
+ if package and (base / package).exists():
214
+ base = base / package
215
+ report = scan_violations(base)
216
+ return report["verdict"] == "PASS", {
217
+ "violation_count": report["violation_count"],
218
+ "samples": report["violations"][:5],
219
+ }
220
+
221
+
222
+ def check_ci_workflow(root: Path) -> tuple[bool, dict]:
223
+ """Continuous-integration evidence — ``.github/workflows/*.yml``.
224
+
225
+ Looks for at least one non-empty workflow file under
226
+ ``.github/workflows/`` (``.yml`` or ``.yaml``). Presence of a
227
+ workflow is treated as evidence of automated quality gating; we
228
+ deliberately do NOT call out to the GitHub API to inspect run
229
+ history, so this remains a hermetic structural check.
230
+
231
+ A project that wires `pytest`, `forge wire`, and `forge certify`
232
+ into its CI pipeline gets the same 5 points as one that wires only
233
+ a smoke test — the axis rewards intent, not depth. The behavioural
234
+ axis is what rewards actual test-pass behaviour.
235
+ """
236
+ wf_dir = root / ".github" / "workflows"
237
+ if not wf_dir.exists() or not wf_dir.is_dir():
238
+ return False, {
239
+ "workflow_dir_exists": False,
240
+ "workflow_files": [],
241
+ }
242
+ files: list[str] = []
243
+ for p in sorted(wf_dir.iterdir()):
244
+ if not p.is_file():
245
+ continue
246
+ if p.suffix.lower() not in {".yml", ".yaml"}:
247
+ continue
248
+ try:
249
+ if p.stat().st_size > 0:
250
+ files.append(p.name)
251
+ except OSError:
252
+ continue
253
+ return len(files) > 0, {
254
+ "workflow_dir_exists": True,
255
+ "workflow_files": files,
256
+ }
257
+
258
+
259
+ def check_changelog(root: Path) -> tuple[bool, dict]:
260
+ """Release-discipline evidence — ``CHANGELOG.md`` or equivalent.
261
+
262
+ Looks for any of the canonical release-notes filenames at the
263
+ project root and credits the project iff the file is non-trivial
264
+ (≥ 200 bytes) — empty placeholders don't earn the points.
265
+
266
+ Recognised names: ``CHANGELOG.md``, ``CHANGELOG.rst``, ``CHANGELOG``,
267
+ ``RELEASE_NOTES.md``, ``HISTORY.md``, ``NEWS.md``.
268
+ """
269
+ candidates = (
270
+ "CHANGELOG.md", "CHANGELOG.rst", "CHANGELOG",
271
+ "RELEASE_NOTES.md", "HISTORY.md", "NEWS.md",
272
+ )
273
+ for name in candidates:
274
+ p = root / name
275
+ if not p.exists() or not p.is_file():
276
+ continue
277
+ try:
278
+ size = p.stat().st_size
279
+ except OSError:
280
+ continue
281
+ if size >= 200:
282
+ return True, {"changelog_file": name, "size_bytes": size}
283
+ return False, {"changelog_file": None, "size_bytes": 0}
284
+
285
+
286
+ def certify(root: Path, *, project: str = "Atomadic project",
287
+ package: str | None = None) -> dict:
288
+ docs_ok, docs_d = check_documentation(root)
289
+ tests_ok, tests_d = check_tests_present(root)
290
+ layout_ok, layout_d = check_tier_layout(root, package)
291
+ wire_ok, wire_d = check_no_upward_imports(root, package)
292
+ ci_ok, ci_d = check_ci_workflow(root)
293
+ changelog_ok, changelog_d = check_changelog(root)
294
+
295
+ # Stub-body detection: scan the package itself (where generated code lives).
296
+ # Only recurse when we have a well-defined src/ layout; falling back to the
297
+ # project root would include all nested forged/sources directories and produce
298
+ # tens of thousands of false positives from extracted-stub skeletons.
299
+ src_for_stubs = root / "src"
300
+ if package and (src_for_stubs / package).exists():
301
+ src_for_stubs = src_for_stubs / package
302
+ elif src_for_stubs.exists():
303
+ pass # use root/src/ as-is
304
+ else:
305
+ # No src/ layout — don't recurse into the full tree; that risks picking
306
+ # up forged/ or sources/ directories. Scan only Python files directly
307
+ # inside the project root (non-recursive).
308
+ src_for_stubs = None
309
+ stub_findings = detect_stubs(package_root=src_for_stubs) if src_for_stubs else []
310
+ stub_pen = stub_penalty(stub_findings)
311
+ no_stubs = stub_pen == 0
312
+
313
+ # Runtime import smoke — does the package actually load?
314
+ # Default to False when there's nothing to import; the import-points are
315
+ # earned by an actual successful import, not by absence.
316
+ smoke: dict | None = None
317
+ importable = False
318
+ if package and (root / "src" / package).exists():
319
+ smoke = dict(import_smoke(output_root=root, package=package))
320
+ importable = smoke["importable"]
321
+ elif not package:
322
+ # No package specified and we can't run a smoke — exempt from this
323
+ # check so legacy callers without a package layout don't see a 40-point
324
+ # blanket deduction.
325
+ importable = True
326
+
327
+ # Behavioral check — actually run pytest against the emitted tests/.
328
+ # This is the breakthrough signal: a package can pass wire + import and
329
+ # still be a no-op stub. Running its own tests catches that.
330
+ test_run: dict | None = None
331
+ test_pass_ratio = 0.0
332
+ if importable and (root / "tests").exists() and tests_ok:
333
+ test_run = dict(run_pytest(output_root=root, package=package))
334
+ test_pass_ratio = test_run["pass_ratio"]
335
+ elif not (root / "tests").exists() or not tests_ok:
336
+ # No tests means we can't credit the ratio — and the structural
337
+ # 'tests_present' check already caught the absence.
338
+ test_pass_ratio = 0.0
339
+
340
+ issues: list[str] = []
341
+ recs: list[str] = []
342
+ if not docs_ok:
343
+ issues.append("Documentation incomplete — add README.md or docs/*.md")
344
+ recs.append("Run `forge auto` to scaffold a docs starter.")
345
+ if not tests_ok:
346
+ issues.append("No test files under tests/")
347
+ recs.append("Add tests/test_*.py before claiming production-ready.")
348
+ if not layout_ok:
349
+ present_count = layout_d.get("tiers_present_count", 0)
350
+ present_list = ", ".join(layout_d.get("tiers_present", [])) or "none"
351
+ issues.append(
352
+ f"Tier layout missing — found {present_count} tier "
353
+ f"director{'y' if present_count == 1 else 'ies'} ({present_list}); "
354
+ "need 3+ of a0_qk_constants/a1_at_functions/a2_mo_composites/"
355
+ "a3_og_features/a4_sy_orchestration."
356
+ )
357
+ recs.append("Split your code into the canonical aN_* directories "
358
+ "(or run `forge auto` to scaffold them).")
359
+ if not wire_ok:
360
+ issues.append(f"Upward-import violations: {wire_d['violation_count']}")
361
+ recs.append("Run `forge wire` to inspect violations, then move imports down-tier or split modules.")
362
+ if not no_stubs:
363
+ issues.append(f"Stub bodies detected: {len(stub_findings)} "
364
+ "function(s) with `pass`/NotImplementedError/TODO")
365
+ for f in stub_findings[:5]:
366
+ issues.append(f" · {f['file']}:{f['lineno']} {f['qualname']} ({f['kind']})")
367
+ recs.append("Replace stub bodies with real implementations before shipping.")
368
+ if smoke is not None and not importable:
369
+ issues.append(f"Package fails to import: {smoke['error_kind']} — "
370
+ f"{smoke['error_message']}")
371
+ recs.append("Fix the import error so the package is loadable; the "
372
+ "wire scan can pass while the runtime fails.")
373
+ if test_run is not None and test_run.get("ran") and test_run["failed"]:
374
+ issues.append(
375
+ f"Tests failed: {test_run['failed']} of {test_run['total']} "
376
+ f"(pass-ratio {test_pass_ratio:.1%})"
377
+ )
378
+ for fid in (test_run.get("failure_excerpts") or [])[:5]:
379
+ issues.append(f" · {fid}")
380
+ recs.append("Fix the failing tests — wire/import alone does not "
381
+ "prove behavior.")
382
+ if not ci_ok:
383
+ issues.append("No CI workflow found under .github/workflows/")
384
+ recs.append("Add a CI workflow (.github/workflows/ci.yml) that "
385
+ "runs pytest + `forge wire` + `forge certify` on push.")
386
+ if not changelog_ok:
387
+ issues.append("No CHANGELOG / release-notes file at project root")
388
+ recs.append("Add CHANGELOG.md (Keep-a-Changelog format) so each "
389
+ "release documents what changed and why.")
390
+
391
+ # Score weights (sum to 100):
392
+ # docs / layout / wire — 10 each (30 max — structural axis)
393
+ # tests-present — 5 (structural axis)
394
+ # importable runtime — 25 (runtime axis)
395
+ # tests-pass-ratio — 30 max (behavioural axis — rewards actual behaviour)
396
+ # ci workflow — 5 (operational axis)
397
+ # changelog/release notes — 5 (operational axis)
398
+ # stub-body penalty — up to 40 deducted
399
+ # Total max: 35 + 25 + 30 + 10 = 100.
400
+ structural = (
401
+ (10 if docs_ok else 0)
402
+ + (10 if layout_ok else 0)
403
+ + (10 if wire_ok else 0)
404
+ + (5 if tests_ok else 0)
405
+ )
406
+ runtime = (25 if importable else 0)
407
+ behavioral = 30 if test_pass_ratio == 1.0 else int(30.0 * test_pass_ratio)
408
+ operational = (
409
+ (5 if ci_ok else 0)
410
+ + (5 if changelog_ok else 0)
411
+ )
412
+ score = max(0.0, float(structural + runtime + behavioral + operational) - stub_pen)
413
+ return {
414
+ "schema_version": "atomadic-forge.certify/v1",
415
+ "project": project,
416
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
417
+ "documentation_complete": docs_ok,
418
+ "tests_present": tests_ok,
419
+ "tier_layout_present": layout_ok,
420
+ "no_upward_imports": wire_ok,
421
+ "no_stub_bodies": no_stubs,
422
+ "package_importable": importable,
423
+ "test_pass_ratio": test_pass_ratio,
424
+ "ci_workflow_present": ci_ok,
425
+ "changelog_present": changelog_ok,
426
+ "score": score,
427
+ "score_components": {
428
+ "structural": structural,
429
+ "runtime": runtime,
430
+ "behavioral": behavioral,
431
+ "operational": operational,
432
+ "stub_penalty": -stub_pen,
433
+ },
434
+ "issues": issues,
435
+ "recommendations": recs,
436
+ "detail": {"docs": docs_d, "tests": tests_d, "layout": layout_d,
437
+ "wire": wire_d,
438
+ "ci": ci_d,
439
+ "changelog": changelog_d,
440
+ "stubs": {"count": len(stub_findings),
441
+ "findings": stub_findings[:20]},
442
+ "import_smoke": smoke,
443
+ "test_run": test_run,
444
+ "untiered_source": count_untiered_source_files(root)},
445
+ }
@@ -0,0 +1,170 @@
1
+ """Tier a1 — bounded repo context for the Forge chat copilot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..a0_qk_constants.lang_extensions import IGNORED_DIRS, file_class_for_path
9
+
10
+ _CONTEXT_CLASSES = {"source", "documentation", "config"}
11
+ _SENSITIVE_NAMES = {".env", ".env.local", ".envrc"}
12
+ _SENSITIVE_SUBSTRINGS = ("secret", "credential", "private_key")
13
+ _SENSITIVE_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
14
+ _MAX_FILE_CHARS = 4_000
15
+
16
+
17
+ def chat_system_prompt() -> str:
18
+ """System prompt for chat-only copilot sessions."""
19
+ return (
20
+ "You are Atomadic Forge's chat copilot. Help the user operate this "
21
+ "CLI product, understand repo structure, plan safe commands, and wire "
22
+ "AI-agent workflows through Forge's provider layer. Be concise, be "
23
+ "specific, and prefer concrete commands. If repository context is "
24
+ "provided, ground your answer in it. Do not claim you executed a "
25
+ "command; you are only responding to the chat prompt."
26
+ )
27
+
28
+
29
+ def build_chat_context(paths: list[Path], *, cwd: Path,
30
+ max_files: int = 12,
31
+ max_chars: int = 16_000) -> dict[str, Any]:
32
+ """Pack selected repo files into a bounded Markdown context block."""
33
+ cwd = cwd.resolve()
34
+ files = _collect_context_files(paths, cwd=cwd)[:max_files]
35
+ chunks: list[str] = []
36
+ used = 0
37
+ included: list[dict[str, Any]] = []
38
+ for file_path in files:
39
+ try:
40
+ text = file_path.read_text(encoding="utf-8", errors="replace")
41
+ except OSError:
42
+ continue
43
+ truncated_file = len(text) > _MAX_FILE_CHARS
44
+ if truncated_file:
45
+ text = text[:_MAX_FILE_CHARS] + "\n... [file truncated]\n"
46
+ rel = _rel_display(file_path, cwd)
47
+ chunk = f"### {rel}\n\n```{_fence_lang(file_path)}\n{text}\n```\n"
48
+ remaining = max_chars - used
49
+ if remaining <= 0:
50
+ break
51
+ truncated_context = len(chunk) > remaining
52
+ if truncated_context:
53
+ chunk = chunk[:remaining] + "\n... [context budget exhausted]\n"
54
+ chunks.append(chunk)
55
+ used += len(chunk)
56
+ included.append({
57
+ "path": rel,
58
+ "chars": min(len(text), _MAX_FILE_CHARS),
59
+ "truncated": truncated_file or truncated_context,
60
+ })
61
+ if truncated_context:
62
+ break
63
+ return {
64
+ "context": "\n".join(chunks).strip(),
65
+ "files": included,
66
+ "file_count": len(included),
67
+ "char_count": used,
68
+ }
69
+
70
+
71
+ def render_chat_prompt(message: str, *, context: str = "",
72
+ history: list[dict[str, str]] | None = None) -> str:
73
+ """Render one chat turn with optional context and prior turns."""
74
+ parts = [
75
+ "# User request",
76
+ message.strip(),
77
+ ]
78
+ if history:
79
+ parts.extend(["", "# Prior chat"])
80
+ for turn in history[-8:]:
81
+ role = turn.get("role", "user")
82
+ content = turn.get("content", "").strip()
83
+ if content:
84
+ parts.append(f"{role}: {content}")
85
+ if context:
86
+ parts.extend(["", "# Repository context", context])
87
+ return "\n".join(parts).strip() + "\n"
88
+
89
+
90
+ def _collect_context_files(paths: list[Path], *, cwd: Path) -> list[Path]:
91
+ out: list[Path] = []
92
+ seen: set[Path] = set()
93
+ for path in paths:
94
+ p = path if path.is_absolute() else cwd / path
95
+ if not p.exists():
96
+ continue
97
+ candidates = [p] if p.is_file() else sorted(p.rglob("*"))
98
+ for candidate in candidates:
99
+ if not candidate.is_file():
100
+ continue
101
+ if _ignored(candidate, cwd):
102
+ continue
103
+ if _sensitive(candidate):
104
+ continue
105
+ if file_class_for_path(candidate.as_posix()) not in _CONTEXT_CLASSES:
106
+ continue
107
+ resolved = candidate.resolve()
108
+ if resolved in seen:
109
+ continue
110
+ seen.add(resolved)
111
+ out.append(resolved)
112
+ return sorted(out, key=lambda p: (_priority(p), p.as_posix().lower()))
113
+
114
+
115
+ def _ignored(path: Path, cwd: Path) -> bool:
116
+ try:
117
+ parts = path.resolve().relative_to(cwd).parts
118
+ except ValueError:
119
+ parts = path.parts
120
+ return any(part in IGNORED_DIRS for part in parts)
121
+
122
+
123
+ def _sensitive(path: Path) -> bool:
124
+ name = path.name.lower()
125
+ return (
126
+ name in _SENSITIVE_NAMES
127
+ or name.endswith(_SENSITIVE_SUFFIXES)
128
+ or any(piece in name for piece in _SENSITIVE_SUBSTRINGS)
129
+ )
130
+
131
+
132
+ def _priority(path: Path) -> tuple[int, int]:
133
+ name = path.name.lower()
134
+ stem = path.stem.lower()
135
+ if name.startswith("readme"):
136
+ return (0, len(path.parts))
137
+ if name in {"pyproject.toml", "package.json"}:
138
+ return (1, len(path.parts))
139
+ if "docs" in {p.lower() for p in path.parts}:
140
+ return (2, len(path.parts))
141
+ if "src" in {p.lower() for p in path.parts}:
142
+ return (3, len(path.parts))
143
+ if stem.startswith("test_") or name.endswith(".test.js"):
144
+ return (4, len(path.parts))
145
+ return (5, len(path.parts))
146
+
147
+
148
+ def _rel_display(path: Path, cwd: Path) -> str:
149
+ try:
150
+ return path.relative_to(cwd).as_posix()
151
+ except ValueError:
152
+ return path.as_posix()
153
+
154
+
155
+ def _fence_lang(path: Path) -> str:
156
+ ext = path.suffix.lower()
157
+ return {
158
+ ".py": "python",
159
+ ".js": "javascript",
160
+ ".mjs": "javascript",
161
+ ".cjs": "javascript",
162
+ ".jsx": "jsx",
163
+ ".ts": "typescript",
164
+ ".tsx": "tsx",
165
+ ".md": "markdown",
166
+ ".toml": "toml",
167
+ ".json": "json",
168
+ ".yaml": "yaml",
169
+ ".yml": "yaml",
170
+ }.get(ext, "")
@@ -0,0 +1,71 @@
1
+ """Tier a1 — pure cherry-picker.
2
+
3
+ Given a scout report and a selection (names or 'all'), produce a manifest
4
+ that downstream assimilate consumes. Conflict resolution is deferred to
5
+ the assimilate phase; this layer just records intent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable
11
+
12
+
13
+ def select_items(scout_report: dict, *, names: Iterable[str] | None = None,
14
+ pick_all: bool = False, min_confidence: float = 0.0,
15
+ only_tier: str | None = None) -> dict:
16
+ """Build a cherry_pick manifest from a scout report.
17
+
18
+ Filters:
19
+ * ``names`` — explicit qualnames. None ⇒ apply other filters.
20
+ * ``pick_all`` — take every symbol that passes the other filters.
21
+ * ``min_confidence`` — drop symbols with low classification confidence.
22
+ (Forge currently emits 0.0 for hard hits and 0.5 for fallbacks; future
23
+ versions will produce richer signals.)
24
+ * ``only_tier`` — restrict to symbols already classified to this tier.
25
+ """
26
+ syms = scout_report.get("symbols", [])
27
+ wanted = set(names) if names else None
28
+ items: list[dict] = []
29
+ for s in syms:
30
+ if wanted is not None and s["qualname"] not in wanted and s["name"] not in wanted:
31
+ continue
32
+ if not pick_all and wanted is None:
33
+ continue
34
+ if only_tier and s["tier_guess"] != only_tier:
35
+ continue
36
+ items.append({
37
+ "qualname": s["qualname"],
38
+ "target_tier": s["tier_guess"],
39
+ "confidence": _confidence_of(s),
40
+ "reasons": _reasons_for(s),
41
+ })
42
+ return {
43
+ "schema_version": "atomadic-forge.cherry/v1",
44
+ "source_repo": scout_report.get("repo", ""),
45
+ "items": items,
46
+ }
47
+
48
+
49
+ def _confidence_of(symbol: dict) -> float:
50
+ # High confidence when name tokens drove the decision; lower when we fell
51
+ # back to the kind-default (function→a1, class→a2). Cheap proxy: complexity
52
+ # below 800 ⇒ small hand-shaped helper, easy to reason about.
53
+ base = 0.7 if symbol["complexity"] < 800 else 0.6
54
+ if symbol["has_self_assign"]:
55
+ base += 0.1
56
+ if symbol["effects"] == ["pure"]:
57
+ base += 0.05
58
+ return min(0.95, round(base, 2))
59
+
60
+
61
+ def _reasons_for(symbol: dict) -> list[str]:
62
+ out: list[str] = []
63
+ if symbol["has_self_assign"]:
64
+ out.append("class with mutable instance state ⇒ a2 promotion")
65
+ if symbol["effects"] == ["pure"]:
66
+ out.append("no detected I/O or state — clean composition target")
67
+ if "io" in symbol["effects"]:
68
+ out.append("I/O-heavy — needs sandboxing on absorb")
69
+ if not out:
70
+ out.append("standard tier-classification fit")
71
+ return out