agentforge-framework 0.2.0__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 (89) hide show
  1. agentforge_framework/.claude-plugin/plugin.json +4 -0
  2. agentforge_framework/__init__.py +3 -0
  3. agentforge_framework/agents/__init__.py +92 -0
  4. agentforge_framework/agents/architect.py +146 -0
  5. agentforge_framework/agents/implementer.py +162 -0
  6. agentforge_framework/agents/orchestrator.py +588 -0
  7. agentforge_framework/agents/reviewer.py +335 -0
  8. agentforge_framework/agents/security.py +138 -0
  9. agentforge_framework/agents/tester.py +125 -0
  10. agentforge_framework/cli.py +461 -0
  11. agentforge_framework/context/__init__.py +1 -0
  12. agentforge_framework/context/extractors/__init__.py +76 -0
  13. agentforge_framework/context/extractors/base.py +47 -0
  14. agentforge_framework/context/extractors/python.py +65 -0
  15. agentforge_framework/context/extractors/sql.py +121 -0
  16. agentforge_framework/context/extractors/yaml.py +59 -0
  17. agentforge_framework/context/prompt.py +104 -0
  18. agentforge_framework/context/resolver.py +185 -0
  19. agentforge_framework/core/__init__.py +1 -0
  20. agentforge_framework/core/commands.py +170 -0
  21. agentforge_framework/core/config.py +90 -0
  22. agentforge_framework/core/contracts.py +875 -0
  23. agentforge_framework/core/gates.py +333 -0
  24. agentforge_framework/core/issues.py +697 -0
  25. agentforge_framework/core/plan_format.py +272 -0
  26. agentforge_framework/core/process.py +141 -0
  27. agentforge_framework/core/project.py +262 -0
  28. agentforge_framework/core/registry.py +455 -0
  29. agentforge_framework/core/repo.py +185 -0
  30. agentforge_framework/core/router.py +1 -0
  31. agentforge_framework/core/runtime.py +639 -0
  32. agentforge_framework/core/skills.py +255 -0
  33. agentforge_framework/core/workflow.py +215 -0
  34. agentforge_framework/plugins/__init__.py +35 -0
  35. agentforge_framework/plugins/databricks/__init__.py +86 -0
  36. agentforge_framework/plugins/pyspark/__init__.py +57 -0
  37. agentforge_framework/plugins/python/__init__.py +45 -0
  38. agentforge_framework/plugins/sql/__init__.py +377 -0
  39. agentforge_framework/providers/__init__.py +48 -0
  40. agentforge_framework/providers/base.py +248 -0
  41. agentforge_framework/providers/claude.py +159 -0
  42. agentforge_framework/providers/codex.py +139 -0
  43. agentforge_framework/skills/MANIFEST.yaml +157 -0
  44. agentforge_framework/skills/NOTICE +49 -0
  45. agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
  46. agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  47. agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
  48. agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
  49. agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
  50. agentforge_framework/skills/grilling/SKILL.md +28 -0
  51. agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
  52. agentforge_framework/skills/to-spec/SKILL.md +75 -0
  53. agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
  54. agentforge_framework/skills/to-tickets/SKILL.md +105 -0
  55. agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
  56. agentforge_framework/skills/unslop/SKILL.md +131 -0
  57. agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
  58. agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
  59. agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
  60. agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
  61. agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
  62. agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
  63. agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
  64. agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
  65. agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
  66. agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
  67. agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
  68. agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
  69. agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
  70. agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
  71. agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
  72. agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
  73. agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
  74. agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
  75. agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
  76. agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
  77. agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
  78. agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
  79. agentforge_framework/skills/write-plainly/SKILL.md +94 -0
  80. agentforge_framework/workflows/bugfix.yaml +8 -0
  81. agentforge_framework/workflows/feature.yaml +16 -0
  82. agentforge_framework/workflows/review.yaml +10 -0
  83. agentforge_framework-0.2.0.dist-info/METADATA +321 -0
  84. agentforge_framework-0.2.0.dist-info/RECORD +89 -0
  85. agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
  86. agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
  87. agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
  88. agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
  89. agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ """The Issue body: prose for the human, one JSON block for the Agent.
2
+
3
+ ADR-0002 puts the handoff contract in a GitHub issue and ADR-0003 freezes it
4
+ once written. Those two together mean the body has to satisfy two readers at
5
+ once. A human judges the plan before any code is written, so the body is
6
+ Markdown. `agentforge implement` recovers the Plan a week later on a different
7
+ machine, so the body also carries a delimited JSON block and the parser reads
8
+ that and only that. No heuristics, no scraping of the prose.
9
+
10
+ The prose is generated from the same `PlanDocument` as the block, so the two
11
+ cannot disagree at filing time. If a human edits one half afterwards, the block
12
+ is the authority — it is what every Role parses.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ from .contracts import (
20
+ PLAN_FORMAT_VERSION,
21
+ ContextPack,
22
+ Plan,
23
+ PlanDocument,
24
+ Roster,
25
+ Task,
26
+ )
27
+
28
+ PLAN_OPEN = "<!-- agentforge:plan -->"
29
+ PLAN_CLOSE = "<!-- /agentforge:plan -->"
30
+
31
+ RESULT_OPEN = "<!-- agentforge:result -->"
32
+ RESULT_CLOSE = "<!-- /agentforge:result -->"
33
+
34
+ #: A Gate's verdict travels in a marker of its own rather than in a result
35
+ #: block, because a Gate is not an Agent and `parse_run_log` must keep returning
36
+ #: Agent Results only. See ADR-0008.
37
+ GATE_OPEN = "<!-- agentforge:gate -->"
38
+ GATE_CLOSE = "<!-- /agentforge:gate -->"
39
+
40
+
41
+ class PlanFormatError(ValueError):
42
+ """An Issue body does not carry a plan AgentForge can execute."""
43
+
44
+
45
+ def render_issue_title(task: Task) -> str:
46
+ """A title a human recognizes in a list of thirty issues."""
47
+ statement = " ".join(task.statement.split())
48
+ if len(statement) <= 72:
49
+ return statement
50
+ return statement[:69].rstrip(" ,.;:-") + "..."
51
+
52
+
53
+ def render_issue_body(task: Task, document: PlanDocument) -> str:
54
+ """The full Issue body: readable plan, then the frozen block."""
55
+ parts = [
56
+ "## Task",
57
+ "",
58
+ f"> {task.statement.strip()}",
59
+ "",
60
+ "## Plan",
61
+ "",
62
+ document.plan.summary.strip(),
63
+ "",
64
+ ]
65
+
66
+ if document.plan.steps:
67
+ parts += ["### Steps", ""]
68
+ for index, step in enumerate(document.plan.steps, start=1):
69
+ parts.append(f"{index}. **{step.id}** — {step.intent}")
70
+ if step.files:
71
+ parts.append(f" - Files: {', '.join(f'`{f}`' for f in step.files)}")
72
+ if step.acceptance:
73
+ parts.append(f" - Done when: {step.acceptance}")
74
+ parts.append("")
75
+
76
+ if document.plan.constraints:
77
+ parts += ["### Constraints", ""]
78
+ parts += [f"- {constraint}" for constraint in document.plan.constraints]
79
+ parts.append("")
80
+
81
+ parts += [
82
+ "## Roster",
83
+ "",
84
+ (
85
+ f"Running the `{document.workflow}` Workflow, in this order. "
86
+ "A Gate between two Steps holds the Run until it clears."
87
+ ),
88
+ "",
89
+ "| Order | Role | Model Tier |",
90
+ "| --- | --- | --- |",
91
+ ]
92
+ for index, role in enumerate(document.roster, start=1):
93
+ parts.append(f"| {index} | {role.name} | `{role.tier}` |")
94
+ parts.append("")
95
+
96
+ if document.context:
97
+ parts += ["## Context Pack", ""]
98
+ for label, values in (
99
+ ("Files", document.context.files),
100
+ ("Symbols", document.context.symbols),
101
+ ("References", document.context.references),
102
+ ("Conventions", document.context.conventions),
103
+ ):
104
+ if values:
105
+ parts.append(f"- {label}: " + ", ".join(values))
106
+ parts.append("")
107
+
108
+ if document.notes:
109
+ parts += ["## Notes", ""]
110
+ parts += [f"- {note}" for note in document.notes]
111
+ parts.append("")
112
+
113
+ parts += [
114
+ "---",
115
+ "",
116
+ PLAN_OPEN,
117
+ "```json",
118
+ json.dumps(document.to_dict(), indent=2, sort_keys=True),
119
+ "```",
120
+ PLAN_CLOSE,
121
+ "",
122
+ (
123
+ "*Filed by AgentForge. The block above is the frozen execution contract "
124
+ "(ADR-0003). Every Role parses it; edit it rather than the prose.*"
125
+ ),
126
+ ]
127
+ return "\n".join(parts) + "\n"
128
+
129
+
130
+ def extract_plan_payload(text: str) -> dict:
131
+ """The raw plan block, before Roles are resolved.
132
+
133
+ The Orchestrator needs this: the model it just ran may have named a Role
134
+ that does not exist yet, and dropping those is Roster selection's job rather
135
+ than the parser's.
136
+ """
137
+ payload = _extract_block(text, PLAN_OPEN, PLAN_CLOSE)
138
+ if payload is None:
139
+ raise PlanFormatError(
140
+ "no AgentForge plan block found; "
141
+ "it was not written by `agentforge plan`, or the block was deleted"
142
+ )
143
+
144
+ try:
145
+ data = json.loads(payload)
146
+ except json.JSONDecodeError as exc:
147
+ raise PlanFormatError(f"plan block is not valid JSON: {exc}") from exc
148
+
149
+ if not isinstance(data, dict) or "plan" not in data:
150
+ raise PlanFormatError("plan block carries no `plan`")
151
+ return data
152
+
153
+
154
+ def parse_issue_body(body: str, resolve=None) -> PlanDocument:
155
+ """Recover the frozen `PlanDocument` from an Issue body."""
156
+ data = extract_plan_payload(body)
157
+
158
+ version = int(data.get("version", PLAN_FORMAT_VERSION))
159
+ if version > PLAN_FORMAT_VERSION:
160
+ raise PlanFormatError(
161
+ f"issue carries plan format v{version}; this AgentForge understands "
162
+ f"v{PLAN_FORMAT_VERSION}. Upgrade AgentForge."
163
+ )
164
+
165
+ if resolve is None:
166
+ from ..agents import resolve_role as resolve
167
+
168
+ try:
169
+ document = PlanDocument.from_dict(data, resolve)
170
+ except KeyError as exc:
171
+ raise PlanFormatError(f"plan block is missing {exc}") from exc
172
+
173
+ if not document.roster:
174
+ raise PlanFormatError("plan block carries an empty Roster; there is nothing to run")
175
+ return document
176
+
177
+
178
+ def render_result_block(payload: dict) -> str:
179
+ """The block a Role is asked to end its output with. Shared by every Provider."""
180
+ return "\n".join(
181
+ [RESULT_OPEN, "```json", json.dumps(payload, indent=2), "```", RESULT_CLOSE]
182
+ )
183
+
184
+
185
+ def extract_result_block(text: str) -> dict | None:
186
+ """Pull an Agent's structured verdict out of its free text, if it wrote one."""
187
+ payload = _extract_block(text, RESULT_OPEN, RESULT_CLOSE)
188
+ if payload is None:
189
+ return None
190
+ try:
191
+ data = json.loads(payload)
192
+ except json.JSONDecodeError:
193
+ return None
194
+ return data if isinstance(data, dict) else None
195
+
196
+
197
+ def render_gate_block(payload: dict) -> str:
198
+ """The block a Gate ends its Run Log entry with. Written by AgentForge only.
199
+
200
+ A Role is asked for a result block; nobody is asked for one of these. The
201
+ Gate that evaluated writes it, so the next Run can read back which Gate
202
+ spoke and what it said.
203
+ """
204
+ return "\n".join(
205
+ [GATE_OPEN, "```json", json.dumps(payload, indent=2), "```", GATE_CLOSE]
206
+ )
207
+
208
+
209
+ def extract_gate_block(text: str) -> dict | None:
210
+ """Pull a Gate's verdict out of a Run Log comment, if it carries one."""
211
+ payload = _extract_block(text, GATE_OPEN, GATE_CLOSE)
212
+ if payload is None:
213
+ return None
214
+ try:
215
+ data = json.loads(payload)
216
+ except json.JSONDecodeError:
217
+ return None
218
+ return data if isinstance(data, dict) else None
219
+
220
+
221
+ def _extract_block(text: str, open_marker: str, close_marker: str) -> str | None:
222
+ """The JSON inside a delimited, fenced block. Last one wins.
223
+
224
+ Last rather than first because an Agent that quotes the instructions it was
225
+ given writes the example before it writes its answer.
226
+
227
+ The closing fence is the last one inside the markers rather than the first,
228
+ because JSON escapes no backticks: a Gate quoting a failing suite and an
229
+ Agent quoting the code it changed both put fences inside the payload, and
230
+ stopping at the first one truncates the JSON into something unparseable.
231
+ The close marker is what bounds the search, so a block missing one falls
232
+ back to the first fence rather than reaching into whatever follows it.
233
+ """
234
+ start = text.rfind(open_marker)
235
+ if start == -1:
236
+ return None
237
+ end = text.find(close_marker, start)
238
+ bounded = end != -1
239
+ inner = text[start + len(open_marker) : end if bounded else len(text)]
240
+
241
+ fence = inner.find("```")
242
+ if fence == -1:
243
+ return inner.strip() or None
244
+ inner = inner[fence + 3 :]
245
+ inner = inner.removeprefix("json")
246
+ closing = inner.rfind("```") if bounded else inner.find("```")
247
+ if closing != -1:
248
+ inner = inner[:closing]
249
+ return inner.strip() or None
250
+
251
+
252
+ __all__ = [
253
+ "GATE_CLOSE",
254
+ "GATE_OPEN",
255
+ "PLAN_CLOSE",
256
+ "PLAN_OPEN",
257
+ "RESULT_CLOSE",
258
+ "RESULT_OPEN",
259
+ "ContextPack",
260
+ "Plan",
261
+ "PlanDocument",
262
+ "PlanFormatError",
263
+ "Roster",
264
+ "extract_gate_block",
265
+ "extract_plan_payload",
266
+ "extract_result_block",
267
+ "parse_issue_body",
268
+ "render_gate_block",
269
+ "render_issue_body",
270
+ "render_issue_title",
271
+ "render_result_block",
272
+ ]
@@ -0,0 +1,141 @@
1
+ """The one place AgentForge touches an external process.
2
+
3
+ `gh`, the coding-agent CLIs, `git`, and the vendored unslop scanners all route
4
+ through this port. Nothing else in the codebase imports `subprocess`.
5
+
6
+ The payoff is in the test suite: a fake runner scripted by argument-vector
7
+ prefix stands in for every external tool at once, so the whole framework above
8
+ this line runs as real code with no network, no GitHub account, and no
9
+ coding-agent CLI installed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shutil
15
+ import subprocess
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Protocol
20
+
21
+
22
+ class MissingBinary(RuntimeError):
23
+ """A required external tool is not on PATH.
24
+
25
+ Raised in preference to a bare `FileNotFoundError` so the message names the
26
+ binary a user has to install rather than the syscall that failed.
27
+ """
28
+
29
+ def __init__(self, binary: str, hint: str = "") -> None:
30
+ message = f"{binary!r} is not installed or not on PATH"
31
+ if hint:
32
+ message = f"{message}. {hint}"
33
+ super().__init__(message)
34
+ self.binary = binary
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CommandResult:
39
+ """Exit status, standard output, and standard error. Nothing else."""
40
+
41
+ argv: tuple[str, ...]
42
+ returncode: int
43
+ stdout: str = ""
44
+ stderr: str = ""
45
+
46
+ @property
47
+ def ok(self) -> bool:
48
+ return self.returncode == 0
49
+
50
+ def check(self) -> CommandResult:
51
+ """Return self, or raise with enough context to debug the failure."""
52
+ if self.ok:
53
+ return self
54
+ rendered = " ".join(self.argv)
55
+ detail = (self.stderr or self.stdout or "").strip()
56
+ raise CommandFailed(f"`{rendered}` exited {self.returncode}: {detail[:800]}", result=self)
57
+
58
+
59
+ class CommandFailed(RuntimeError):
60
+ """An external process ran and returned a non-zero status."""
61
+
62
+ def __init__(self, message: str, result: CommandResult) -> None:
63
+ super().__init__(message)
64
+ self.result = result
65
+
66
+
67
+ class CommandRunner(Protocol):
68
+ """The port. An argument vector and a working directory go in; a result
69
+ comes out."""
70
+
71
+ def run(
72
+ self,
73
+ argv: Sequence[str],
74
+ *,
75
+ cwd: Path | str | None = None,
76
+ stdin: str | None = None,
77
+ timeout: float | None = None,
78
+ ) -> CommandResult: ...
79
+
80
+ def has_binary(self, binary: str) -> bool:
81
+ """Whether the tool is available, checked before a Run spends anything."""
82
+ ...
83
+
84
+
85
+ class SubprocessRunner:
86
+ """The real implementation. The only `subprocess` call site in AgentForge."""
87
+
88
+ def run(
89
+ self,
90
+ argv: Sequence[str],
91
+ *,
92
+ cwd: Path | str | None = None,
93
+ stdin: str | None = None,
94
+ timeout: float | None = None,
95
+ ) -> CommandResult:
96
+ argv = tuple(str(part) for part in argv)
97
+ try:
98
+ completed = subprocess.run(
99
+ argv,
100
+ cwd=str(cwd) if cwd else None,
101
+ input=stdin,
102
+ capture_output=True,
103
+ text=True,
104
+ encoding="utf-8",
105
+ errors="replace",
106
+ timeout=timeout,
107
+ # A non-zero exit is a result here, not an exception. `gh` uses
108
+ # one to mean "no such issue" and the unslop scanners use one to
109
+ # mean "found something"; callers decide what it means.
110
+ check=False,
111
+ )
112
+ except FileNotFoundError as exc:
113
+ raise MissingBinary(argv[0]) from exc
114
+ except subprocess.TimeoutExpired as exc:
115
+ return CommandResult(
116
+ argv=argv,
117
+ returncode=124,
118
+ stdout=_text(exc.stdout),
119
+ stderr=f"timed out after {timeout}s",
120
+ )
121
+ return CommandResult(
122
+ argv=argv,
123
+ returncode=completed.returncode,
124
+ stdout=completed.stdout or "",
125
+ stderr=completed.stderr or "",
126
+ )
127
+
128
+ def has_binary(self, binary: str) -> bool:
129
+ return shutil.which(binary) is not None
130
+
131
+
132
+ def _text(value: bytes | str | None) -> str:
133
+ if isinstance(value, bytes):
134
+ return value.decode("utf-8", errors="replace")
135
+ return value or ""
136
+
137
+
138
+ def require(runner: CommandRunner, binary: str, hint: str = "") -> None:
139
+ """Fail before a model is invoked rather than halfway through a Run."""
140
+ if not runner.has_binary(binary):
141
+ raise MissingBinary(binary, hint)
@@ -0,0 +1,262 @@
1
+ """What `agentforge init` learns about a repository, and what it writes down.
2
+
3
+ Two halves, kept apart because they are held to different standards. Detection
4
+ reads the repository and answers three questions — which Plugins answer for it,
5
+ what its suite appears to be, which Provider it will drive — and is allowed to
6
+ be wrong, because everything it produces is printed for a human to correct.
7
+ Rendering turns the answers into `.agentforge/config.yaml`, and is allowed to
8
+ write only what `core.config` reads back.
9
+
10
+ That second rule is the whole shape of this module. `docs/PLAN.md` promised a
11
+ config file owning tier mapping, Provider selection, plugin activation, and Gate
12
+ policy; `load_config` reads two keys. Writing the other three would be writing
13
+ keys nothing consults, which is worse than not writing them: a human who edits a
14
+ key that has no effect has been lied to by the file. So what init detects and
15
+ cannot yet persist it prints, and the gap stays visible. See ADR-0020.
16
+
17
+ Nothing here decides whether the repository may be written to. `open_repository`
18
+ answers that, and the CLI asks it first, so a repository that cannot host a Run
19
+ never gets a config file suggesting it can.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ import yaml as pyyaml
28
+
29
+ from .config import DEFAULT_CAPABILITIES, DEFAULT_TEST_SUITE, CapabilityTier
30
+
31
+ CONFIG_DIR = ".agentforge"
32
+ CONFIG_FILE = "config.yaml"
33
+
34
+ #: How many tracked files the language census reads. A repository's languages
35
+ #: are visible in the first couple of thousand files, and the census is a line
36
+ #: of output rather than a decision anything turns on.
37
+ MAX_CENSUS = 2000
38
+
39
+ #: Suffix to the name a human calls it. Deliberately short: this names what
40
+ #: AgentForge might have something to say about, and a census listing `.gitignore`
41
+ #: as a language would be noise dressed as information.
42
+ LANGUAGES = {
43
+ ".py": "Python",
44
+ ".sql": "SQL",
45
+ ".yml": "YAML",
46
+ ".yaml": "YAML",
47
+ ".ipynb": "Notebook",
48
+ ".scala": "Scala",
49
+ ".java": "Java",
50
+ ".ts": "TypeScript",
51
+ ".tsx": "TypeScript",
52
+ ".js": "JavaScript",
53
+ ".jsx": "JavaScript",
54
+ ".go": "Go",
55
+ ".rs": "Rust",
56
+ ".sh": "Shell",
57
+ ".md": "Markdown",
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ProjectContext:
63
+ """What AgentForge learned about one repository at `agentforge init`.
64
+
65
+ `suite_detected` and `plugins` are the two fields that exist because a human
66
+ reads this before a machine does. The first says whether the suite in the
67
+ file was found or assumed, which is the difference between a line to leave
68
+ alone and a line to correct. The second is printed and never written:
69
+ activation is decided per Run from the frozen Plan's blast radius, and a
70
+ `plugins:` key would be a key nothing consults.
71
+ """
72
+
73
+ root: Path
74
+ provider: str
75
+ capability_tier: CapabilityTier
76
+ test_suite: tuple[str, ...] = DEFAULT_TEST_SUITE
77
+ suite_detected: str = ""
78
+ languages: tuple[str, ...] = ()
79
+ plugins: tuple[str, ...] = ()
80
+
81
+
82
+ def detect(
83
+ root: Path | str,
84
+ provider: str,
85
+ tracked: tuple[str, ...] = (),
86
+ plugins: tuple[str, ...] = (),
87
+ ) -> ProjectContext:
88
+ """Everything init has to say about this repository.
89
+
90
+ `tracked` is the repository's files as git reports them, and `plugins` the
91
+ names `core.registry` activated for it. Both are passed in rather than read
92
+ here: this module opens no process and imports no registry, so detection is
93
+ a pure function of what it was handed and a test needs no repository.
94
+ """
95
+ suite, because = _suite(Path(root), tracked)
96
+ return ProjectContext(
97
+ root=Path(root),
98
+ provider=provider,
99
+ capability_tier=DEFAULT_CAPABILITIES.get(provider, CapabilityTier.FRAGMENT),
100
+ test_suite=suite,
101
+ suite_detected=because,
102
+ languages=_languages(tracked),
103
+ plugins=tuple(plugins),
104
+ )
105
+
106
+
107
+ def config_path(root: Path | str) -> Path:
108
+ return Path(root) / CONFIG_DIR / CONFIG_FILE
109
+
110
+
111
+ def render_config(context: ProjectContext) -> str:
112
+ """The file, with a comment on every line a human might want to change.
113
+
114
+ Written by hand rather than dumped, because the comments are the point. A
115
+ reader who cannot tell a detected value from a default has to re-derive both
116
+ before touching either, and the first thing anybody does to a generated
117
+ config is edit it.
118
+ """
119
+ suite = ", ".join(_quoted(part) for part in context.test_suite)
120
+ because = (
121
+ f"detected: {context.suite_detected}"
122
+ if context.suite_detected
123
+ else "not detected — this is the documented default, so correct it if it is wrong"
124
+ )
125
+
126
+ return f"""\
127
+ # AgentForge project configuration, written by `agentforge init`.
128
+ #
129
+ # This file holds what AgentForge reads and nothing else. Which Plugins answer
130
+ # for this repository is decided per Run from the frozen Plan's blast radius
131
+ # (ADR-0016), so there is no `plugins:` key here to edit.
132
+
133
+ providers:
134
+ # What this Provider's CLI can be relied on to support, declared rather than
135
+ # probed (ADR-0005). `native` delivers a Role's skills as the CLI's own
136
+ # commands; `fragment` inlines them into the prompt instead.
137
+ {context.provider}:
138
+ capability_tier: {context.capability_tier}
139
+
140
+ gates:
141
+ tests:
142
+ # The argument vector the `tests` Gate runs, in this repository.
143
+ # {because}
144
+ suite: [{suite}]
145
+ """
146
+
147
+
148
+ def differences(context: ProjectContext, existing: str) -> tuple[str, ...]:
149
+ """How the config on disk differs from the one init would write.
150
+
151
+ Compared as the values `load_config` would read rather than as text, so a
152
+ file somebody reformatted, commented, or reordered is not reported as a
153
+ difference. The point of the comparison is to tell a human whether their
154
+ edits are still there, and a diff that fired on whitespace would not.
155
+ """
156
+ try:
157
+ data = pyyaml.safe_load(existing) or {}
158
+ except pyyaml.YAMLError as exc:
159
+ return (f"the file on disk is not valid YAML: {exc}",)
160
+
161
+ if not isinstance(data, dict):
162
+ return ("the file on disk is not a mapping",)
163
+
164
+ found: list[str] = []
165
+
166
+ providers = data.get("providers") or {}
167
+ tier = (providers.get(context.provider) or {}).get("capability_tier")
168
+ if tier is None:
169
+ found.append(f"it names no capability tier for {context.provider!r}")
170
+ elif str(tier) != str(context.capability_tier):
171
+ found.append(
172
+ f"{context.provider} capability tier: {tier} on disk, "
173
+ f"{context.capability_tier} from detection"
174
+ )
175
+
176
+ suite = ((data.get("gates") or {}).get("tests") or {}).get("suite")
177
+ if suite is not None:
178
+ rendered = " ".join(suite) if isinstance(suite, list) else str(suite)
179
+ if rendered.split() != list(context.test_suite):
180
+ found.append(
181
+ f"test suite: `{rendered}` on disk, "
182
+ f"`{' '.join(context.test_suite)}` from detection"
183
+ )
184
+
185
+ return tuple(found)
186
+
187
+
188
+ def _suite(root: Path, tracked: tuple[str, ...]) -> tuple[tuple[str, ...], str]:
189
+ """The suite this repository appears to run, and the evidence for it.
190
+
191
+ Ordered by how specific the evidence is rather than by popularity: a
192
+ repository declaring a pytest section is telling us directly, and one with a
193
+ `tests/` directory is telling us by convention. A repository that shows
194
+ nothing gets the documented default and is told it was a default — guessing
195
+ silently is how a Gate ends up running the wrong command for a month.
196
+ """
197
+ files = set(tracked)
198
+
199
+ text = _read(root / "pyproject.toml")
200
+ if "[tool.pytest" in text:
201
+ return ("pytest",), "a `[tool.pytest]` section in pyproject.toml"
202
+
203
+ if "pytest.ini" in files or "conftest.py" in files:
204
+ return ("pytest",), "pytest configuration at the repository root"
205
+
206
+ if any(path.startswith("tests/") for path in files):
207
+ return ("pytest",), "a `tests/` directory"
208
+
209
+ package = _read(root / "package.json")
210
+ if '"test"' in package:
211
+ return ("npm", "test"), "a `test` script in package.json"
212
+
213
+ if "go.mod" in files:
214
+ return ("go", "test", "./..."), "a go.mod at the repository root"
215
+
216
+ if "Cargo.toml" in files:
217
+ return ("cargo", "test"), "a Cargo.toml at the repository root"
218
+
219
+ return DEFAULT_TEST_SUITE, ""
220
+
221
+
222
+ def _languages(tracked: tuple[str, ...]) -> tuple[str, ...]:
223
+ """The languages this repository is written in, commonest first.
224
+
225
+ A census rather than a claim: it counts the suffixes git already knows
226
+ about, so a `.venv` nobody committed does not make this a repository full of
227
+ somebody else's Python.
228
+ """
229
+ counts: dict[str, int] = {}
230
+ for path in tracked[:MAX_CENSUS]:
231
+ language = LANGUAGES.get(Path(path).suffix.lower())
232
+ if language:
233
+ counts[language] = counts.get(language, 0) + 1
234
+
235
+ # Sorted by count and then by name, so two languages with the same number of
236
+ # files do not swap places between two runs against one repository.
237
+ return tuple(name for name, _ in sorted(counts.items(), key=lambda item: (-item[1], item[0])))
238
+
239
+
240
+ def _read(path: Path) -> str:
241
+ try:
242
+ return path.read_text(encoding="utf-8", errors="replace")
243
+ except OSError:
244
+ return ""
245
+
246
+
247
+ def _quoted(part: str) -> str:
248
+ """A suite argument as YAML. Quoted, because `-q` unquoted is not a string."""
249
+ return '"' + part.replace('"', '\\"') + '"'
250
+
251
+
252
+ __all__ = [
253
+ "CONFIG_DIR",
254
+ "CONFIG_FILE",
255
+ "LANGUAGES",
256
+ "MAX_CENSUS",
257
+ "ProjectContext",
258
+ "config_path",
259
+ "detect",
260
+ "differences",
261
+ "render_config",
262
+ ]