agentforge-framework 0.2.1__tar.gz → 0.2.2__tar.gz

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 (116) hide show
  1. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/PKG-INFO +3 -3
  2. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/README.md +2 -2
  3. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/pyproject.toml +1 -1
  4. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/__init__.py +1 -1
  5. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/decomposer.py +38 -5
  6. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/skills.py +78 -0
  7. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/providers/base.py +35 -12
  8. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/providers/claude.py +7 -4
  9. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/providers/codex.py +8 -4
  10. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/PKG-INFO +3 -3
  11. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_cli.py +2 -3
  12. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_decompose.py +74 -1
  13. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_docs.py +23 -0
  14. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_execution_posture.py +2 -2
  15. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_interview.py +1 -1
  16. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_plugins.py +2 -2
  17. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_providers.py +138 -7
  18. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_reviewer.py +3 -4
  19. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_runtime.py +10 -10
  20. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_skills.py +37 -0
  21. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_tester.py +1 -1
  22. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/LICENSE +0 -0
  23. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/setup.cfg +0 -0
  24. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/.claude-plugin/plugin.json +0 -0
  25. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/__init__.py +0 -0
  26. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/architect.py +0 -0
  27. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/implementer.py +0 -0
  28. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/orchestrator.py +0 -0
  29. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/reviewer.py +0 -0
  30. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/security.py +0 -0
  31. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/agents/tester.py +0 -0
  32. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/cli.py +0 -0
  33. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/__init__.py +0 -0
  34. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/extractors/__init__.py +0 -0
  35. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/extractors/base.py +0 -0
  36. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/extractors/python.py +0 -0
  37. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/extractors/sql.py +0 -0
  38. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/extractors/yaml.py +0 -0
  39. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/prompt.py +0 -0
  40. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/context/resolver.py +0 -0
  41. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/__init__.py +0 -0
  42. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/commands.py +0 -0
  43. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/config.py +0 -0
  44. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/contracts.py +0 -0
  45. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/gates.py +0 -0
  46. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/issues.py +0 -0
  47. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/plan_format.py +0 -0
  48. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/process.py +0 -0
  49. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/project.py +0 -0
  50. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/registry.py +0 -0
  51. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/repo.py +0 -0
  52. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/router.py +0 -0
  53. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/runtime.py +0 -0
  54. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/core/workflow.py +0 -0
  55. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/plugins/__init__.py +0 -0
  56. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/plugins/databricks/__init__.py +0 -0
  57. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/plugins/pyspark/__init__.py +0 -0
  58. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/plugins/python/__init__.py +0 -0
  59. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/plugins/sql/__init__.py +0 -0
  60. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/providers/__init__.py +0 -0
  61. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/MANIFEST.yaml +0 -0
  62. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/NOTICE +0 -0
  63. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +0 -0
  64. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +0 -0
  65. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/domain-modeling/SKILL.md +0 -0
  66. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/domain-modeling/agents/openai.yaml +0 -0
  67. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/grill-with-docs/SKILL.md +0 -0
  68. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/grilling/SKILL.md +0 -0
  69. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/grilling/agents/openai.yaml +0 -0
  70. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/to-spec/SKILL.md +0 -0
  71. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/to-spec/agents/openai.yaml +0 -0
  72. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/to-tickets/SKILL.md +0 -0
  73. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/to-tickets/agents/openai.yaml +0 -0
  74. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/SKILL.md +0 -0
  75. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +0 -0
  76. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/_lang.py +0 -0
  77. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +0 -0
  78. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +0 -0
  79. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/calibrate_score.py +0 -0
  80. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/check_packs.py +0 -0
  81. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/check_suggestions.py +0 -0
  82. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/contribute.py +0 -0
  83. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/diff_check.py +0 -0
  84. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/extract_constraints.py +0 -0
  85. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/harvest_classify.py +0 -0
  86. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/harvest_samples.py +0 -0
  87. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/readability_metrics.py +0 -0
  88. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/refresh_status.py +0 -0
  89. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/silhouette_scan.py +0 -0
  90. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/structure_scan.py +0 -0
  91. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/suggest.py +0 -0
  92. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/validate_preservation.py +0 -0
  93. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/voice_card.py +0 -0
  94. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/voice_profile.py +0 -0
  95. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/voice_score.py +0 -0
  96. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/unslop/scripts/wiki_sync.py +0 -0
  97. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/skills/write-plainly/SKILL.md +0 -0
  98. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/workflows/bugfix.yaml +0 -0
  99. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/workflows/feature.yaml +0 -0
  100. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework/workflows/review.yaml +0 -0
  101. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/SOURCES.txt +0 -0
  102. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/dependency_links.txt +0 -0
  103. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/entry_points.txt +0 -0
  104. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/requires.txt +0 -0
  105. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/src/agentforge_framework.egg-info/top_level.txt +0 -0
  106. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_agents.py +0 -0
  107. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_architect.py +0 -0
  108. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_config.py +0 -0
  109. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_context.py +0 -0
  110. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_contracts.py +0 -0
  111. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_gates.py +0 -0
  112. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_issues.py +0 -0
  113. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_plan_format.py +0 -0
  114. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_project.py +0 -0
  115. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_security.py +0 -0
  116. {agentforge_framework-0.2.1 → agentforge_framework-0.2.2}/tests/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentforge-framework
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A modular framework for coordinating specialized software agents.
5
5
  Author: Yash Mhatre
