agentflow-proof 1.0.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.
- agentflow/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
agentflow/recommend.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Deterministic workflow recommendation from a task brief (#70).
|
|
2
|
+
|
|
3
|
+
Reads a machine-authored task brief and returns a recommended workflow posture
|
|
4
|
+
(one of five archetypes) plus nearest-cheaper / nearest-safer alternatives and a
|
|
5
|
+
full workflow-contract candidate. Pure and stdlib-only: no ``.agent`` writes, no
|
|
6
|
+
plan locking, no ``load_pack``, no subprocess/eval/import of data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .contracts import RISK_LEVELS, WORKFLOW_CONTRACT_SCHEMA_VERSION
|
|
15
|
+
from .versioning import validate_schema_version
|
|
16
|
+
|
|
17
|
+
BRIEF_SCHEMA_VERSION = "0.1.0"
|
|
18
|
+
RECOMMENDATION_SCHEMA_VERSION = "0.1.0"
|
|
19
|
+
PACK_ID = "agentflow-default"
|
|
20
|
+
|
|
21
|
+
TASK_TYPES = ("docs", "bugfix", "feature", "refactor")
|
|
22
|
+
BLAST_RADII = ("isolated", "local", "cross_cutting")
|
|
23
|
+
DECLARED_SIZES = ("xs", "s", "m", "l", "xl")
|
|
24
|
+
DOCS_EXTENSIONS = (".md", ".rst", ".txt", ".adoc")
|
|
25
|
+
DOCS_DIR_SEGMENTS = ("docs", "doc")
|
|
26
|
+
|
|
27
|
+
SMALL_FILE_MAX = 5
|
|
28
|
+
LARGE_FILE_MIN = 20
|
|
29
|
+
UNKNOWN = "unknown"
|
|
30
|
+
|
|
31
|
+
BRIEF_FIELDS = {
|
|
32
|
+
"schema_version",
|
|
33
|
+
"task_type",
|
|
34
|
+
"declared_risk",
|
|
35
|
+
"security_sensitive",
|
|
36
|
+
"candidate_files",
|
|
37
|
+
"blast_radius",
|
|
38
|
+
"validation_needs",
|
|
39
|
+
"declared_size",
|
|
40
|
+
}
|
|
41
|
+
REQUIRED_BRIEF_FIELDS = ("schema_version", "task_type", "declared_risk")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _non_empty_string(value: Any) -> bool:
|
|
45
|
+
return isinstance(value, str) and bool(value.strip())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _string_list(value: Any) -> bool:
|
|
49
|
+
return isinstance(value, list) and all(_non_empty_string(item) for item in value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_brief(brief: Any) -> List[str]:
|
|
53
|
+
"""Return machine-readable diagnostics for a task brief; empty means valid."""
|
|
54
|
+
errors: List[str] = []
|
|
55
|
+
if not isinstance(brief, dict):
|
|
56
|
+
return ["task brief must be a JSON object"]
|
|
57
|
+
|
|
58
|
+
for field in sorted(set(brief) - BRIEF_FIELDS):
|
|
59
|
+
errors.append(f"unknown task brief field: {field}")
|
|
60
|
+
for field in REQUIRED_BRIEF_FIELDS:
|
|
61
|
+
if field not in brief:
|
|
62
|
+
errors.append(f"missing required task brief field: {field}")
|
|
63
|
+
if errors:
|
|
64
|
+
return errors
|
|
65
|
+
|
|
66
|
+
errors.extend(
|
|
67
|
+
validate_schema_version(brief["schema_version"], BRIEF_SCHEMA_VERSION, "task-brief")
|
|
68
|
+
)
|
|
69
|
+
if brief["task_type"] not in TASK_TYPES:
|
|
70
|
+
errors.append("task_type must be one of: " + ", ".join(TASK_TYPES))
|
|
71
|
+
if brief["declared_risk"] not in RISK_LEVELS:
|
|
72
|
+
errors.append("declared_risk must be one of: " + ", ".join(RISK_LEVELS))
|
|
73
|
+
|
|
74
|
+
if "security_sensitive" in brief and not isinstance(brief["security_sensitive"], bool):
|
|
75
|
+
errors.append("security_sensitive must be boolean")
|
|
76
|
+
if "candidate_files" in brief and not _string_list(brief["candidate_files"]):
|
|
77
|
+
errors.append("candidate_files must be an array of non-empty strings")
|
|
78
|
+
if "validation_needs" in brief and not _string_list(brief["validation_needs"]):
|
|
79
|
+
errors.append("validation_needs must be an array of non-empty strings")
|
|
80
|
+
if "blast_radius" in brief and brief["blast_radius"] not in BLAST_RADII:
|
|
81
|
+
errors.append("blast_radius must be one of: " + ", ".join(BLAST_RADII))
|
|
82
|
+
if "declared_size" in brief and brief["declared_size"] not in DECLARED_SIZES:
|
|
83
|
+
errors.append("declared_size must be one of: " + ", ".join(DECLARED_SIZES))
|
|
84
|
+
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Archetype:
|
|
90
|
+
profile_id: str
|
|
91
|
+
rank: int
|
|
92
|
+
review_depth: str
|
|
93
|
+
hunk_attribution: str
|
|
94
|
+
require_review_run: bool
|
|
95
|
+
required_capabilities: Tuple[Dict[str, Any], ...]
|
|
96
|
+
required_gates: Tuple[str, ...]
|
|
97
|
+
summary: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
ARCHETYPES: Tuple[Archetype, ...] = (
|
|
101
|
+
Archetype("docs-only", 0, "none", "observe", False, (), (),
|
|
102
|
+
"a documentation-only, low-risk change"),
|
|
103
|
+
Archetype("small-bugfix", 1, "light", "enforce", False, (), ("unit-tests",),
|
|
104
|
+
"a bounded, low-risk fix with few known files"),
|
|
105
|
+
Archetype("medium-feature", 2, "standard", "enforce", False, (), ("unit-tests",),
|
|
106
|
+
"a change that adds bounded new behavior"),
|
|
107
|
+
Archetype("large-feature", 3, "deep", "enforce", True, (), ("unit-tests",),
|
|
108
|
+
"broad or cross-cutting work"),
|
|
109
|
+
Archetype("high-risk", 4, "deep", "enforce", True,
|
|
110
|
+
({"id": "security-review", "required": True},), ("unit-tests", "security-scan"),
|
|
111
|
+
"high-risk or security-sensitive work"),
|
|
112
|
+
)
|
|
113
|
+
ARCHETYPES_BY_ID: Dict[str, Archetype] = {a.profile_id: a for a in ARCHETYPES}
|
|
114
|
+
# Rank must equal index so "nearest cheaper/safer" is index +/- 1 (see _alternatives).
|
|
115
|
+
assert [a.rank for a in ARCHETYPES] == list(range(len(ARCHETYPES)))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_docs_path(path: str) -> bool:
|
|
119
|
+
if path.lower().endswith(DOCS_EXTENSIONS):
|
|
120
|
+
return True
|
|
121
|
+
return any(segment in DOCS_DIR_SEGMENTS for segment in path.split("/"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rationale(profile_id: str) -> str:
|
|
125
|
+
return f"Recommended {profile_id}: {ARCHETYPES_BY_ID[profile_id].summary}."
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def classify(brief: Dict[str, Any]) -> Tuple[str, List[str], str]:
|
|
129
|
+
"""Map a (validated) brief to ``(profile_id, signals, rationale)``. Pure.
|
|
130
|
+
|
|
131
|
+
Ordered rules, first match wins. Missing optional signals are ``unknown``
|
|
132
|
+
and never read as "safe": only an explicitly-present, satisfied signal may
|
|
133
|
+
de-escalate to ``docs-only`` or ``small-bugfix``.
|
|
134
|
+
"""
|
|
135
|
+
task_type = brief["task_type"]
|
|
136
|
+
declared_risk = brief["declared_risk"]
|
|
137
|
+
security = bool(brief.get("security_sensitive", False))
|
|
138
|
+
files = brief.get("candidate_files")
|
|
139
|
+
files = list(files) if isinstance(files, list) else None
|
|
140
|
+
blast = brief.get("blast_radius", UNKNOWN)
|
|
141
|
+
size = brief.get("declared_size", UNKNOWN)
|
|
142
|
+
file_count = len(files) if files is not None else None
|
|
143
|
+
|
|
144
|
+
broad_signal = (
|
|
145
|
+
blast == "cross_cutting"
|
|
146
|
+
or size in ("l", "xl")
|
|
147
|
+
or (file_count is not None and file_count >= LARGE_FILE_MIN)
|
|
148
|
+
)
|
|
149
|
+
all_docs = files is not None and len(files) > 0 and all(_is_docs_path(f) for f in files)
|
|
150
|
+
bounded_small = (
|
|
151
|
+
files is not None
|
|
152
|
+
and len(files) > 0
|
|
153
|
+
and len(files) <= SMALL_FILE_MAX
|
|
154
|
+
and not broad_signal
|
|
155
|
+
and size in ("xs", "s")
|
|
156
|
+
and blast in ("isolated", "local")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if declared_risk == "high" or security:
|
|
160
|
+
profile_id = "high-risk"
|
|
161
|
+
elif task_type == "docs" and declared_risk == "low" and not security and all_docs:
|
|
162
|
+
profile_id = "docs-only"
|
|
163
|
+
elif broad_signal:
|
|
164
|
+
profile_id = "large-feature"
|
|
165
|
+
elif task_type == "bugfix" and declared_risk == "low" and not security and bounded_small:
|
|
166
|
+
profile_id = "small-bugfix"
|
|
167
|
+
else:
|
|
168
|
+
profile_id = "medium-feature"
|
|
169
|
+
|
|
170
|
+
signals = [
|
|
171
|
+
f"task_type={task_type}",
|
|
172
|
+
f"declared_risk={declared_risk}",
|
|
173
|
+
f"security_sensitive={str(security).lower()}",
|
|
174
|
+
f"candidate_files={file_count if file_count is not None else UNKNOWN}",
|
|
175
|
+
f"blast_radius={blast}",
|
|
176
|
+
f"declared_size={size}",
|
|
177
|
+
f"broad_signal={str(broad_signal).lower()}",
|
|
178
|
+
f"bounded_small={str(bounded_small).lower()}",
|
|
179
|
+
]
|
|
180
|
+
return profile_id, signals, _rationale(profile_id)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class RecommendError(ValueError):
|
|
184
|
+
"""Invalid recommendation argument (unknown profile / missing override reason)."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, message: str, code: str = "recommend_error") -> None:
|
|
187
|
+
super().__init__(message)
|
|
188
|
+
self.code = code
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_contract_candidate(
|
|
192
|
+
profile_id: str, brief: Dict[str, Any], *, selected_by: str, selection_reason: str
|
|
193
|
+
) -> Dict[str, Any]:
|
|
194
|
+
"""Project an archetype + brief into a full, valid workflow.contract.json object.
|
|
195
|
+
|
|
196
|
+
The candidate is never written; it is the report's ready-to-materialize
|
|
197
|
+
payload. ``validation_needs`` from the brief union into the archetype gates.
|
|
198
|
+
"""
|
|
199
|
+
arch = ARCHETYPES_BY_ID[profile_id]
|
|
200
|
+
needs = brief.get("validation_needs") or []
|
|
201
|
+
gates = sorted(set(arch.required_gates) | set(needs))
|
|
202
|
+
return {
|
|
203
|
+
"schema_version": WORKFLOW_CONTRACT_SCHEMA_VERSION,
|
|
204
|
+
"workflow_pack": PACK_ID,
|
|
205
|
+
"workflow_profile": arch.profile_id,
|
|
206
|
+
"selected_by": selected_by,
|
|
207
|
+
"selection_reason": selection_reason,
|
|
208
|
+
"required_capabilities": [dict(cap) for cap in arch.required_capabilities],
|
|
209
|
+
"review_depth": arch.review_depth,
|
|
210
|
+
"validation_policy": {"required_gates": gates},
|
|
211
|
+
"proof_policy": {
|
|
212
|
+
"hunk_attribution": arch.hunk_attribution,
|
|
213
|
+
"require_review_run": arch.require_review_run,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _alternatives(profile_id: str) -> List[Dict[str, str]]:
|
|
219
|
+
rank = ARCHETYPES_BY_ID[profile_id].rank
|
|
220
|
+
out: List[Dict[str, str]] = []
|
|
221
|
+
for relation, target in (("cheaper", rank - 1), ("safer", rank + 1)):
|
|
222
|
+
if 0 <= target < len(ARCHETYPES):
|
|
223
|
+
arch = ARCHETYPES[target]
|
|
224
|
+
out.append({
|
|
225
|
+
"profile": arch.profile_id,
|
|
226
|
+
"relation": relation,
|
|
227
|
+
"reason": f"choose {arch.profile_id} for {arch.summary}",
|
|
228
|
+
})
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def recommend(
|
|
233
|
+
brief: Dict[str, Any],
|
|
234
|
+
*,
|
|
235
|
+
selected_profile: Optional[str] = None,
|
|
236
|
+
reason: Optional[str] = None,
|
|
237
|
+
) -> Dict[str, Any]:
|
|
238
|
+
"""Return the full recommendation report. Pure; raises ``RecommendError``.
|
|
239
|
+
|
|
240
|
+
``selected_profile`` lets an operator override the recommendation. An
|
|
241
|
+
override (selected != recommended) requires a non-empty ``reason``.
|
|
242
|
+
"""
|
|
243
|
+
recommended, signals, rationale = classify(brief)
|
|
244
|
+
selected = recommended
|
|
245
|
+
override: Optional[Dict[str, str]] = None
|
|
246
|
+
|
|
247
|
+
if selected_profile is not None:
|
|
248
|
+
if selected_profile not in ARCHETYPES_BY_ID:
|
|
249
|
+
raise RecommendError(f"unknown profile: {selected_profile}", code="unknown_profile")
|
|
250
|
+
selected = selected_profile
|
|
251
|
+
if selected_profile != recommended:
|
|
252
|
+
if not (reason and reason.strip()):
|
|
253
|
+
raise RecommendError(
|
|
254
|
+
"override requires --reason", code="override_requires_reason"
|
|
255
|
+
)
|
|
256
|
+
override = {
|
|
257
|
+
"from_profile": recommended,
|
|
258
|
+
"to_profile": selected_profile,
|
|
259
|
+
"reason": reason,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if override is not None:
|
|
263
|
+
selected_by = "recommend-workflow --selected-profile"
|
|
264
|
+
selection_reason = f"Override: {recommended} -> {selected}. {reason}"
|
|
265
|
+
else:
|
|
266
|
+
selected_by = "recommend-workflow"
|
|
267
|
+
selection_reason = rationale
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"schema_version": RECOMMENDATION_SCHEMA_VERSION,
|
|
271
|
+
"recommended": {"pack": PACK_ID, "profile": recommended},
|
|
272
|
+
"selected": {"pack": PACK_ID, "profile": selected},
|
|
273
|
+
"signals": signals,
|
|
274
|
+
"rationale": rationale,
|
|
275
|
+
"alternatives": _alternatives(recommended),
|
|
276
|
+
"override": override,
|
|
277
|
+
"workflow_contract_candidate": build_contract_candidate(
|
|
278
|
+
selected, brief, selected_by=selected_by, selection_reason=selection_reason
|
|
279
|
+
),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def render_text(report: Dict[str, Any]) -> List[str]:
|
|
284
|
+
"""Render a recommendation report as human-readable lines."""
|
|
285
|
+
recommended = report["recommended"]
|
|
286
|
+
lines = [f"recommended {recommended['pack']}/{recommended['profile']}"]
|
|
287
|
+
override = report.get("override")
|
|
288
|
+
if override is not None:
|
|
289
|
+
selected = report["selected"]
|
|
290
|
+
lines.append(f"selected {selected['pack']}/{selected['profile']} (override)")
|
|
291
|
+
lines.append(
|
|
292
|
+
f"override: {override['from_profile']} -> {override['to_profile']}: {override['reason']}"
|
|
293
|
+
)
|
|
294
|
+
lines.append("signals: " + ", ".join(report["signals"]))
|
|
295
|
+
lines.append(f"rationale: {report['rationale']}")
|
|
296
|
+
lines.append("alternatives:")
|
|
297
|
+
for alt in report["alternatives"]:
|
|
298
|
+
lines.append(f" {alt['relation']} {alt['profile']}: {alt['reason']}")
|
|
299
|
+
return lines
|