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/draft_plan.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Compile a task brief plus a workflow pack into an unlocked draft plan (#71).
|
|
2
|
+
|
|
3
|
+
This is the bridge from adaptive workflow *policy* (a #70 recommendation over a
|
|
4
|
+
#61 task brief, and a #17 workflow pack) to an executable Agentflow *plan*. It is
|
|
5
|
+
deterministic, provider-agnostic, and read-only at the module level: it never
|
|
6
|
+
locks a plan and never writes to disk (the CLI layer owns I/O).
|
|
7
|
+
|
|
8
|
+
The design treats the recommendation as a **lower-bound posture** and the pack as
|
|
9
|
+
the **executable catalog**. ``recommend`` says how careful the workflow must be;
|
|
10
|
+
the pack profile is the real, representable workflow whose plan template provides
|
|
11
|
+
the step topology. ``draft-plan`` selects the least-strict pack profile that
|
|
12
|
+
satisfies the recommended posture, hydrates that profile's template with the
|
|
13
|
+
brief, and emits an unlocked ``plan.lock.json`` draft that passes
|
|
14
|
+
``validate_plan``. ``lock-plan`` remains the only authority for validation and
|
|
15
|
+
locking.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from copy import deepcopy
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from . import packs
|
|
23
|
+
from .contracts import REVIEW_DEPTH_ORDER, REVIEW_DEPTH_POLICY
|
|
24
|
+
from .packs import _safe_relative_path
|
|
25
|
+
from .recommend import ARCHETYPES_BY_ID, LARGE_FILE_MIN, recommend, validate_brief
|
|
26
|
+
from .validation import validate_plan
|
|
27
|
+
from .workflow_contract import WORKFLOW_CONTRACT_PATH
|
|
28
|
+
|
|
29
|
+
DRAFT_PLAN_SCHEMA_VERSION = "0.2.0" # 0.2.0: output payload always carries a `warnings` array (#89)
|
|
30
|
+
SELECTION_MODE = "least_strict_satisfying_profile"
|
|
31
|
+
WORKFLOW_BLOCK_KEY = "workflow"
|
|
32
|
+
|
|
33
|
+
# #90: a profile that requires a review run will, at proof time, write review
|
|
34
|
+
# artifacts under this path; scope it into allowed_files so audit-drift does not
|
|
35
|
+
# flag them.
|
|
36
|
+
# ponytail: keep this constant until state_root actually varies per pack.
|
|
37
|
+
REVIEW_STATE_PATH = "docs/ai/state/"
|
|
38
|
+
|
|
39
|
+
# Strictness ladders. A profile satisfies a lower bound only when it is equal or
|
|
40
|
+
# stricter on every axis; "least strict" minimizes this tuple lexicographically.
|
|
41
|
+
# No recommend archetype emits `spec_quality`; it is ranked between `standard`
|
|
42
|
+
# and `deep` so a pack-only profile that uses it sorts sensibly during selection.
|
|
43
|
+
# Review depth ranking lives in contracts so plan selection and proof checks stay aligned.
|
|
44
|
+
HUNK_ORDER = {"off": 0, "observe": 1, "enforce": 2}
|
|
45
|
+
RISK_ORDER = {"low": 0, "medium": 1, "high": 2}
|
|
46
|
+
|
|
47
|
+
# Task types whose candidate files are edits to existing code and must pre-exist.
|
|
48
|
+
FILE_BACKED_TASK_TYPES = ("bugfix", "refactor", "docs")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DraftPlanError(ValueError):
|
|
52
|
+
"""A fail-closed compile diagnostic carrying a machine-readable ``code``."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str, code: str = "draft_plan_error") -> None:
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.code = code
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _required_cap_ids(profile_like: Dict[str, Any]) -> set:
|
|
60
|
+
return {
|
|
61
|
+
cap["id"]
|
|
62
|
+
for cap in profile_like.get("required_capabilities", [])
|
|
63
|
+
if cap.get("required")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _profile_requires_review_run(profile: Dict[str, Any]) -> bool:
|
|
68
|
+
"""Return True when the profile mandates a recorded review run at proof time.
|
|
69
|
+
|
|
70
|
+
Mirrors the proof-time condition exactly: either the profile sets
|
|
71
|
+
``proof_policy.require_review_run`` or its ``review_depth`` carries a
|
|
72
|
+
requires-run obligation in :data:`REVIEW_DEPTH_POLICY` (``spec_quality`` /
|
|
73
|
+
``deep``). Keeping this in lockstep with the policy means a deep profile that
|
|
74
|
+
leaves the explicit flag False still gets its review-state path scoped in.
|
|
75
|
+
"""
|
|
76
|
+
if profile["proof_policy"].get("require_review_run"):
|
|
77
|
+
return True
|
|
78
|
+
_, requires_run = REVIEW_DEPTH_POLICY.get(profile["review_depth"], ("warn", False))
|
|
79
|
+
return requires_run
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lower_bound_from_recommendation(report: Dict[str, Any]) -> Dict[str, Any]:
|
|
83
|
+
"""Extract the minimum-safety *posture* from a ``recommend`` report.
|
|
84
|
+
|
|
85
|
+
The floor is the recommended archetype's intrinsic posture, NOT the report's
|
|
86
|
+
``workflow_contract_candidate`` (which also folds in the brief's ad-hoc
|
|
87
|
+
``validation_needs``). Those needs are operator gate requests added to the
|
|
88
|
+
plan at hydration; they must not force every pack profile to pre-declare them.
|
|
89
|
+
"""
|
|
90
|
+
archetype = ARCHETYPES_BY_ID[report["recommended"]["profile"]]
|
|
91
|
+
return {
|
|
92
|
+
"required_gates": set(archetype.required_gates),
|
|
93
|
+
"required_capabilities": {
|
|
94
|
+
cap["id"] for cap in archetype.required_capabilities if cap.get("required")
|
|
95
|
+
},
|
|
96
|
+
"review_depth": archetype.review_depth,
|
|
97
|
+
"hunk_attribution": archetype.hunk_attribution,
|
|
98
|
+
"require_review_run": archetype.require_review_run,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def profile_satisfies(
|
|
103
|
+
profile: Dict[str, Any], manifest: Dict[str, Any], lower_bound: Dict[str, Any]
|
|
104
|
+
) -> bool:
|
|
105
|
+
"""Return True when a pack profile meets or exceeds the lower-bound posture."""
|
|
106
|
+
gates = set(profile["validation_policy"]["required_gates"])
|
|
107
|
+
if not lower_bound["required_gates"] <= gates:
|
|
108
|
+
return False
|
|
109
|
+
if not lower_bound["required_capabilities"] <= _required_cap_ids(profile):
|
|
110
|
+
return False
|
|
111
|
+
if REVIEW_DEPTH_ORDER[profile["review_depth"]] < REVIEW_DEPTH_ORDER[lower_bound["review_depth"]]:
|
|
112
|
+
return False
|
|
113
|
+
if (
|
|
114
|
+
HUNK_ORDER[profile["proof_policy"]["hunk_attribution"]]
|
|
115
|
+
< HUNK_ORDER[lower_bound["hunk_attribution"]]
|
|
116
|
+
):
|
|
117
|
+
return False
|
|
118
|
+
if lower_bound["require_review_run"] and not _profile_requires_review_run(profile):
|
|
119
|
+
return False
|
|
120
|
+
# The profile's plan template must actually carry the required gates, else the
|
|
121
|
+
# compiled plan could not run them.
|
|
122
|
+
template = manifest.get("plan_templates", {}).get(profile["plan_template"], {})
|
|
123
|
+
if not lower_bound["required_gates"] <= set(template.get("validation_gates", [])):
|
|
124
|
+
return False
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def profile_strictness(profile: Dict[str, Any]) -> Tuple[int, int, int, int, int]:
|
|
129
|
+
"""A lexicographic strictness key; lower means less strict."""
|
|
130
|
+
return (
|
|
131
|
+
REVIEW_DEPTH_ORDER[profile["review_depth"]],
|
|
132
|
+
HUNK_ORDER[profile["proof_policy"]["hunk_attribution"]],
|
|
133
|
+
int(profile["proof_policy"]["require_review_run"]),
|
|
134
|
+
len(profile["validation_policy"]["required_gates"]),
|
|
135
|
+
len(_required_cap_ids(profile)),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def select_profile(
|
|
140
|
+
manifest: Dict[str, Any],
|
|
141
|
+
lower_bound: Dict[str, Any],
|
|
142
|
+
*,
|
|
143
|
+
profile_id: Optional[str] = None,
|
|
144
|
+
reason: Optional[str] = None,
|
|
145
|
+
) -> Tuple[Dict[str, Any], str]:
|
|
146
|
+
"""Select the pack profile that becomes the plan's final posture.
|
|
147
|
+
|
|
148
|
+
With ``profile_id`` the operator chooses explicitly; a profile weaker than the
|
|
149
|
+
recommendation is refused unless a ``reason`` is supplied. Without it, the
|
|
150
|
+
least-strict profile that satisfies the lower bound wins; zero matches or a tie
|
|
151
|
+
at the minimum fails closed.
|
|
152
|
+
"""
|
|
153
|
+
if profile_id is not None:
|
|
154
|
+
try:
|
|
155
|
+
profile = packs.find_profile(manifest, profile_id)
|
|
156
|
+
except packs.PackError as exc:
|
|
157
|
+
raise DraftPlanError(str(exc), code="unknown_profile") from exc
|
|
158
|
+
if profile_satisfies(profile, manifest, lower_bound):
|
|
159
|
+
return profile, "operator_selected"
|
|
160
|
+
if not (reason and reason.strip()):
|
|
161
|
+
raise DraftPlanError(
|
|
162
|
+
f"profile {profile_id!r} is weaker than the recommended posture; "
|
|
163
|
+
"pass --reason to override",
|
|
164
|
+
code="profile_weaker_than_recommended",
|
|
165
|
+
)
|
|
166
|
+
return profile, "operator_override"
|
|
167
|
+
|
|
168
|
+
candidates = [
|
|
169
|
+
p for p in manifest["profiles"] if profile_satisfies(p, manifest, lower_bound)
|
|
170
|
+
]
|
|
171
|
+
if not candidates:
|
|
172
|
+
available = ", ".join(p["id"] for p in manifest["profiles"])
|
|
173
|
+
raise DraftPlanError(
|
|
174
|
+
"no workflow pack profile satisfies the recommended posture "
|
|
175
|
+
f"(available: {available})",
|
|
176
|
+
code="no_satisfying_profile",
|
|
177
|
+
)
|
|
178
|
+
best_key = min(profile_strictness(p) for p in candidates)
|
|
179
|
+
minimal = [p for p in candidates if profile_strictness(p) == best_key]
|
|
180
|
+
if len(minimal) > 1:
|
|
181
|
+
tied = ", ".join(p["id"] for p in minimal)
|
|
182
|
+
raise DraftPlanError(
|
|
183
|
+
f"multiple pack profiles are equally least-strict ({tied}); "
|
|
184
|
+
"pass --profile to choose one",
|
|
185
|
+
code="ambiguous_profile",
|
|
186
|
+
)
|
|
187
|
+
return minimal[0], SELECTION_MODE
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_broad(report: Dict[str, Any]) -> bool:
|
|
191
|
+
"""True when the recommendation reflects broad / cross-cutting work."""
|
|
192
|
+
profile = report["recommended"]["profile"]
|
|
193
|
+
broad_signal = "broad_signal=true" in report["signals"]
|
|
194
|
+
return profile == "large-feature" or (profile == "high-risk" and broad_signal)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _max_risk(*levels: str) -> str:
|
|
198
|
+
return max(levels, key=lambda level: RISK_ORDER.get(level, 0))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _missing_candidate_files(brief: Dict[str, Any], root: Path) -> list:
|
|
202
|
+
"""Return candidate files that must pre-exist but do not (else an empty list).
|
|
203
|
+
|
|
204
|
+
Only file-backed task types (edits to existing code) require pre-existence;
|
|
205
|
+
greenfield task types name files they are about to create.
|
|
206
|
+
"""
|
|
207
|
+
if brief["task_type"] not in FILE_BACKED_TASK_TYPES:
|
|
208
|
+
return []
|
|
209
|
+
return [f for f in (brief.get("candidate_files") or []) if not (root / f).exists()]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _candidate_escapes_root(root: Path, relative: str) -> bool:
|
|
213
|
+
try:
|
|
214
|
+
resolved_root = root.resolve(strict=False)
|
|
215
|
+
(resolved_root / relative).resolve(strict=False).relative_to(resolved_root)
|
|
216
|
+
return False
|
|
217
|
+
except (OSError, RuntimeError, ValueError):
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _check_adequacy(report: Dict[str, Any], brief: Dict[str, Any], template: Dict[str, Any]) -> None:
|
|
222
|
+
n_steps = len([s for s in template.get("steps", []) if isinstance(s, dict)])
|
|
223
|
+
if n_steps >= 2:
|
|
224
|
+
return
|
|
225
|
+
files = brief.get("candidate_files") or []
|
|
226
|
+
if brief.get("declared_size") == "xl" or len(files) >= LARGE_FILE_MIN:
|
|
227
|
+
raise DraftPlanError(
|
|
228
|
+
"brief is too large for a single-step template; provide a multi-step "
|
|
229
|
+
"bounded template or split the brief",
|
|
230
|
+
code="task_too_large",
|
|
231
|
+
)
|
|
232
|
+
if is_broad(report):
|
|
233
|
+
raise DraftPlanError(
|
|
234
|
+
"recommended posture is broad but the selected template has fewer than "
|
|
235
|
+
"two steps; decompose the brief or choose a multi-step template",
|
|
236
|
+
code="decomposition_required",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _hydrate(
|
|
241
|
+
plan: Dict[str, Any],
|
|
242
|
+
brief: Dict[str, Any],
|
|
243
|
+
objective: str,
|
|
244
|
+
profile: Dict[str, Any],
|
|
245
|
+
manifest: Dict[str, Any],
|
|
246
|
+
report: Dict[str, Any],
|
|
247
|
+
selection_mode: str,
|
|
248
|
+
) -> Dict[str, Any]:
|
|
249
|
+
plan = deepcopy(plan)
|
|
250
|
+
plan["objective"] = objective.strip()
|
|
251
|
+
needs = brief.get("validation_needs") or []
|
|
252
|
+
plan["validation_gates"] = sorted(
|
|
253
|
+
set(plan.get("validation_gates", []))
|
|
254
|
+
| set(profile["validation_policy"]["required_gates"])
|
|
255
|
+
| set(needs)
|
|
256
|
+
)
|
|
257
|
+
candidate_files = brief.get("candidate_files") or []
|
|
258
|
+
# Preserve order, dedupe, and guarantee .agent/ is writable for the loop.
|
|
259
|
+
# When the profile requires a review run (#90), scope the review-state path
|
|
260
|
+
# too so recorded review artifacts are not flagged as out-of-scope drift.
|
|
261
|
+
extra_scope = [".agent/"]
|
|
262
|
+
if _profile_requires_review_run(profile):
|
|
263
|
+
extra_scope.append(REVIEW_STATE_PATH)
|
|
264
|
+
plan["allowed_files"] = list(
|
|
265
|
+
dict.fromkeys([*plan.get("allowed_files", []), *candidate_files, *extra_scope])
|
|
266
|
+
)
|
|
267
|
+
plan["risk_level"] = _max_risk(plan.get("risk_level", "low"), brief["declared_risk"])
|
|
268
|
+
# Pointer/provenance only; the authoritative policy lives in the linked
|
|
269
|
+
# workflow contract, not duplicated into the plan body.
|
|
270
|
+
plan[WORKFLOW_BLOCK_KEY] = {
|
|
271
|
+
"contract_path": WORKFLOW_CONTRACT_PATH,
|
|
272
|
+
"workflow_pack": manifest["id"],
|
|
273
|
+
"workflow_profile": profile["id"],
|
|
274
|
+
"recommended_profile": report["recommended"]["profile"],
|
|
275
|
+
"selection_mode": selection_mode,
|
|
276
|
+
}
|
|
277
|
+
plan["locked"] = False
|
|
278
|
+
plan["locked_at"] = None
|
|
279
|
+
return plan
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def selection_reason(
|
|
283
|
+
report: Dict[str, Any],
|
|
284
|
+
profile: Dict[str, Any],
|
|
285
|
+
selection_mode: str,
|
|
286
|
+
reason: Optional[str],
|
|
287
|
+
) -> str:
|
|
288
|
+
"""Human-readable rationale recorded in the linked workflow contract."""
|
|
289
|
+
recommended = report["recommended"]["profile"]
|
|
290
|
+
if selection_mode == "operator_override":
|
|
291
|
+
return (
|
|
292
|
+
f"Operator override to profile {profile['id']} (weaker than recommended "
|
|
293
|
+
f"{recommended}). {reason}"
|
|
294
|
+
)
|
|
295
|
+
if selection_mode == "operator_selected":
|
|
296
|
+
return (
|
|
297
|
+
f"Operator selected profile {profile['id']} (satisfies recommended {recommended})."
|
|
298
|
+
)
|
|
299
|
+
return (
|
|
300
|
+
f"Selected profile {profile['id']} as the least-strict pack profile satisfying "
|
|
301
|
+
f"recommended {recommended}."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def compile_draft_plan(
|
|
306
|
+
brief: Dict[str, Any],
|
|
307
|
+
manifest: Dict[str, Any],
|
|
308
|
+
*,
|
|
309
|
+
objective: str,
|
|
310
|
+
root: Path,
|
|
311
|
+
profile_id: Optional[str] = None,
|
|
312
|
+
reason: Optional[str] = None,
|
|
313
|
+
allow_missing_candidates: bool = False,
|
|
314
|
+
) -> Dict[str, Any]:
|
|
315
|
+
"""Compile a brief + pack into an unlocked draft plan.
|
|
316
|
+
|
|
317
|
+
``manifest`` must be a ``load_pack``-validated pack manifest; the selection
|
|
318
|
+
and hydration logic trusts its schema (profiles, ``validation_policy``,
|
|
319
|
+
``proof_policy``, enum-constrained ``review_depth``/``hunk_attribution``).
|
|
320
|
+
Returns ``{"plan", "profile", "report", "selection_mode", "template_id",
|
|
321
|
+
"warnings"}``. Raises :class:`DraftPlanError` (with a machine-readable
|
|
322
|
+
``code``) on any fail-closed condition. The returned plan passes
|
|
323
|
+
``validate_plan`` and is unlocked.
|
|
324
|
+
|
|
325
|
+
``allow_missing_candidates`` is the #89 greenfield switch: a file-backed
|
|
326
|
+
brief whose ``candidate_files`` do not exist yet normally fails closed with
|
|
327
|
+
``candidate_file_missing``; with this flag the absence is downgraded to a
|
|
328
|
+
returned warning so a plan can be drafted for files about to be created.
|
|
329
|
+
Path-escape rejection (``candidate_file_unsafe``) is never downgraded.
|
|
330
|
+
"""
|
|
331
|
+
errors = validate_brief(brief)
|
|
332
|
+
if errors:
|
|
333
|
+
raise DraftPlanError("invalid brief: " + "; ".join(errors), code="invalid_brief")
|
|
334
|
+
|
|
335
|
+
# Candidate files are brief-controlled and flow into the existence probe and
|
|
336
|
+
# the draft's allowed_files; reject anything that could escape the repo root.
|
|
337
|
+
unsafe = [
|
|
338
|
+
f
|
|
339
|
+
for f in (brief.get("candidate_files") or [])
|
|
340
|
+
if not _safe_relative_path(f) or _candidate_escapes_root(root, f)
|
|
341
|
+
]
|
|
342
|
+
if unsafe:
|
|
343
|
+
raise DraftPlanError(
|
|
344
|
+
"candidate files must be contained relative paths: " + ", ".join(unsafe),
|
|
345
|
+
code="candidate_file_unsafe",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not (objective and objective.strip()):
|
|
349
|
+
raise DraftPlanError(
|
|
350
|
+
"an objective is required to compile a plan", code="brief_too_vague"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
report = recommend(brief)
|
|
354
|
+
lower_bound = lower_bound_from_recommendation(report)
|
|
355
|
+
|
|
356
|
+
if is_broad(report) and not (brief.get("candidate_files") or []):
|
|
357
|
+
raise DraftPlanError(
|
|
358
|
+
"recommended posture is broad but the brief lists no candidate_files to "
|
|
359
|
+
"bound the change",
|
|
360
|
+
code="brief_too_vague",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
warnings: list = []
|
|
364
|
+
missing = _missing_candidate_files(brief, root)
|
|
365
|
+
if missing:
|
|
366
|
+
message = "candidate files do not exist under the repo root: " + ", ".join(missing)
|
|
367
|
+
if allow_missing_candidates:
|
|
368
|
+
warnings.append({"code": "candidate_file_missing", "message": message})
|
|
369
|
+
else:
|
|
370
|
+
raise DraftPlanError(message, code="candidate_file_missing")
|
|
371
|
+
|
|
372
|
+
profile, selection_mode = select_profile(
|
|
373
|
+
manifest, lower_bound, profile_id=profile_id, reason=reason
|
|
374
|
+
)
|
|
375
|
+
template_id = profile["plan_template"]
|
|
376
|
+
if template_id not in manifest.get("plan_templates", {}):
|
|
377
|
+
raise DraftPlanError(
|
|
378
|
+
f"selected profile references unknown plan template: {template_id}",
|
|
379
|
+
code="workflow_inputs_unavailable",
|
|
380
|
+
)
|
|
381
|
+
template = manifest["plan_templates"][template_id]
|
|
382
|
+
|
|
383
|
+
_check_adequacy(report, brief, template)
|
|
384
|
+
|
|
385
|
+
plan = packs.template_to_plan(manifest, template_id)
|
|
386
|
+
plan = _hydrate(plan, brief, objective, profile, manifest, report, selection_mode)
|
|
387
|
+
|
|
388
|
+
plan_errors = validate_plan(plan)
|
|
389
|
+
if plan_errors:
|
|
390
|
+
raise DraftPlanError(
|
|
391
|
+
"compiled plan failed validation: " + "; ".join(plan_errors),
|
|
392
|
+
code="plan_invalid",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
"plan": plan,
|
|
397
|
+
"profile": profile,
|
|
398
|
+
"report": report,
|
|
399
|
+
"selection_mode": selection_mode,
|
|
400
|
+
"template_id": template_id,
|
|
401
|
+
"warnings": warnings,
|
|
402
|
+
}
|
agentflow/events.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Read-only event-stream projection over Agentflow's .agent/ ledgers.
|
|
2
|
+
|
|
3
|
+
The five append-only ledgers remain authoritative. This module derives a single
|
|
4
|
+
chronological view and never writes to disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import date, datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List
|
|
12
|
+
|
|
13
|
+
from .artifacts import read_jsonl
|
|
14
|
+
from .contracts import ARTIFACT_PATHS
|
|
15
|
+
|
|
16
|
+
# Ledger projection order. Rank only breaks ties at identical timestamps; it
|
|
17
|
+
# never reorders events that already differ in time.
|
|
18
|
+
_LEDGER_ORDER = (
|
|
19
|
+
"step-runs",
|
|
20
|
+
"command-receipts",
|
|
21
|
+
"file-receipts",
|
|
22
|
+
"verification-runs",
|
|
23
|
+
"review-runs",
|
|
24
|
+
)
|
|
25
|
+
_LEDGER_RANK = {name: index for index, name in enumerate(_LEDGER_ORDER)}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _collect(record: Dict[str, Any], keys: tuple) -> Dict[str, Any]:
|
|
29
|
+
return {key: record[key] for key in keys if record.get(key) is not None}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _step_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
33
|
+
kind = record.get("event")
|
|
34
|
+
return {
|
|
35
|
+
"type": f"step.{kind}" if kind else "step.unknown",
|
|
36
|
+
"timestamp": record.get("recorded_at", ""),
|
|
37
|
+
"step_id": record.get("step_id"),
|
|
38
|
+
"attempt_id": record.get("attempt_id"),
|
|
39
|
+
"source": {"ledger": "step-runs", "record_id": None, "index": index},
|
|
40
|
+
"data": _collect(
|
|
41
|
+
record,
|
|
42
|
+
(
|
|
43
|
+
"agent_id",
|
|
44
|
+
"lease_expires_at",
|
|
45
|
+
"reason",
|
|
46
|
+
"reason_code",
|
|
47
|
+
"amends_attempt",
|
|
48
|
+
"amends_completed_at",
|
|
49
|
+
"findings",
|
|
50
|
+
"finding_refs",
|
|
51
|
+
"abandoned_by",
|
|
52
|
+
"superseded_by",
|
|
53
|
+
"failed_by",
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _command_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
60
|
+
risk = record.get("risk") or {}
|
|
61
|
+
data = _collect(
|
|
62
|
+
record,
|
|
63
|
+
(
|
|
64
|
+
"command",
|
|
65
|
+
"exit_code",
|
|
66
|
+
"gate",
|
|
67
|
+
"cwd",
|
|
68
|
+
"provenance",
|
|
69
|
+
"decision",
|
|
70
|
+
"finished_at",
|
|
71
|
+
"timed_out",
|
|
72
|
+
"timeout_seconds",
|
|
73
|
+
"stdout_sha256",
|
|
74
|
+
"stderr_sha256",
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
data["risk_level"] = risk.get("level")
|
|
78
|
+
data["finding_count"] = len(risk.get("findings", []))
|
|
79
|
+
return {
|
|
80
|
+
"type": "command.recorded",
|
|
81
|
+
"timestamp": record.get("started_at", ""),
|
|
82
|
+
"step_id": record.get("step_id"),
|
|
83
|
+
"attempt_id": record.get("attempt_id"),
|
|
84
|
+
"source": {"ledger": "command-receipts", "record_id": record.get("id"), "index": index},
|
|
85
|
+
"data": data,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _file_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"type": "file.changed",
|
|
92
|
+
"timestamp": record.get("recorded_at", ""),
|
|
93
|
+
"step_id": record.get("step_id"),
|
|
94
|
+
"attempt_id": record.get("attempt_id"),
|
|
95
|
+
"source": {"ledger": "file-receipts", "record_id": record.get("id"), "index": index},
|
|
96
|
+
"data": _collect(record, ("path", "previous_path", "change_kind", "after_sha256")),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _verification_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
101
|
+
data = _collect(record, ("scope", "status", "strict", "replay"))
|
|
102
|
+
findings = record.get("findings")
|
|
103
|
+
if isinstance(findings, list):
|
|
104
|
+
data["finding_count"] = len(findings)
|
|
105
|
+
return {
|
|
106
|
+
"type": "verification.run",
|
|
107
|
+
"timestamp": record.get("recorded_at", ""),
|
|
108
|
+
"step_id": record.get("step_id"),
|
|
109
|
+
"attempt_id": record.get("attempt_id"),
|
|
110
|
+
"source": {"ledger": "verification-runs", "record_id": record.get("id"), "index": index},
|
|
111
|
+
"data": data,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _review_event(record: Dict[str, Any], index: int) -> Dict[str, Any]:
|
|
116
|
+
data = _collect(record, ("review_run_id", "gate_status", "policy"))
|
|
117
|
+
data["active_blocking_count"] = len(record.get("active_blocking", []) or [])
|
|
118
|
+
return {
|
|
119
|
+
"type": "review.recorded",
|
|
120
|
+
"timestamp": record.get("recorded_at", ""),
|
|
121
|
+
"step_id": None,
|
|
122
|
+
"attempt_id": None,
|
|
123
|
+
"source": {
|
|
124
|
+
"ledger": "review-runs",
|
|
125
|
+
"record_id": record.get("review_run_id"),
|
|
126
|
+
"index": index,
|
|
127
|
+
},
|
|
128
|
+
"data": data,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_PROJECTORS: Dict[str, Callable[[Dict[str, Any], int], Dict[str, Any]]] = {
|
|
133
|
+
"step-runs": _step_event,
|
|
134
|
+
"command-receipts": _command_event,
|
|
135
|
+
"file-receipts": _file_event,
|
|
136
|
+
"verification-runs": _verification_event,
|
|
137
|
+
"review-runs": _review_event,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def project_events(root: Path) -> List[Dict[str, Any]]:
|
|
142
|
+
"""Project the five ledgers into one deterministically ordered event list."""
|
|
143
|
+
events: List[Dict[str, Any]] = []
|
|
144
|
+
for ledger in _LEDGER_ORDER:
|
|
145
|
+
path = root / ARTIFACT_PATHS[ledger]
|
|
146
|
+
projector = _PROJECTORS[ledger]
|
|
147
|
+
for index, record in enumerate(read_jsonl(path)):
|
|
148
|
+
if isinstance(record, dict):
|
|
149
|
+
events.append(projector(record, index))
|
|
150
|
+
events.sort(
|
|
151
|
+
key=lambda event: (
|
|
152
|
+
event["timestamp"],
|
|
153
|
+
_LEDGER_RANK[event["source"]["ledger"]],
|
|
154
|
+
event["source"]["index"],
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return events
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _normalize_utc_suffix(value: str) -> str:
|
|
161
|
+
if value.endswith("Z"):
|
|
162
|
+
return f"{value[:-1]}+00:00"
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_iso_timestamp(value: str) -> datetime:
|
|
167
|
+
normalized = _normalize_utc_suffix(value)
|
|
168
|
+
try:
|
|
169
|
+
parsed = datetime.fromisoformat(normalized)
|
|
170
|
+
except ValueError:
|
|
171
|
+
parsed_date = date.fromisoformat(normalized)
|
|
172
|
+
parsed = datetime.combine(parsed_date, datetime.min.time())
|
|
173
|
+
if parsed.tzinfo is None:
|
|
174
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
175
|
+
return parsed.astimezone(timezone.utc)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def filter_events_since(events: List[Dict[str, Any]], since: str) -> List[Dict[str, Any]]:
|
|
179
|
+
"""Return events whose timestamp is at or after ``since`` (inclusive)."""
|
|
180
|
+
threshold = _parse_iso_timestamp(since)
|
|
181
|
+
filtered = []
|
|
182
|
+
for event in events:
|
|
183
|
+
timestamp = event.get("timestamp")
|
|
184
|
+
if not isinstance(timestamp, str):
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
event_time = _parse_iso_timestamp(timestamp)
|
|
188
|
+
except ValueError:
|
|
189
|
+
continue
|
|
190
|
+
if event_time >= threshold:
|
|
191
|
+
filtered.append(event)
|
|
192
|
+
return filtered
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def valid_since(value: str) -> bool:
|
|
196
|
+
"""True when ``value`` parses as an ISO-8601 date or datetime."""
|
|
197
|
+
try:
|
|
198
|
+
_parse_iso_timestamp(value)
|
|
199
|
+
except ValueError:
|
|
200
|
+
return False
|
|
201
|
+
return True
|