6
6
  License-Expression: Apache-2.0
@@ -145,7 +145,7 @@ without notice. See
145
145
  ```console
146
146
  $ pip install agentforge-framework
147
147
  $ agentforge --version
148
- agentforge 0.2.1
148
+ agentforge 0.2.2
149
149
  ```
150
150
 
151
151
  That is the whole of it. The two routes below the naming note are for a machine
@@ -169,7 +169,7 @@ from the [latest release](https://github.com/yashmhatre/AgentForge/releases/late
169
169
  and install the file:
170
170
 
171
171
  ```console
172
- $ pip install agentforge_framework-0.2.1-py3-none-any.whl
172
+ $ pip install agentforge_framework-0.2.2-py3-none-any.whl
173
173
  ```
174
174
 
175
175
  Publishing a release is a wizard rather than a checklist:
@@ -127,7 +127,7 @@ without notice. See
127
127
  ```console
128
128
  $ pip install agentforge-framework
129
129
  $ agentforge --version
130
- agentforge 0.2.1
130
+ agentforge 0.2.2
131
131
  ```
132
132
 
133
133
  That is the whole of it. The two routes below the naming note are for a machine
@@ -151,7 +151,7 @@ from the [latest release](https://github.com/yashmhatre/AgentForge/releases/late
151
151
  and install the file:
152
152
 
153
153
  ```console
154
- $ pip install agentforge_framework-0.2.1-py3-none-any.whl
154
+ $ pip install agentforge_framework-0.2.2-py3-none-any.whl
155
155
  ```
156
156
 
157
157
  Publishing a release is a wizard rather than a checklist:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentforge-framework"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "A modular framework for coordinating specialized software agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """AgentForge — coordination of specialized software agents through reusable workflows."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -45,6 +45,8 @@ from ..core.contracts import (
45
45
  Task,
46
46
  )
47
47
  from ..core.plan_format import (
48
+ RESULT_CLOSE,
49
+ RESULT_OPEN,
48
50
  SLICES_CLOSE,
49
51
  SLICES_OPEN,
50
52
  SPEC_CLOSE,
@@ -105,8 +107,9 @@ not publish the spec, do not open an issue, and do not apply a label.
105
107
 
106
108
  ## Required output
107
109
 
108
- End your reply with this block and nothing after it. Markdown inside it, not \
109
- JSON:
110
+ End your reply with these two blocks, in this order, and nothing after them.
111
+
112
+ First the spec. Markdown inside it, not JSON:
110
113
 
111
114
  {spec_open}
112
115
  ## Problem Statement
@@ -128,10 +131,22 @@ JSON:
128
131
  ...
129
132
  {spec_close}
130
133
 
134
+ Then your own verdict:
135
+
136
+ {result_open}
137
+ ```json
138
+ {{"outcome": "completed", "summary": "one line on what the spec covers"}}
139
+ ```
140
+ {result_close}
141
+
131
142
  Cover the whole source. Anything in it that reaches no user story reaches no \
132
143
  Issue and does not get built. Scale the spec to what you were given: a \
133
144
  one-sentence Task earns a short spec, and padding one out invents scope nobody \
134
- asked for.\
145
+ asked for.
146
+
147
+ If the source is too thin to synthesize without inventing most of it, write the \
148
+ verdict block with `"outcome": "escalated"` and a summary naming what you need \
149
+ the human to supply. Omit the spec block in that case.\
135
150
  """
136
151
 
137
152
  SLICES = """\
@@ -186,7 +201,9 @@ publish anything, do not open an issue, and do not apply a label.
186
201
 
187
202
  ## Required output
188
203
 
189
- End your reply with this block and nothing after it:
204
+ End your reply with these two blocks, in this order, and nothing after them.
205
+
206
+ First the cut:
190
207
 
191
208
  {slices_open}
192
209
  ```json
@@ -202,7 +219,19 @@ End your reply with this block and nothing after it:
202
219
  ]
203
220
  }}
204
221
  ```
205
- {slices_close}\
222
+ {slices_close}
223
+
224
+ Then your own verdict:
225
+
226
+ {result_open}
227
+ ```json
228
+ {{"outcome": "completed", "summary": "one line on how you cut it and why"}}
229
+ ```
230
+ {result_close}
231
+
232
+ If the spec cannot be cut without guessing at something it left open, write the \
233
+ verdict block with `"outcome": "escalated"` and a summary naming what has to be \
234
+ settled first. Omit the slices block in that case.\
206
235
  """
207
236
 
208
237
  #: What one approved Slice looks like when it is handed to a planning pass. The
@@ -298,6 +327,8 @@ class Decomposer:
298
327
  cwd=cwd,
299
328
  spec_open=SPEC_OPEN,
300
329
  spec_close=SPEC_CLOSE,
330
+ result_open=RESULT_OPEN,
331
+ result_close=RESULT_CLOSE,
301
332
  ),
302
333
  cwd,
303
334
  )
