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,461 @@
1
+ """Command-line entry point for AgentForge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .core.contracts import ModelTier, Outcome, RunStatus
11
+
12
+ TIER_HELP = (
13
+ "override the Model Tier: `deep`, `standard`, `cheap`, or `role=tier` "
14
+ "to move one Role. Repeatable."
15
+ )
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="agentforge",
21
+ description="Coordinate specialized software agents through reusable workflows.",
22
+ )
23
+
24
+ # `--version` is answered by the parser and exits, exactly as `--help` does;
25
+ # neither ever reaches `main`'s dispatch. The literal lives in `__version__`
26
+ # alone, so a release bumps one line here and one in `pyproject.toml` --
27
+ # tests/test_docs.py fails if those two ever disagree.
28
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
29
+
30
+ subcommands = parser.add_subparsers(dest="command", metavar="<command>")
31
+
32
+ plan = subcommands.add_parser("plan", help="turn a task into a GitHub issue")
33
+ plan.add_argument("task", help="the task, in your own words")
34
+ plan.add_argument("--provider", default=None, help="coding-agent CLI to drive")
35
+ plan.add_argument("--tier", default=None, help="Model Tier for the Orchestrator")
36
+ plan.add_argument("-C", "--directory", default=".", help="repository to plan against")
37
+
38
+ implement = subcommands.add_parser(
39
+ "implement", help="run an issue's roster and open a draft pull request"
40
+ )
41
+ implement.add_argument("issue", type=int, help="issue number")
42
+ implement.add_argument("--provider", default=None, help="coding-agent CLI to drive")
43
+ implement.add_argument("--tier", action="append", default=[], help=TIER_HELP)
44
+ implement.add_argument("-C", "--directory", default=".", help="repository to work in")
45
+ implement.add_argument(
46
+ "--allow-commands",
47
+ action="store_true",
48
+ help=(
49
+ "let Agents run commands, not just edit files (ADR-0007). Off by default, "
50
+ "and granted for this Run only -- never persisted to configuration"
51
+ ),
52
+ )
53
+ implement.add_argument(
54
+ "--no-context-pack",
55
+ action="store_true",
56
+ help=(
57
+ "hand every Role an empty Context Pack, so each reads the repository for "
58
+ "itself. This is the control Run: compare its cost against a packed Run of "
59
+ "the same issue to find out what the pack is worth. Plugin Fragments ride "
60
+ "in the pack, so this drops those too -- use --no-plugins to drop only those"
61
+ ),
62
+ )
63
+ implement.add_argument(
64
+ "--no-plugins",
65
+ action="store_true",
66
+ help=(
67
+ "resolve the Context Pack as usual but activate no Plugins, so no Fragment "
68
+ "reaches a prompt. This is the control for what the Fragments cost, and it "
69
+ "is a separate switch because --no-context-pack removes both at once"
70
+ ),
71
+ )
72
+
73
+ run = subcommands.add_parser(
74
+ "run",
75
+ help="run a Plugin's Command: a repeated chore, with no model involved",
76
+ )
77
+ run.add_argument(
78
+ "name",
79
+ nargs="?",
80
+ help="the Command to run. Leave it out to list what this repository has",
81
+ )
82
+ run.add_argument("arguments", nargs="*", help="the Command's positional arguments")
83
+ run.add_argument("-C", "--directory", default=".", help="repository to run in")
84
+
85
+ init = subcommands.add_parser(
86
+ "init",
87
+ help="inspect this repository and write .agentforge/config.yaml",
88
+ )
89
+ init.add_argument("--provider", default=None, help="coding-agent CLI to drive")
90
+ init.add_argument("-C", "--directory", default=".", help="repository to configure")
91
+ init.add_argument(
92
+ "--force",
93
+ action="store_true",
94
+ help="replace an existing config. Without it, init reports what differs and writes nothing",
95
+ )
96
+
97
+ unslop = subcommands.add_parser("unslop", help="scan prose for machine-writing tells")
98
+ unslop.add_argument("path", help="file to scan")
99
+ unslop.add_argument("--json", action="store_true", help="emit the full report as JSON")
100
+
101
+ return parser
102
+
103
+
104
+ def _run_unslop(args: argparse.Namespace, runner=None) -> int:
105
+ from .core.skills import run_unslop
106
+
107
+ report = run_unslop(args.path, runner=runner)
108
+
109
+ if args.json:
110
+ print(json.dumps(report.to_dict(), indent=2))
111
+ else:
112
+ from .core.skills import _describe
113
+
114
+ for result in report.results:
115
+ if result.error:
116
+ print(f" {result.scanner}: could not run — {result.error}")
117
+ continue
118
+ verdict = "clean" if result.clean else f"{result.violations} finding(s)"
119
+ print(f" {result.scanner}: {verdict}")
120
+ for line in _describe(result):
121
+ print(f" {line}")
122
+ summary = "clean" if report.clean else f"{report.violations} finding(s)"
123
+ print(f"{report.path.name}: {summary}")
124
+
125
+ if report.failed:
126
+ return 2
127
+ return 0 if report.clean else 1
128
+
129
+
130
+ def parse_tier_overrides(values: list[str]) -> tuple[ModelTier | None, dict[str, ModelTier]]:
131
+ """`deep` moves every Role; `implementer=deep` moves one."""
132
+ default: ModelTier | None = None
133
+ per_role: dict[str, ModelTier] = {}
134
+
135
+ for value in values or ():
136
+ role, _, tier = value.partition("=")
137
+ if not tier:
138
+ default = _tier(role)
139
+ continue
140
+ per_role[role.strip().lower()] = _tier(tier)
141
+
142
+ return default, per_role
143
+
144
+
145
+ def _tier(value: str) -> ModelTier:
146
+ try:
147
+ return ModelTier(value.strip().lower())
148
+ except ValueError as exc:
149
+ known = ", ".join(str(tier) for tier in ModelTier)
150
+ raise SystemExit(f"unknown Model Tier {value.strip()!r}; expected one of: {known}") from exc
151
+
152
+
153
+ def build_interviewer(stdin=None, prompt=input):
154
+ """The human, as a callable — or `None` when nothing interactive is attached.
155
+
156
+ The terminal lives here and nowhere else: `core` and `agents` take a
157
+ callable, so the interview is exercised in tests by passing a list of
158
+ answers rather than by faking a console.
159
+
160
+ An empty line ends the interview, and so does end-of-input. A Task that was
161
+ already clear should not cost a conversation, and a human who has said
162
+ everything they have to say should not have to answer three more questions
163
+ to get their Issue.
164
+ """
165
+ stream = stdin if stdin is not None else sys.stdin
166
+ if not (hasattr(stream, "isatty") and stream.isatty()):
167
+ return None
168
+
169
+ asked = False
170
+
171
+ def ask(question: str) -> str | None:
172
+ nonlocal asked
173
+ if not asked:
174
+ print("\nThe Orchestrator has questions before it writes anything down.")
175
+ print("Answer them, or press Enter on an empty line to plan with what it has.")
176
+ asked = True
177
+ print(f"\n {question}")
178
+ try:
179
+ answer = prompt(" > ").strip()
180
+ except EOFError:
181
+ print()
182
+ return None
183
+ return answer or None
184
+
185
+ return ask
186
+
187
+
188
+ def _run_plan(args: argparse.Namespace, runner=None) -> int:
189
+ from .core.runtime import Forge, RunFailed
190
+ from .providers import DEFAULT_PROVIDER
191
+
192
+ forge = Forge(cwd=args.directory, provider=args.provider or DEFAULT_PROVIDER, runner=runner)
193
+
194
+ try:
195
+ outcome = forge.plan(
196
+ args.task,
197
+ tier=_tier(args.tier) if args.tier else None,
198
+ interviewer=build_interviewer(),
199
+ )
200
+ except RunFailed as exc:
201
+ print(f"agentforge: {exc}", file=sys.stderr)
202
+ return 2
203
+
204
+ result = outcome.result
205
+ if not outcome.filed:
206
+ heading = "needs a decision from you" if result.escalated else "could not plan this task"
207
+ print(f"agentforge: the Orchestrator {heading}.", file=sys.stderr)
208
+ print(f" {result.summary}", file=sys.stderr)
209
+ return 1 if result.escalated else 2
210
+
211
+ issue = outcome.issue
212
+ document = outcome.document
213
+ print(f"\nFiled issue #{issue.number}: {issue.url}")
214
+ print(f" Roster: {', '.join(f'{r.name} ({r.tier})' for r in document.roster)}")
215
+ if outcome.interview:
216
+ print(f" Interview: {len(outcome.interview)} question(s) answered")
217
+ for note in document.notes:
218
+ print(f" Note: {note}")
219
+
220
+ if outcome.touched:
221
+ print("\nThe interview left changes in your working tree:")
222
+ for path in outcome.touched:
223
+ print(f" - {path}")
224
+ print("Review and commit them: a Run refuses to start on a dirty tree.")
225
+
226
+ print(f"\nRun it with: agentforge implement {issue.number}")
227
+ return 0
228
+
229
+
230
+ def _run_implement(args: argparse.Namespace, runner=None) -> int:
231
+ from .core.issues import IssueError, render_run_cost
232
+ from .core.runtime import Forge, RunFailed
233
+ from .providers import DEFAULT_PROVIDER
234
+
235
+ default_tier, per_role = parse_tier_overrides(args.tier)
236
+ forge = Forge(cwd=args.directory, provider=args.provider or DEFAULT_PROVIDER, runner=runner)
237
+
238
+ try:
239
+ state = forge.implement(
240
+ args.issue,
241
+ tier_overrides=per_role or None,
242
+ tier=default_tier,
243
+ allow_commands=args.allow_commands,
244
+ resolve_context=not args.no_context_pack,
245
+ use_plugins=not args.no_plugins,
246
+ )
247
+ except (RunFailed, IssueError) as exc:
248
+ print(f"agentforge: {exc}", file=sys.stderr)
249
+ return 2
250
+
251
+ for result in state.results:
252
+ marker = {
253
+ Outcome.COMPLETED: "ok",
254
+ Outcome.ESCALATED: "escalated",
255
+ Outcome.FAILED: "failed",
256
+ }[result.outcome]
257
+ print(f" [{marker}] {result.role} ({result.tier}) — {result.summary}")
258
+
259
+ if state.results:
260
+ print(f"\n Cost: {render_run_cost(state.results)}")
261
+
262
+ if state.status is RunStatus.AWAITING_SIGNOFF:
263
+ print(f"\nDraft pull request: {state.pull_request}")
264
+ print("AgentForge stops at Sign-off. A human merges.")
265
+ return 0
266
+
267
+ if state.status is RunStatus.HALTED:
268
+ print(
269
+ f"\nRun halted at step {state.current_step}. Issue #{state.issue} is labelled "
270
+ f"`{RunStatus.HALTED.label}`; correct the plan block and re-run.",
271
+ file=sys.stderr,
272
+ )
273
+ return 1
274
+
275
+ if state.status is RunStatus.SUSPENDED:
276
+ blocking = next((entry for entry in reversed(state.gates) if entry.blocked), None)
277
+ gate = f"the `{blocking.kind}` Gate" if blocking else "a Gate"
278
+ print(
279
+ f"\nRun suspended at step {state.current_step}, waiting on {gate}. Issue "
280
+ f"#{state.issue} is labelled `{RunStatus.SUSPENDED.label}`; re-run once it clears.",
281
+ file=sys.stderr,
282
+ )
283
+ if blocking and blocking.summary:
284
+ print(f" {blocking.summary}", file=sys.stderr)
285
+ return 1
286
+
287
+ if not state.results:
288
+ print(f"Issue #{state.issue} has nothing left to run.")
289
+ return 0
290
+
291
+ print(f"\nRun failed. See issue #{state.issue}.", file=sys.stderr)
292
+ return 2
293
+
294
+
295
+ def _run_command(args: argparse.Namespace, runner=None) -> int:
296
+ """`agentforge run`: a chore, run directly, with no Issue and no Run.
297
+
298
+ Activation outside a Run has no blast radius to read, so what answers is
299
+ what the repository is — its root markers. A dbt project has dbt chores
300
+ whatever anybody is editing today (ADR-0019).
301
+
302
+ The human typing this is ADR-0007's grant, so a Command that starts a
303
+ process may start it here. That is the difference between this and the same
304
+ Command reached from inside an unattended Run.
305
+ """
306
+ from pathlib import Path
307
+
308
+ from .core.commands import run_command
309
+ from .core.contracts import Plan
310
+ from .core.process import SubprocessRunner
311
+ from .core.registry import activate, commands_for
312
+
313
+ root = Path(args.directory).resolve()
314
+ table = commands_for(activate(Plan(summary=""), root))
315
+
316
+ if not args.name:
317
+ if not table:
318
+ print(f"No Plugin answers for {root}, so there are no Commands to run.")
319
+ print("A Command is contributed by a Plugin: see `agentforge --help`.")
320
+ return 0
321
+ print("Commands this repository's Plugins contribute:\n")
322
+ for name, command in sorted(table.items()):
323
+ named = " ".join(f"<{argument}>" for argument in command.arguments)
324
+ print(f" {name} {named}".rstrip())
325
+ if command.summary:
326
+ print(f" {command.summary}")
327
+ return 0
328
+
329
+ command = table.get(args.name.strip().lower())
330
+ if command is None:
331
+ available = ", ".join(sorted(table)) or "none in this repository"
332
+ print(f"agentforge: no Command named {args.name!r}; available: {available}", file=sys.stderr)
333
+ return 2
334
+
335
+ outcome = run_command(
336
+ command,
337
+ args.arguments,
338
+ root=root,
339
+ runner=runner if runner is not None else SubprocessRunner(),
340
+ allow_commands=True,
341
+ )
342
+
343
+ for path in outcome.written:
344
+ print(f" wrote {path}")
345
+
346
+ if outcome.error:
347
+ print(f"agentforge: {outcome.error}", file=sys.stderr)
348
+ return 2
349
+
350
+ if outcome.result is not None and not outcome.result.ok:
351
+ rendered = " ".join(outcome.result.argv)
352
+ print(f"agentforge: `{rendered}` exited {outcome.result.returncode}", file=sys.stderr)
353
+ detail = (outcome.result.stderr or outcome.result.stdout).strip()
354
+ if detail:
355
+ print(f" {detail[:800]}", file=sys.stderr)
356
+ return 1
357
+
358
+ if outcome.written:
359
+ print("\nReview them as a diff and commit them yourself: a Command commits nothing.")
360
+ return 0
361
+
362
+
363
+ def _run_init(args: argparse.Namespace, runner=None) -> int:
364
+ """`agentforge init`: look at the repository, say so, and write the config.
365
+
366
+ The precondition comes first and refuses loudly. A repository with no
367
+ GitHub remote cannot host a Run (ADR-0002), and finding that out at setup is
368
+ better than finding it out when the first Run halts — so nothing is created
369
+ until `open_repository` has answered.
370
+
371
+ What init detects and cannot yet persist it prints. `load_config` reads two
372
+ keys, and writing the rest would be writing keys nothing consults (ADR-0020).
373
+ """
374
+ from .core.config import load_config
375
+ from .core.contracts import Plan
376
+ from .core.process import SubprocessRunner
377
+ from .core.project import config_path, detect, differences, render_config
378
+ from .core.registry import activate
379
+ from .core.repo import PreconditionFailed, open_repository
380
+ from .providers import DEFAULT_PROVIDER, PROVIDERS
381
+
382
+ provider = (args.provider or DEFAULT_PROVIDER).strip().lower()
383
+ if provider not in PROVIDERS:
384
+ known = ", ".join(sorted(PROVIDERS))
385
+ print(f"agentforge: unknown provider {provider!r}; available: {known}", file=sys.stderr)
386
+ return 2
387
+
388
+ runner = runner if runner is not None else SubprocessRunner()
389
+ try:
390
+ repo = open_repository(runner, args.directory)
391
+ except PreconditionFailed as exc:
392
+ print(f"agentforge: {exc}", file=sys.stderr)
393
+ return 2
394
+
395
+ active = activate(Plan(summary=""), repo.root)
396
+ context = detect(
397
+ repo.root,
398
+ provider,
399
+ tracked=repo.tracked_files(),
400
+ plugins=tuple(plugin.name for plugin in active.plugins),
401
+ )
402
+
403
+ print(f"Repository: {repo.root}")
404
+ print(f" Languages: {', '.join(context.languages) or 'none recognised'}")
405
+ print(f" Provider: {context.provider} ({context.capability_tier} capability tier)")
406
+ suite = " ".join(context.test_suite)
407
+ where = context.suite_detected or "not detected, so this is the documented default"
408
+ print(f" Suite: `{suite}` — {where}")
409
+ print(f" Plugins: {', '.join(context.plugins) or 'none by root marker'}")
410
+ print(
411
+ " printed, not written: which Plugins answer is decided per Run\n"
412
+ " from the frozen plan's blast radius, not from this file."
413
+ )
414
+
415
+ path = config_path(repo.root)
416
+ if path.is_file() and not args.force:
417
+ found = differences(context, path.read_text(encoding="utf-8"))
418
+ print(f"\n{path} already exists.")
419
+ if not found:
420
+ print("It matches what init would write. Nothing to do.")
421
+ return 0
422
+ for line in found:
423
+ print(f" - {line}")
424
+ print("Nothing was written. Re-run with --force to replace it.")
425
+ return 1
426
+
427
+ path.parent.mkdir(parents=True, exist_ok=True)
428
+ path.write_text(render_config(context), encoding="utf-8")
429
+ load_config(repo.root) # it reads back, or this command has not succeeded
430
+
431
+ print(f"\nWrote {path}")
432
+ print("Review it and commit it: AgentForge reads it and never edits it again.")
433
+ return 0
434
+
435
+
436
+ def main(argv: list[str] | None = None, runner=None) -> int:
437
+ """`runner` is the Command Runner seam: leave it unset and the real one is
438
+ built. Tests pass a fake and the whole CLI runs offline."""
439
+ parser = build_parser()
440
+ args = parser.parse_args(argv)
441
+
442
+ if args.command is None:
443
+ parser.print_help()
444
+ return 2
445
+
446
+ if args.command == "unslop":
447
+ return _run_unslop(args, runner)
448
+ if args.command == "plan":
449
+ return _run_plan(args, runner)
450
+ if args.command == "implement":
451
+ return _run_implement(args, runner)
452
+ if args.command == "run":
453
+ return _run_command(args, runner)
454
+ if args.command == "init":
455
+ return _run_init(args, runner)
456
+
457
+ raise SystemExit(f"agentforge {args.command} is not implemented yet.")
458
+
459
+
460
+ if __name__ == "__main__":
461
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Context resolution and extraction."""
@@ -0,0 +1,76 @@
1
+ """Per-language readers that turn one file into what it defines and what it uses.
2
+
3
+ An extractor answers two questions about a single file and nothing else: what
4
+ does it define, and what does it reach for. It never opens a second file, never
5
+ resolves an import, and never decides what belongs in a Context Pack — that is
6
+ `context.resolver`'s job, and keeping the two apart is what lets a language be
7
+ added as one small module with one fixture.
8
+
9
+ A file type nobody wrote an extractor for is not an error. `extractor_for`
10
+ returns `None`, the resolver carries the path, and nothing is claimed about the
11
+ contents — an unfamiliar language degrades the pack rather than failing the Run.
12
+
13
+ Every extractor takes text rather than a path. Reading is the resolver's job, so
14
+ these are pure functions and their tests need no repository.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable, Mapping
20
+ from pathlib import Path
21
+
22
+ from . import python as python_extractor
23
+ from . import sql as sql_extractor
24
+ from . import yaml as yaml_extractor
25
+ from .base import Extraction
26
+
27
+ #: Suffix to extractor. Lowercased before lookup, because a repository written
28
+ #: on Windows has `.SQL` files in it and they are the same language.
29
+ EXTRACTORS: dict[str, Callable[[str], Extraction]] = {
30
+ ".py": python_extractor.extract,
31
+ ".sql": sql_extractor.extract,
32
+ ".yaml": yaml_extractor.extract,
33
+ ".yml": yaml_extractor.extract,
34
+ }
35
+
36
+
37
+ def extractor_for(
38
+ path: str | Path, extractors: Mapping[str, Callable[[str], Extraction]] | None = None
39
+ ) -> Callable[[str], Extraction] | None:
40
+ """The extractor for this path's file type, or `None` if nobody wrote one.
41
+
42
+ `extractors` is the table to look in, defaulting to the built-in three. A
43
+ Run with active Plugins passes a wider one, assembled by `core.registry`,
44
+ and the widening is invisible from here: a Plugin's reader is looked up the
45
+ same way and by the same suffix.
46
+ """
47
+ table = EXTRACTORS if extractors is None else extractors
48
+ return table.get(Path(path).suffix.lower())
49
+
50
+
51
+ def extract(
52
+ path: str | Path,
53
+ text: str,
54
+ extractors: Mapping[str, Callable[[str], Extraction]] | None = None,
55
+ ) -> Extraction:
56
+ """Read one file with whatever extractor its type has.
57
+
58
+ An unknown type and a file that will not parse produce the same empty
59
+ extraction on purpose: in both cases AgentForge has nothing to say about the
60
+ contents, and inventing a difference between them would be inventing a
61
+ claim.
62
+
63
+ A Plugin's extractor is caught by the same `except` as a built-in one. A
64
+ Plugin that raises costs the pack one file's contents, never the Run — the
65
+ same bargain `core.registry` makes when a Plugin raises during activation.
66
+ """
67
+ reader = extractor_for(path, extractors)
68
+ if reader is None:
69
+ return Extraction()
70
+ try:
71
+ return reader(text)
72
+ except Exception: # noqa: BLE001 - a malformed file degrades the pack, never a Run
73
+ return Extraction()
74
+
75
+
76
+ __all__ = ["EXTRACTORS", "Extraction", "extract", "extractor_for"]
@@ -0,0 +1,47 @@
1
+ """What an extractor hands back.
2
+
3
+ Its own module so that an extractor can import it without importing the
4
+ registry that imports every extractor. `providers/base.py` splits for the same
5
+ reason.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Extraction:
15
+ """What one file defines, and what it reaches for outside itself.
16
+
17
+ Two tuples rather than one, because a Role reading the pack asks different
18
+ questions of them: `symbols` is where to make the change, and `references`
19
+ is what the change can break. A Python module's functions and a YAML file's
20
+ keys are both symbols; its imports and a query's source tables are both
21
+ references.
22
+ """
23
+
24
+ symbols: tuple[str, ...] = ()
25
+ references: tuple[str, ...] = ()
26
+
27
+ def __bool__(self) -> bool:
28
+ return bool(self.symbols or self.references)
29
+
30
+
31
+ def ordered(names) -> tuple[str, ...]:
32
+ """First occurrence wins, order preserved, blanks dropped.
33
+
34
+ Every extractor needs this and every extractor needs it to behave the same
35
+ way: ADR-0010 makes a pack's contents deterministic for a given Plan and
36
+ repository, and a set would make the order of two symbols depend on the
37
+ hash seed.
38
+ """
39
+ seen: dict[str, None] = {}
40
+ for name in names:
41
+ text = str(name).strip()
42
+ if text:
43
+ seen.setdefault(text, None)
44
+ return tuple(seen)
45
+
46
+
47
+ __all__ = ["Extraction", "ordered"]
@@ -0,0 +1,65 @@
1
+ """What a Python module defines, and what it imports.
2
+
3
+ Parsed with `ast` rather than matched with regular expressions. A regex that
4
+ finds `def` also finds it in a docstring, and a Context Pack that names symbols
5
+ which are not there costs a Role the tokens to discover that.
6
+
7
+ A module that will not parse yields nothing. Half a syntax tree is a worse
8
+ answer than no answer, and the file is still carried in the pack by path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import sys
15
+
16
+ from .base import Extraction, ordered
17
+
18
+ #: Methods of a class are carried as `Class.method`, one level deep. A nested
19
+ #: function is a detail of its parent and is not a place a Plan sends anybody.
20
+ _DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
21
+
22
+
23
+ def extract(text: str) -> Extraction:
24
+ """The module's top-level definitions and the modules it imports."""
25
+ tree = ast.parse(text)
26
+
27
+ symbols: list[str] = []
28
+ references: list[str] = []
29
+
30
+ for node in tree.body:
31
+ if isinstance(node, _DEFINITIONS):
32
+ symbols.append(node.name)
33
+ if isinstance(node, ast.ClassDef):
34
+ symbols.extend(
35
+ f"{node.name}.{child.name}"
36
+ for child in node.body
37
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
38
+ )
39
+
40
+ for node in ast.walk(tree):
41
+ if isinstance(node, ast.Import):
42
+ references.extend(alias.name for alias in node.names)
43
+ elif isinstance(node, ast.ImportFrom):
44
+ # A relative import keeps its dots: `..core.contracts` says which
45
+ # package the file belongs to, and `core.contracts` would not.
46
+ references.append("." * node.level + (node.module or ""))
47
+
48
+ return Extraction(
49
+ symbols=ordered(symbols),
50
+ references=ordered(name for name in references if not _stdlib(name)),
51
+ )
52
+
53
+
54
+ def _stdlib(name: str) -> bool:
55
+ """Whether an import names the standard library.
56
+
57
+ Dropped, because a reference is what a change can break and nothing in a
58
+ Plan breaks `pathlib`. It is also most of what a typical module imports, so
59
+ carrying it would spend the pack's budget on the one part of it every Role
60
+ already knows.
61
+ """
62
+ return name.partition(".")[0] in sys.stdlib_module_names
63
+
64
+
65
+ __all__ = ["extract"]