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,267 @@
1
+ """Tier a1 — pure unified-diff risk scorer.
2
+
3
+ Codex's 'Copilot's Copilot' primitive #3:
4
+
5
+ > score_patch({diff}) — returns architecture risk, test risk,
6
+ > public API risk, release risk, 'needs human review?' boolean,
7
+ > suggested validation commands. Turns Forge into a PR reviewer
8
+ > before the PR exists.
9
+
10
+ Pure: parses a unified-diff string, classifies per-file changes,
11
+ returns a structured risk report. Heuristic — false positives are
12
+ expected; the value is the structured prompt for the agent to
13
+ think about before applying the diff.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import TypedDict
19
+
20
+ SCHEMA_VERSION_PATCH_SCORE_V1 = "atomadic-forge.patch_score/v1"
21
+
22
+
23
+ _FILE_HEADER_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t|\s|$)", re.MULTILINE)
24
+ _TIER_NAMES = (
25
+ "a0_qk_constants", "a1_at_functions", "a2_mo_composites",
26
+ "a3_og_features", "a4_sy_orchestration",
27
+ )
28
+ _PUBLIC_API_PATHS = ("__init__.py",)
29
+ _RELEASE_FILES = (
30
+ "pyproject.toml", "setup.py", "setup.cfg", "package.json",
31
+ "Cargo.toml", "version.py", "VERSION", "_version.py",
32
+ "CHANGELOG.md", "LICENSE",
33
+ )
34
+ _FORBIDDEN_BY_TIER: dict[str, tuple[str, ...]] = {
35
+ "a0_qk_constants": (
36
+ "a1_at_functions", "a2_mo_composites",
37
+ "a3_og_features", "a4_sy_orchestration",
38
+ ),
39
+ "a1_at_functions": (
40
+ "a2_mo_composites", "a3_og_features", "a4_sy_orchestration",
41
+ ),
42
+ "a2_mo_composites": ("a3_og_features", "a4_sy_orchestration"),
43
+ "a3_og_features": ("a4_sy_orchestration",),
44
+ "a4_sy_orchestration": (),
45
+ }
46
+
47
+
48
+ class PatchFileScore(TypedDict, total=False):
49
+ path: str
50
+ detected_tier: str | None
51
+ added_lines: int
52
+ removed_lines: int
53
+ architectural_risk: bool
54
+ public_api_risk: bool
55
+ release_risk: bool
56
+ notes: list[str]
57
+
58
+
59
+ class PatchScore(TypedDict, total=False):
60
+ schema_version: str
61
+ file_count: int
62
+ total_added: int
63
+ total_removed: int
64
+ architectural_risk: bool
65
+ test_risk: bool
66
+ public_api_risk: bool
67
+ release_risk: bool
68
+ needs_human_review: bool
69
+ files: list[PatchFileScore]
70
+ suggested_validation_commands: list[str]
71
+ notes: list[str]
72
+
73
+
74
+ def _split_diff_per_file(diff: str) -> list[tuple[str, str]]:
75
+ """Return [(path, file_diff_text), ...] for a unified diff."""
76
+ chunks: list[tuple[str, str]] = []
77
+ current_path: str | None = None
78
+ current: list[str] = []
79
+ for line in diff.splitlines():
80
+ if line.startswith("+++ b/"):
81
+ # Flush prior chunk
82
+ if current_path is not None and current:
83
+ chunks.append((current_path, "\n".join(current)))
84
+ current_path = line[len("+++ b/"):].split("\t", 1)[0].strip()
85
+ current = []
86
+ else:
87
+ current.append(line)
88
+ if current_path is not None and current:
89
+ chunks.append((current_path, "\n".join(current)))
90
+ return chunks
91
+
92
+
93
+ def _detect_tier(path: str) -> str | None:
94
+ for part in path.split("/"):
95
+ if part in _TIER_NAMES:
96
+ return part
97
+ return None
98
+
99
+
100
+ def _check_added_imports_against_tier(
101
+ path: str, tier: str | None, diff_text: str,
102
+ ) -> list[str]:
103
+ """Look at '+' lines for upward imports against the file's tier."""
104
+ if not tier:
105
+ return []
106
+ forbidden = _FORBIDDEN_BY_TIER.get(tier, ())
107
+ if not forbidden:
108
+ return []
109
+ issues: list[str] = []
110
+ for raw in diff_text.splitlines():
111
+ if not raw.startswith("+") or raw.startswith("+++"):
112
+ continue
113
+ body = raw[1:].lstrip()
114
+ if not body.startswith(("import ", "from ")):
115
+ continue
116
+ for f in forbidden:
117
+ if f in body:
118
+ issues.append(
119
+ f"new import in {path} references forbidden tier "
120
+ f"{f!r}: {body[:120]}"
121
+ )
122
+ break
123
+ return issues
124
+
125
+
126
+ def _file_score(path: str, diff_text: str) -> PatchFileScore:
127
+ added = sum(1 for line in diff_text.splitlines()
128
+ if line.startswith("+") and not line.startswith("+++"))
129
+ removed = sum(1 for line in diff_text.splitlines()
130
+ if line.startswith("-") and not line.startswith("---"))
131
+ tier = _detect_tier(path)
132
+ notes: list[str] = []
133
+ arch_risk = False
134
+ api_risk = False
135
+ rel_risk = False
136
+
137
+ upward = _check_added_imports_against_tier(path, tier, diff_text)
138
+ if upward:
139
+ arch_risk = True
140
+ notes.extend(upward)
141
+
142
+ if path.endswith(_PUBLIC_API_PATHS):
143
+ api_risk = True
144
+ notes.append(
145
+ f"{path} is a public-export surface; an unintended "
146
+ "deletion can break downstream callers"
147
+ )
148
+
149
+ base = path.split("/")[-1]
150
+ if base in _RELEASE_FILES or path.endswith(_RELEASE_FILES):
151
+ rel_risk = True
152
+ notes.append(
153
+ f"{path} is a release-control file; review version bump "
154
+ "+ changelog impact before merging"
155
+ )
156
+
157
+ return PatchFileScore(
158
+ path=path,
159
+ detected_tier=tier,
160
+ added_lines=added,
161
+ removed_lines=removed,
162
+ architectural_risk=arch_risk,
163
+ public_api_risk=api_risk,
164
+ release_risk=rel_risk,
165
+ notes=notes,
166
+ )
167
+
168
+
169
+ def score_patch(diff: str, *, project_root: object | None = None) -> PatchScore:
170
+ """Score a unified-diff string. Returns the patch_score/v1 shape.
171
+
172
+ Empty or unparseable diffs return a low-risk report with a note
173
+ instead of raising — the agent should never see a traceback when
174
+ asking 'is this safe?'.
175
+
176
+ Codex-6: when ``project_root`` is provided AND that project has a
177
+ [tool.forge.agent] policy with protected_files, any diff touching
178
+ a protected file is flagged as ``needs_human_review``.
179
+ """
180
+ if not diff or not diff.strip():
181
+ return PatchScore(
182
+ schema_version=SCHEMA_VERSION_PATCH_SCORE_V1,
183
+ file_count=0, total_added=0, total_removed=0,
184
+ architectural_risk=False, test_risk=False,
185
+ public_api_risk=False, release_risk=False,
186
+ needs_human_review=False, files=[],
187
+ suggested_validation_commands=[],
188
+ notes=["empty diff — nothing to score"],
189
+ )
190
+
191
+ chunks = _split_diff_per_file(diff)
192
+ files = [_file_score(p, txt) for p, txt in chunks]
193
+
194
+ arch_risk = any(f["architectural_risk"] for f in files)
195
+ api_risk = any(f["public_api_risk"] for f in files)
196
+ rel_risk = any(f["release_risk"] for f in files)
197
+
198
+ src_paths = [f for f in files
199
+ if not f["path"].startswith(("tests/", "test/"))
200
+ and not f["path"].endswith(("_test.py",))]
201
+ test_paths = [f for f in files
202
+ if f["path"].startswith(("tests/", "test/"))
203
+ or f["path"].endswith(("_test.py",))]
204
+ test_risk = bool(src_paths) and not test_paths
205
+
206
+ notes: list[str] = []
207
+ if test_risk:
208
+ notes.append(
209
+ "code changed without tests touched — add coverage or "
210
+ "explicitly justify why existing tests are sufficient"
211
+ )
212
+ if not chunks:
213
+ notes.append(
214
+ "diff parsed but no '+++ b/<path>' headers found — "
215
+ "unified-diff format expected"
216
+ )
217
+
218
+ # Codex-6: respect [tool.forge.agent] protected_files when a
219
+ # project_root is provided. Lazy import to avoid a circular path.
220
+ protected_hits: list[str] = []
221
+ if project_root is not None:
222
+ try:
223
+ from pathlib import Path as _Path
224
+
225
+ from .policy_loader import file_is_protected, load_policy
226
+ policy = load_policy(_Path(str(project_root)))
227
+ for f in files:
228
+ if file_is_protected(f["path"], policy):
229
+ protected_hits.append(f["path"])
230
+ f.setdefault("notes", []).append(
231
+ "policy: file listed in protected_files — needs review"
232
+ )
233
+ except Exception: # noqa: BLE001
234
+ pass
235
+
236
+ needs_review = arch_risk or api_risk or rel_risk or bool(protected_hits) or (
237
+ sum(f["added_lines"] for f in files) > 200
238
+ )
239
+ if protected_hits:
240
+ notes.append(
241
+ f"diff touches {len(protected_hits)} file(s) listed in "
242
+ "[tool.forge.agent] protected_files — needs_human_review=True"
243
+ )
244
+ if needs_review and "review" not in " ".join(notes):
245
+ notes.append(
246
+ "needs_human_review=True because of architectural / "
247
+ "public-API / release / large-patch risk — do NOT auto-merge"
248
+ )
249
+
250
+ return PatchScore(
251
+ schema_version=SCHEMA_VERSION_PATCH_SCORE_V1,
252
+ file_count=len(files),
253
+ total_added=sum(f["added_lines"] for f in files),
254
+ total_removed=sum(f["removed_lines"] for f in files),
255
+ architectural_risk=arch_risk,
256
+ test_risk=test_risk,
257
+ public_api_risk=api_risk,
258
+ release_risk=rel_risk,
259
+ needs_human_review=needs_review,
260
+ files=files,
261
+ suggested_validation_commands=[
262
+ "forge wire src --fail-on-violations",
263
+ "python -m pytest",
264
+ "forge certify . --fail-under 75",
265
+ ],
266
+ notes=notes,
267
+ )
@@ -0,0 +1,75 @@
1
+ """Tier a1 — adapt_plan: capability-aware card filtering (Codex #8).
2
+
3
+ Codex's prescription:
4
+
5
+ > Different agents have different strengths: some can edit, some
6
+ > can only review, some can run commands, some cannot access
7
+ > network. adapt_plan({agent_capabilities}) tailors action cards:
8
+ > 'agent can apply', 'agent should ask human', 'agent should
9
+ > delegate', 'agent should only report'.
10
+
11
+ Pure: takes an existing AgentPlan + capability set, returns a new
12
+ plan with each card's recommended_handling field populated.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from copy import deepcopy
17
+
18
+ SCHEMA_VERSION_ADAPTED_PLAN_V1 = "atomadic-forge.agent_plan_adapted/v1"
19
+
20
+
21
+ # Known agent capability slugs.
22
+ _KNOWN_CAPABILITIES: frozenset[str] = frozenset({
23
+ "edit_files", # can write to disk
24
+ "run_commands", # can shell out
25
+ "network", # can reach external HTTP
26
+ "review", # can analyse but not modify
27
+ "delegate", # can spawn other agents
28
+ })
29
+
30
+
31
+ def _handling_for(card: dict, *, capabilities: set[str]) -> str:
32
+ """Map a card to one of:
33
+ apply — agent has every capability the card needs
34
+ delegate — agent should spawn / invoke another tool
35
+ ask_human — card needs out-of-band judgement
36
+ report_only — agent should surface, not act
37
+ """
38
+ applyable = bool(card.get("applyable"))
39
+ if not applyable:
40
+ return "ask_human"
41
+ kind = card.get("kind", "")
42
+ risk = card.get("risk", "high")
43
+ if kind == "synthesis" and "delegate" in capabilities:
44
+ return "delegate"
45
+ if kind in ("operational", "architectural"):
46
+ if "edit_files" in capabilities and "run_commands" in capabilities:
47
+ return "apply"
48
+ if "edit_files" in capabilities:
49
+ return "apply"
50
+ return "report_only"
51
+ if risk == "high":
52
+ return "ask_human"
53
+ return "report_only" if "edit_files" not in capabilities else "apply"
54
+
55
+
56
+ def adapt_plan(plan: dict, *, agent_capabilities: list[str]) -> dict:
57
+ """Return a new plan with per-card 'recommended_handling' set.
58
+
59
+ Pure: input is not mutated.
60
+ """
61
+ caps = {c for c in agent_capabilities or [] if c in _KNOWN_CAPABILITIES}
62
+ out = deepcopy(plan)
63
+ for card in out.get("top_actions", []):
64
+ card["recommended_handling"] = _handling_for(card, capabilities=caps)
65
+ out["schema_version"] = SCHEMA_VERSION_ADAPTED_PLAN_V1
66
+ out["agent_capabilities"] = sorted(caps)
67
+ out["unknown_capabilities"] = sorted(
68
+ c for c in (agent_capabilities or []) if c not in _KNOWN_CAPABILITIES
69
+ )
70
+ counts: dict[str, int] = {}
71
+ for card in out.get("top_actions", []):
72
+ h = card["recommended_handling"]
73
+ counts[h] = counts.get(h, 0) + 1
74
+ out["handling_counts"] = counts
75
+ return out
@@ -0,0 +1,107 @@
1
+ """Tier a1 — pure pyproject.toml [tool.forge.agent] policy reader.
2
+
3
+ Codex #10. Reads the policy block (if present) and returns an
4
+ AgentPolicy dict with defaults filled in for any missing fields.
5
+
6
+ Pure: one bounded read of pyproject.toml; no other I/O. Returns the
7
+ default policy unchanged when no pyproject / no [tool.forge.agent]
8
+ section exists — Forge never errors on policy absence.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from ..a0_qk_constants.policy_schema import (
17
+ DEFAULT_MAX_FILES_PER_PATCH,
18
+ DEFAULT_PROTECTED_FILES,
19
+ DEFAULT_RELEASE_GATE,
20
+ DEFAULT_REQUIRE_HUMAN_REVIEW_FOR,
21
+ SCHEMA_VERSION_POLICY_V1,
22
+ AgentPolicy,
23
+ )
24
+
25
+ # Python 3.11+ has stdlib tomllib; older versions need a fallback.
26
+ if sys.version_info >= (3, 11):
27
+ import tomllib as _toml # type: ignore[import-not-found]
28
+ else: # pragma: no cover — Forge requires 3.10+, so this is rare path
29
+ try:
30
+ import tomli as _toml # type: ignore[import-not-found]
31
+ except ImportError:
32
+ _toml = None # type: ignore[assignment]
33
+
34
+
35
+ def default_policy() -> AgentPolicy:
36
+ """The lenient baseline policy applied when no [tool.forge.agent]
37
+ section is declared. Every field is mutable — caller can layer
38
+ user overrides on top."""
39
+ return AgentPolicy(
40
+ schema_version=SCHEMA_VERSION_POLICY_V1,
41
+ protected_files=list(DEFAULT_PROTECTED_FILES),
42
+ release_gate=list(DEFAULT_RELEASE_GATE),
43
+ max_files_per_patch=DEFAULT_MAX_FILES_PER_PATCH,
44
+ require_human_review_for=list(DEFAULT_REQUIRE_HUMAN_REVIEW_FOR),
45
+ extra={},
46
+ )
47
+
48
+
49
+ def load_policy(project_root: Path) -> AgentPolicy:
50
+ """Read [tool.forge.agent] from project_root/pyproject.toml.
51
+
52
+ Returns the default policy with any user-declared values merged
53
+ on top. Missing pyproject or missing section → defaults.
54
+ Malformed TOML → defaults + a note in extra['_load_error'].
55
+ """
56
+ project_root = Path(project_root).resolve()
57
+ policy = default_policy()
58
+
59
+ pp = project_root / "pyproject.toml"
60
+ if not pp.exists() or _toml is None:
61
+ return policy
62
+ try:
63
+ data = _toml.loads(pp.read_text(encoding="utf-8"))
64
+ except Exception as exc: # noqa: BLE001
65
+ policy["extra"] = {"_load_error": f"{type(exc).__name__}: {exc}"}
66
+ return policy
67
+
68
+ section = (data.get("tool") or {}).get("forge", {}).get("agent")
69
+ if not isinstance(section, dict):
70
+ return policy
71
+
72
+ if isinstance(section.get("protected_files"), list):
73
+ policy["protected_files"] = [str(s) for s in section["protected_files"]]
74
+ if isinstance(section.get("release_gate"), list):
75
+ policy["release_gate"] = [str(s) for s in section["release_gate"]]
76
+ if isinstance(section.get("max_files_per_patch"), int):
77
+ policy["max_files_per_patch"] = int(section["max_files_per_patch"])
78
+ if isinstance(section.get("require_human_review_for"), list):
79
+ policy["require_human_review_for"] = [
80
+ str(s) for s in section["require_human_review_for"]
81
+ ]
82
+ # Forward-compat: any unrecognised keys preserved in extra.
83
+ known = {
84
+ "protected_files", "release_gate", "max_files_per_patch",
85
+ "require_human_review_for",
86
+ }
87
+ extra: dict[str, Any] = {}
88
+ for k, v in section.items():
89
+ if k not in known:
90
+ extra[k] = v
91
+ if extra:
92
+ policy["extra"] = extra
93
+ return policy
94
+
95
+
96
+ def file_is_protected(path: str, policy: AgentPolicy) -> bool:
97
+ """True when ``path`` matches any protected_files entry.
98
+
99
+ Matching is exact-or-suffix: 'pyproject.toml' matches
100
+ 'pyproject.toml' and 'src/pkg/pyproject.toml' but NOT
101
+ 'src/pyproject_helper.py'.
102
+ """
103
+ protected = policy.get("protected_files") or []
104
+ for p in protected:
105
+ if path == p or path.endswith("/" + p) or Path(path).name == p:
106
+ return True
107
+ return False
@@ -0,0 +1,227 @@
1
+ """Tier a1 — pure preflight check for proposed changes.
2
+
3
+ Codex's 'Copilot's Copilot' primitive #2:
4
+
5
+ > preflight_change({intent, proposed_files}) — returns where the
6
+ > code should live, forbidden imports, tests likely affected,
7
+ > files the agent should read first, whether the write scope is
8
+ > too broad. Most agent mistakes happen before code is written.
9
+
10
+ Pure: no I/O beyond optional file-read for write_scope-too-broad
11
+ size hints. The classifier reads the proposed file paths only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import TypedDict
17
+
18
+ SCHEMA_VERSION_PREFLIGHT_V1 = "atomadic-forge.preflight/v1"
19
+
20
+
21
+ _TIER_NAMES = (
22
+ "a0_qk_constants",
23
+ "a1_at_functions",
24
+ "a2_mo_composites",
25
+ "a3_og_features",
26
+ "a4_sy_orchestration",
27
+ )
28
+
29
+ # Per tier: the tier names this tier may NOT import (forbidden).
30
+ _FORBIDDEN_BY_TIER: dict[str, tuple[str, ...]] = {
31
+ "a0_qk_constants": (
32
+ "a0_qk_constants", # may not import siblings either
33
+ "a1_at_functions", "a2_mo_composites",
34
+ "a3_og_features", "a4_sy_orchestration",
35
+ ),
36
+ "a1_at_functions": (
37
+ "a2_mo_composites", "a3_og_features", "a4_sy_orchestration",
38
+ ),
39
+ "a2_mo_composites": ("a3_og_features", "a4_sy_orchestration"),
40
+ "a3_og_features": ("a4_sy_orchestration",),
41
+ "a4_sy_orchestration": (),
42
+ }
43
+
44
+ _DEFAULT_SCOPE_TOO_BROAD = 8
45
+
46
+
47
+ class PreflightFile(TypedDict, total=False):
48
+ path: str
49
+ detected_tier: str | None
50
+ forbidden_imports: list[str]
51
+ likely_tests: list[str]
52
+ siblings_to_read: list[str]
53
+ notes: list[str]
54
+
55
+
56
+ class PreflightReport(TypedDict, total=False):
57
+ schema_version: str
58
+ intent: str
59
+ project_root: str
60
+ proposed_files: list[PreflightFile]
61
+ write_scope_too_broad: bool
62
+ write_scope_size: int
63
+ write_scope_threshold: int
64
+ overall_notes: list[str]
65
+ suggested_validation_commands: list[str]
66
+
67
+
68
+ def _detect_tier(path: str) -> str | None:
69
+ parts = Path(path).parts
70
+ for p in parts:
71
+ if p in _TIER_NAMES:
72
+ return p
73
+ return None
74
+
75
+
76
+ def _likely_tests_for(path: str) -> list[str]:
77
+ """Mirror-style test path heuristic.
78
+
79
+ src/pkg/a1_at_functions/foo.py -> tests/test_foo.py,
80
+ tests/a1_at_functions/test_foo.py
81
+ """
82
+ p = Path(path)
83
+ stem = p.stem
84
+ if stem.startswith("test_") or stem.endswith("_test"):
85
+ return [path] # the file IS a test
86
+ candidates: list[str] = []
87
+ candidates.append(f"tests/test_{stem}.py")
88
+ candidates.append(f"tests/{stem}_test.py")
89
+ parent_tier = _detect_tier(path)
90
+ if parent_tier:
91
+ candidates.append(f"tests/{parent_tier}/test_{stem}.py")
92
+ return candidates
93
+
94
+
95
+ def _siblings_to_read(path: str, *, project_root: Path) -> list[str]:
96
+ """When the agent edits a file, sibling files in the same tier
97
+ directory are the most likely places to share patterns or to
98
+ accidentally diverge — read them first."""
99
+ p = Path(path)
100
+ if not p.parent.parts:
101
+ return []
102
+ sib_dir = project_root / p.parent
103
+ if not sib_dir.is_dir():
104
+ return []
105
+ out: list[str] = []
106
+ for sib in sorted(sib_dir.glob("*.py")):
107
+ if sib.name == p.name:
108
+ continue
109
+ if sib.name.startswith("_"):
110
+ continue
111
+ out.append(str(sib.relative_to(project_root).as_posix()))
112
+ if len(out) >= 5:
113
+ break
114
+ return out
115
+
116
+
117
+ def _file_preflight(
118
+ path: str,
119
+ *,
120
+ project_root: Path,
121
+ ) -> PreflightFile:
122
+ notes: list[str] = []
123
+ tier = _detect_tier(path)
124
+ forbidden = list(_FORBIDDEN_BY_TIER.get(tier, ())) if tier else []
125
+ likely_tests = _likely_tests_for(path)
126
+ siblings = _siblings_to_read(path, project_root=project_root)
127
+ if tier is None:
128
+ notes.append(
129
+ "no tier directory in this path — file likely belongs in "
130
+ "a tier under src/<package>/aN_*/"
131
+ )
132
+ if Path(path).name == "__init__.py":
133
+ notes.append(
134
+ "__init__.py edits affect tier re-exports; run "
135
+ "tier_init_rebuild after a structural change"
136
+ )
137
+ return PreflightFile(
138
+ path=path,
139
+ detected_tier=tier,
140
+ forbidden_imports=forbidden,
141
+ likely_tests=likely_tests,
142
+ siblings_to_read=siblings,
143
+ notes=notes,
144
+ )
145
+
146
+
147
+ def preflight_change(
148
+ *,
149
+ intent: str,
150
+ proposed_files: list[str],
151
+ project_root: Path,
152
+ scope_threshold: int = _DEFAULT_SCOPE_TOO_BROAD,
153
+ ) -> PreflightReport:
154
+ """Heuristic preflight for a proposed code change.
155
+
156
+ Pure: takes paths + intent, walks the local filesystem only to
157
+ check sibling presence. Does NOT read the proposed_files
158
+ themselves — they may not exist yet.
159
+
160
+ Codex-6: when [tool.forge.agent] is declared in the project's
161
+ pyproject.toml, ``max_files_per_patch`` overrides the default
162
+ threshold and ``protected_files`` adds per-file warnings.
163
+ """
164
+ from .policy_loader import file_is_protected, load_policy
165
+ project_root = Path(project_root).resolve()
166
+ policy = load_policy(project_root)
167
+ if scope_threshold == _DEFAULT_SCOPE_TOO_BROAD and \
168
+ isinstance(policy.get("max_files_per_patch"), int):
169
+ scope_threshold = int(policy["max_files_per_patch"])
170
+ files: list[PreflightFile] = [
171
+ _file_preflight(p, project_root=project_root)
172
+ for p in proposed_files
173
+ ]
174
+ for f in files:
175
+ if file_is_protected(f["path"], policy):
176
+ notes = list(f.get("notes") or [])
177
+ notes.append(
178
+ "policy: this file is in [tool.forge.agent] "
179
+ "protected_files — agent should request human review"
180
+ )
181
+ f["notes"] = notes
182
+ overall: list[str] = []
183
+ protected_in_scope = [f["path"] for f in files
184
+ if any("protected_files" in n
185
+ for n in f.get("notes", []))]
186
+ if protected_in_scope:
187
+ overall.append(
188
+ f"{len(protected_in_scope)} protected file(s) in scope "
189
+ f"(per [tool.forge.agent]): {protected_in_scope[:5]} — "
190
+ "request human review before applying."
191
+ )
192
+ too_broad = len(proposed_files) > scope_threshold
193
+ if too_broad:
194
+ overall.append(
195
+ f"write_scope has {len(proposed_files)} files (> "
196
+ f"{scope_threshold} threshold). Consider splitting the "
197
+ "change or asking for human review."
198
+ )
199
+ detected_tiers = {f.get("detected_tier") for f in files}
200
+ detected_tiers.discard(None)
201
+ if len(detected_tiers) > 1:
202
+ overall.append(
203
+ f"change spans {len(detected_tiers)} tiers "
204
+ f"({sorted(detected_tiers)}); cross-tier edits should be "
205
+ "split into per-tier patches when possible."
206
+ )
207
+ if any(f.get("path", "").startswith("tests/") for f in files) and \
208
+ not any(not f.get("path", "").startswith("tests/") for f in files):
209
+ overall.append(
210
+ "test-only change — verify it pins existing behaviour and "
211
+ "is not silently masking a regression."
212
+ )
213
+ return PreflightReport(
214
+ schema_version=SCHEMA_VERSION_PREFLIGHT_V1,
215
+ intent=intent,
216
+ project_root=str(project_root),
217
+ proposed_files=files,
218
+ write_scope_too_broad=too_broad,
219
+ write_scope_size=len(proposed_files),
220
+ write_scope_threshold=scope_threshold,
221
+ overall_notes=overall,
222
+ suggested_validation_commands=[
223
+ "forge wire src --fail-on-violations",
224
+ "python -m pytest",
225
+ "forge certify . --fail-under 75",
226
+ ],
227
+ )