@@ -311,6 +342,8 @@ class Decomposer:
311
342
  cap=MAX_SLICES,
312
343
  slices_open=SLICES_OPEN,
313
344
  slices_close=SLICES_CLOSE,
345
+ result_open=RESULT_OPEN,
346
+ result_close=RESULT_CLOSE,
314
347
  ),
315
348
  cwd,
316
349
  )
@@ -14,6 +14,13 @@ one task. `COMPOSED` says what each expands to, because a composite has to
14
14
  survive both Capability Tiers — natively it fans out through the Skill tool, and
15
15
  as a Fragment there is no tool to fan out with, so the delivery path inlines what
16
16
  it names.
17
+
18
+ A skill's author can also forbid autonomous invocation outright, by marking
19
+ `disable-model-invocation` in the frontmatter. ADR-0005 chose delivery from one
20
+ input — what the Provider can do — and had none for what the skill permits, so a
21
+ marked skill offered natively is refused by the Skill tool and the Role escalates
22
+ instead of working. `fragment_only` reads the mark from the bundle as shipped, so
23
+ the rule survives a refresh that marks a skill we have never heard of.
17
24
  """
18
25
 
19
26
  from __future__ import annotations
@@ -22,8 +29,11 @@ import json
22
29
  import sys
23
30
  from collections.abc import Sequence
24
31
  from dataclasses import dataclass, field
32
+ from functools import lru_cache
25
33
  from pathlib import Path
26
34
 
35
+ import yaml
36
+
27
37
  from .process import CommandRunner, SubprocessRunner
28
38
 
29
39
  SKILLS_ROOT = Path(__file__).resolve().parent.parent / "skills"
@@ -69,6 +79,74 @@ def expand(names: Sequence[str]) -> tuple[str, ...]:
69
79
  return tuple(ordered)
70
80
 
71
81
 
82
+ #: The frontmatter key by which a skill's author says a model may not invoke it
83
+ #: autonomously — only a human typing the command may.
84
+ MODEL_INVOCATION_KEY = "disable-model-invocation"
85
+
86
+
87
+ def _frontmatter(text: str) -> dict:
88
+ """The YAML block a SKILL.md opens with, or nothing if it has none."""
89
+ if not text.startswith("---"):
90
+ return {}
91
+ parts = text.split("---", 2)
92
+ if len(parts) < 3:
93
+ return {}
94
+ data = yaml.safe_load(parts[1])
95
+ return data if isinstance(data, dict) else {}
96
+
97
+
98
+ @lru_cache(maxsize=1)
99
+ def fragment_only() -> frozenset[str]:
100
+ """Skills the bundle forbids a model to invoke, read from the bundle itself.
101
+
102
+ Hand-listing these would have exactly the problem vendoring exists to
103
+ prevent: a refresh that marks a third skill would be picked up by nobody,
104
+ and the failure arrives as an escalation from a planning pass rather than as
105
+ anything a reader would connect to a bundle bump.
106
+
107
+ Frontmatter that will not parse counts as marked. Fragment delivery costs
108
+ tokens; getting this wrong the other way costs the Run.
109
+ """
110
+ marked = set()
111
+ for path in sorted(SKILLS_ROOT.iterdir()):
112
+ source = path / "SKILL.md"
113
+ if not source.is_file():
114
+ continue
115
+ try:
116
+ data = _frontmatter(source.read_text(encoding="utf-8"))
117
+ except yaml.YAMLError:
118
+ marked.add(path.name)
119
+ continue
120
+ if data.get(MODEL_INVOCATION_KEY):
121
+ marked.add(path.name)
122
+ return frozenset(marked)
123
+
124
+
125
+ def forbids_model_invocation(name: str) -> bool:
126
+ """Whether a skill, or anything it composes, may not be model-invoked.
127
+
128
+ A composite is checked through its parts as well: natively it fans out
129
+ through the Skill tool, so a composite naming a marked skill would reach the
130
+ same refusal one indirection later.
131
+ """
132
+ marked = fragment_only()
133
+ return any(part in marked for part in expand((name,)))
134
+
135
+
136
+ def split_delivery(names: Sequence[str]) -> tuple[tuple[str, ...], tuple[str, ...]]:
137
+ """Partition declared skills into the natively deliverable and the rest.
138
+
139
+ This is consulted before the Capability Tier, not after it. A marked skill
140
+ has no native delivery at any tier, so the Fragment is not the degraded form
141
+ here — it is the only one.
142
+ """
143
+ native: list[str] = []
144
+ fragment: list[str] = []
145
+ for name in names:
146
+ (fragment if forbids_model_invocation(name) else native).append(name)
147
+ return tuple(native), tuple(fragment)
148
+
149
+
72
150
  class SkillNotFound(LookupError):
73
151
  """A skill was requested that is not in the vendored bundle."""
74
152
 
@@ -35,7 +35,7 @@ from ..core.contracts import (
35
35
  )
36
36
  from ..core.plan_format import extract_result_block
37
37
  from ..core.process import CommandResult, CommandRunner, MissingBinary, require
38
- from ..core.skills import expand, read_skill
38
+ from ..core.skills import expand, read_skill, split_delivery
39
39
 
40
40
 
41
41
  class ProviderError(RuntimeError):
@@ -133,9 +133,9 @@ class CliProvider(Provider):
133
133
  ) from exc
134
134
 
135
135
  @abstractmethod
136
- def build_argv(
137
- self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
138
- ) -> Sequence[str]: ...
136
+ def build_argv(self, model: str, native_skills: tuple[str, ...] = ()) -> Sequence[str]:
137
+ """The invocation, without the prompt. See `invoke` for why it is absent."""
138
+ ...
139
139
 
140
140
  @abstractmethod
141
141
  def parse_output(self, result: CommandResult) -> ProviderOutput: ...
@@ -149,9 +149,20 @@ class CliProvider(Provider):
149
149
  tier: ModelTier,
150
150
  cwd: Path,
151
151
  ) -> AgentResult:
152
+ """The prompt travels on stdin, never in the argument vector.
153
+
154
+ Windows caps a command line at 32,767 characters, and an AgentForge
155
+ prompt carries a Context Pack, a Spec, or both — `decompose` crossed the
156
+ cap on a 30k plan document and died as `[WinError 206]`. Linux allows
157
+ roughly 2MB, so every `ubuntu-latest` job in CI passed throughout (#100).
158
+
159
+ Always stdin, never "stdin when the prompt is long": a size threshold
160
+ would make this the rarely-taken path and the bug would return the first
161
+ time a prompt landed the other side of it.
162
+ """
152
163
  prompt, native_skills = self._deliver_skills(role, prompt)
153
- argv = self.build_argv(prompt, self.model_for(tier), native_skills)
154
- completed = self.runner.run(argv, cwd=cwd, timeout=self.timeout)
164
+ argv = self.build_argv(self.model_for(tier), native_skills)
165
+ completed = self.runner.run(argv, cwd=cwd, stdin=prompt, timeout=self.timeout)
155
166
  output = self.parse_output(completed)
156
167
  return to_agent_result(role=role, tier=tier, output=output)
157
168
 
@@ -166,6 +177,10 @@ class CliProvider(Provider):
166
177
  here and every body travels. Without that, a skill whose text says "run
167
178
  these two" reaches a Provider that cannot run anything and the Role is
168
179
  left with an instruction pointing at nothing.
180
+
181
+ A skill whose author forbids model invocation is a Fragment whatever the
182
+ tier, so the two deliveries are not exclusive: a Role can declare one of
183
+ each and the prompt carries both.
169
184
  """
170
185
  if not role.skills:
171
186
  return prompt, ()
@@ -173,16 +188,24 @@ class CliProvider(Provider):
173
188
  if self.capability_tier is CapabilityTier.NATIVE:
174
189
  for name in role.skills:
175
190
  read_skill(name) # refuse a name nothing answers for, before the CLI runs
176
- commands = ", ".join(f"/agentforge:{name}" for name in role.skills)
191
+ native, inlined = split_delivery(role.skills)
192
+ else:
193
+ native, inlined = (), tuple(role.skills)
194
+
195
+ if native:
196
+ commands = ", ".join(f"/agentforge:{name}" for name in native)
177
197
  instruction = (
178
198
  f"Use the declared native AgentForge skills before doing this work: {commands}."
179
199
  )
180
- return f"{instruction}\n\n{prompt}", role.skills
200
+ prompt = f"{instruction}\n\n{prompt}"
201
+
202
+ if inlined:
203
+ fragments = [
204
+ f"## Skill: {name}\n\n{read_skill(name).rstrip()}" for name in expand(inlined)
205
+ ]
206
+ prompt = f"{prompt}\n\n" + "\n\n".join(fragments) + "\n"
181
207
 
182
- fragments = [
183
- f"## Skill: {name}\n\n{read_skill(name).rstrip()}" for name in expand(role.skills)
184
- ]
185
- return f"{prompt}\n\n" + "\n\n".join(fragments) + "\n", ()
208
+ return prompt, native
186
209
 
187
210
 
188
211
  def to_agent_result(*, role: Role, tier: ModelTier, output: ProviderOutput) -> AgentResult:
@@ -47,13 +47,16 @@ class ClaudeProvider(CliProvider):
47
47
  def permission_mode(self) -> str:
48
48
  return self.PERMITTED if self.allow_commands else self.DENIED
49
49
 
50
- def build_argv(
51
- self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
52
- ) -> Sequence[str]:
50
+ def build_argv(self, model: str, native_skills: tuple[str, ...] = ()) -> Sequence[str]:
51
+ """`-p` carries no prompt argument: with none, the CLI reads stdin.
52
+
53
+ Verified against the installed CLI rather than the help text, which
54
+ documents `-p` as "print response and exit" and says nothing about where
55
+ the prompt comes from.
56
+ """
53
57
  argv = (
54
58
  self.binary,
55
59
  "-p",
56
- prompt,
57
60
  "--model",
58
61
  model,
59
62
  "--output-format",
@@ -74,11 +74,15 @@ class CodexProvider(CliProvider):
74
74
  #: rather than an edit here.
75
75
  REASONING_EFFORT: ClassVar[str] = "medium"
76
76
 
77
- def build_argv(
78
- self, prompt: str, model: str, native_skills: tuple[str, ...] = ()
79
- ) -> Sequence[str]:
77
+ def build_argv(self, model: str, native_skills: tuple[str, ...] = ()) -> Sequence[str]:
80
78
  """Options precede the subcommand: `codex [OPTIONS] <COMMAND> [ARGS]`.
81
79
 
80
+ The prompt argument is `-`, which `codex exec --help` documents as "read
81
+ instructions from stdin". Omitting it entirely reads stdin too, but the
82
+ sentinel says so out loud, and the same help says a prompt supplied
83
+ alongside piped stdin gets the stdin appended as a `<stdin>` block —
84
+ which is the failure mode the explicit `-` rules out.
85
+
82
86
  This adapter previously passed `--full-auto` after `exec`, which fails
83
87
  twice over: that flag does not exist in the current CLI, and options
84
88
  placed after the subcommand are rejected regardless.
@@ -98,7 +102,7 @@ class CodexProvider(CliProvider):
98
102
  "--ask-for-approval",
99
103
  self.PERMITTED if self.allow_commands else self.DENIED,
100
104
  "exec",
101
- prompt,
105
+ "-",
102
106
  )
103
107
 
104
108
  def parse_output(self, result: CommandResult) -> ProviderOutput:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentforge-framework
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: A modular framework for coordinating specialized software agents.
5
5
  Author: Yash Mhatre
6
6
  License-Expression: Apache-2.0
@@ -145,7 +145,7 @@ without notice. See
145
145
  ```console
146
146
  $ pip install agentforge-framework
147
147
  $ agentforge --version
148
- agentforge 0.2.1
148
+ agentforge 0.2.2
149
149
  ```
150
150
 
151
151
  That is the whole of it. The two routes below the naming note are for a machine
@@ -169,7 +169,7 @@ from the [latest release](https://github.com/yashmhatre/AgentForge/releases/late
169
169
  and install the file:
170
170
 
171
171
  ```console
172
- $ pip install agentforge_framework-0.2.1-py3-none-any.whl
172
+ $ pip install agentforge_framework-0.2.2-py3-none-any.whl
173
173
  ```
174
174
 
175
175
  Publishing a release is a wizard rather than a checklist:
@@ -272,7 +272,7 @@ def test_the_control_run_is_one_flag(runner, capsys):
272
272
 
273
273
  assert run(["implement", "12", "--allow-commands", "--no-context-pack"], runner) == 0
274
274
 
275
- prompts = [call[call.index("-p") + 1] for call in runner.matching("claude")]
275
+ prompts = runner.prompts_to("claude")
276
276
  assert prompts and all("## Context Pack" not in prompt for prompt in prompts)
277
277
 
278
278
 
@@ -516,8 +516,7 @@ def test_the_document_reaches_the_synthesis_pass(runner, tmp_path):
516
516
 
517
517
  run(["decompose", str(plan), "--yes"], runner)
518
518
 
519
- first = runner.matching("claude")[0]
520
- prompt = first[first.index("-p") + 1]
519
+ prompt = runner.prompts_to("claude")[0]
521
520
  assert "Late-arriving facts need a handler." in prompt
522
521
  assert "PLAN.md" in prompt
523
522
 
@@ -23,6 +23,11 @@ from agentforge_framework.agents.decomposer import (
23
23
  )
24
24
  from agentforge_framework.core.contracts import Slice
25
25
  from agentforge_framework.core.plan_format import (
26
+ RESULT_CLOSE,
27
+ RESULT_OPEN,
28
+ SLICES_OPEN,
29
+ SPEC_CLOSE,
30
+ SPEC_OPEN,
26
31
  PlanFormatError,
27
32
  extract_slices,
28
33
  order_slices,
@@ -71,7 +76,7 @@ def a_runner(issues=(12,)) -> FakeRunner:
71
76
 
72
77
  def _prompts(runner: FakeRunner) -> list[str]:
73
78
  """What each stage was actually asked, in order. `claude -p <prompt>`."""
74
- return [call[call.index("-p") + 1] for call in runner.matching("claude")]
79
+ return runner.prompts_to("claude")
75
80
 
76
81
 
77
82
  def bodies(runner: FakeRunner) -> list[str]:
@@ -500,3 +505,71 @@ def test_the_decomposer_reports_every_invocation_so_the_pass_can_be_priced():
500
505
  assert cut.ok
501
506
  assert len(cut.results) == 2
502
507
  assert len(cut.slices) == 3
508
+
509
+
510
+ # --- every stage asks for a verdict ----------------------------------------
511
+ #
512
+ # The bug this file did not catch (#96). Each stage's prompt asked for its own
513
+ # block and stopped, and `providers/base.py` requires a result block from every
514
+ # invocation — so `decompose` failed on its first call against a real model
515
+ # while 27 tests passed. The fixtures were more generous than the prompts: they
516
+ # appended a result block nothing had asked the model to write.
517
+ #
518
+ # So these read the prompt rather than a fake response. A fixture cannot prove
519
+ # that a prompt asks for what the parser needs; only the prompt can.
520
+
521
+
522
+ def _stage_prompts(runner: FakeRunner) -> list[str]:
523
+ return runner.prompts_to("claude")
524
+
525
+
526
+ def test_every_stage_asks_the_model_for_a_result_block():
527
+ """Whatever else a stage wants, it wants a verdict. `providers/base.py`
528
+ fails any invocation that comes back without one."""
529
+ runner = a_runner()
530
+ runner.script("claude", stdout=pipeline())
531
+
532
+ forge(runner).plan("add a retry", approver=_yes)
533
+
534
+ prompts = _stage_prompts(runner)
535
+ assert len(prompts) == 3, "spec, cut, and one planning pass"
536
+ for index, prompt in enumerate(prompts):
537
+ assert RESULT_OPEN in prompt, f"stage {index} never asks for a result block"
538
+ assert RESULT_CLOSE in prompt
539
+
540
+
541
+ def test_the_spec_stage_asks_for_the_spec_block_and_then_the_verdict():
542
+ runner = a_runner()
543
+ runner.script("claude", stdout=pipeline())
544
+
545
+ forge(runner).plan("add a retry", approver=_yes)
546
+
547
+ prompt = _stage_prompts(runner)[0]
548
+ assert prompt.index(SPEC_OPEN) < prompt.index(RESULT_OPEN), "in that order"
549
+ assert '"outcome": "escalated"' in prompt, "a source too thin to use has a way out"
550
+
551
+
552
+ def test_the_cut_stage_asks_for_the_slices_block_and_then_the_verdict():
553
+ runner = a_runner()
554
+ runner.script("claude", stdout=pipeline())
555
+
556
+ forge(runner).plan("add a retry", approver=_yes)
557
+
558
+ prompt = _stage_prompts(runner)[1]
559
+ assert prompt.index(SLICES_OPEN) < prompt.index(RESULT_OPEN), "in that order"
560
+ assert '"outcome": "escalated"' in prompt
561
+
562
+
563
+ def test_a_stage_that_answers_with_only_its_own_block_is_the_bug_that_shipped():
564
+ """Reproduces #96 exactly: a model that does what the old prompt asked, and
565
+ nothing more, gets a failed Agent Result rather than a spec."""
566
+ runner = a_runner()
567
+ runner.script(
568
+ "claude",
569
+ stdout=_envelope(f"{SPEC_OPEN}\n## Problem Statement\n\nNo verdict.\n{SPEC_CLOSE}"),
570
+ )
571
+
572
+ outcome = forge(runner).plan("add a retry", approver=_yes)
573
+
574
+ assert outcome.failure is not None
575
+ assert "without reporting a result block" in outcome.failure.summary
@@ -391,3 +391,26 @@ def test_the_matrix_runs_every_python_the_metadata_claims():
391
391
  tested = [int(version.split(".")[1]) for version in matrix]
392
392
 
393
393
  assert tested == list(range(floor, max(tested) + 1))
394
+
395
+
396
+ def test_the_suite_runs_on_windows_somewhere_in_ci():
397
+ """#100 was a Windows process limit that every `ubuntu-latest` job stepped
398
+ over without noticing, on a project whose author develops on Windows. It
399
+ survived a release that way.
400
+
401
+ This asks only that some job runs the suite on Windows — which job, and on
402
+ which Python, is left open.
403
+ """
404
+ workflow = yaml.safe_load(
405
+ (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
406
+ )
407
+
408
+ on_windows = [
409
+ job
410
+ for job in workflow["jobs"].values()
411
+ if "windows" in str(job.get("runs-on", "")) + str(job.get("strategy", ""))
412
+ ]
413
+ assert on_windows, "no CI job runs on Windows"
414
+ assert any(
415
+ "pytest" in str(step.get("run", "")) for job in on_windows for step in job.get("steps", [])
416
+ ), "a Windows job exists but nothing runs the suite on it"
@@ -108,7 +108,7 @@ def test_codex_options_precede_the_subcommand():
108
108
 
109
109
  for flag in ("--model", "--sandbox", "--ask-for-approval"):
110
110
  assert argv.index(flag) < exec_at, f"{flag} must precede `exec`"
111
- assert argv[-1] != "exec", "the prompt is the subcommand's argument"
111
+ assert argv[-1] == "-", "the subcommand's argument is the stdin sentinel, not the prompt"
112
112
 
113
113
 
114
114
  def test_codex_no_longer_passes_a_flag_the_cli_does_not_have():
@@ -205,7 +205,7 @@ def test_a_denied_role_is_told_to_report_rather_than_substitute_inspection():
205
205
 
206
206
  Forge(cwd=ROOT, provider="claude", runner=runner).implement(12)
207
207
 
208
- prompt = runner.only("claude")[2]
208
+ prompt = runner.prompt_to("claude")
209
209
  assert "cannot run commands" in prompt.lower()
210
210
  assert "escalate" in prompt.lower()
211
211
 
@@ -68,7 +68,7 @@ def an_orchestrator(*replies: str) -> tuple[Orchestrator, FakeRunner]:
68
68
 
69
69
 
70
70
  def prompts(runner: FakeRunner) -> list[str]:
71
- return [call[call.index("-p") + 1] for call in runner.matching("claude")]
71
+ return runner.prompts_to("claude")
72
72
 
73
73
 
74
74
  # --- the interview -----------------------------------------------------------
@@ -228,7 +228,7 @@ def test_fragments_never_travel_in_an_issue_body():
228
228
 
229
229
  def prompts_from(runner: FakeRunner) -> list[str]:
230
230
  """What each Agent was actually handed, read off the argument vector."""
231
- return [call[call.index("-p") + 1] for call in runner.matching("claude")]
231
+ return runner.prompts_to("claude")
232
232
 
233
233
 
234
234
  def a_python_run() -> FakeRunner:
@@ -298,7 +298,7 @@ def test_a_fragment_reaches_both_capability_tiers_the_same_way():
298
298
  runner = a_python_run()
299
299
  Forge(cwd=ROOT, provider="codex", runner=runner).implement(12, allow_commands=True)
300
300
 
301
- prompts = [call[call.index("exec") + 1] for call in runner.matching("codex")]
301
+ prompts = runner.prompts_to("codex")
302
302
  assert "Type-annotate new public functions" in prompts[0]
303
303
 
304
304