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/validation.py
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"""Validation and drift-audit logic for Agentflow artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
from .artifacts import try_read_json, utc_now
|
|
12
|
+
from .contracts import (
|
|
13
|
+
AUTHORITIES,
|
|
14
|
+
DRIFT_REPORT_SCHEMA_VERSION,
|
|
15
|
+
EXECUTION_MODES,
|
|
16
|
+
PLAN_SCHEMA_VERSION,
|
|
17
|
+
RISK_LEVELS,
|
|
18
|
+
)
|
|
19
|
+
from .execution import (
|
|
20
|
+
attempt_is_expired,
|
|
21
|
+
lease_grace_seconds,
|
|
22
|
+
lease_policy,
|
|
23
|
+
read_step_state,
|
|
24
|
+
)
|
|
25
|
+
from .git import changed_file_records, changed_files, is_git_repo
|
|
26
|
+
from .hunks import effective_hunk_policy, unmapped_hunks
|
|
27
|
+
from .versioning import parse_schema_version, validate_schema_version
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
REQUIRED_PLAN_FIELDS = {
|
|
31
|
+
"schema_version": str,
|
|
32
|
+
"objective": str,
|
|
33
|
+
"scope": list,
|
|
34
|
+
"non_goals": list,
|
|
35
|
+
"invariants": list,
|
|
36
|
+
"allowed_files": list,
|
|
37
|
+
"blocked_files": list,
|
|
38
|
+
"validation_gates": list,
|
|
39
|
+
"rollback_plan": str,
|
|
40
|
+
"risk_level": str,
|
|
41
|
+
"drift_budget": dict,
|
|
42
|
+
"steps": list,
|
|
43
|
+
"evidence_ids": list,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_TRACE_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,127}$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_non_empty_string_list(value: Any) -> bool:
|
|
50
|
+
return isinstance(value, list) and all(isinstance(item, str) and item for item in value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_required_non_empty_string_list(value: Any) -> bool:
|
|
54
|
+
return bool(value) and _is_non_empty_string_list(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_string_list(value: Any) -> bool:
|
|
58
|
+
return isinstance(value, list) and all(isinstance(item, str) for item in value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_non_negative_int(value: Any) -> bool:
|
|
62
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_trace_refs(
|
|
66
|
+
prefix: str,
|
|
67
|
+
value: Any,
|
|
68
|
+
known_ids: set[str],
|
|
69
|
+
item_name: str,
|
|
70
|
+
target_name: str,
|
|
71
|
+
errors: List[str],
|
|
72
|
+
non_blank: bool = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
if not isinstance(value, list):
|
|
75
|
+
errors.append(f"{prefix} must be a list")
|
|
76
|
+
return
|
|
77
|
+
if not value:
|
|
78
|
+
errors.append(f"{prefix} must contain at least one {item_name} id")
|
|
79
|
+
return
|
|
80
|
+
if not _is_non_empty_string_list(value) or (
|
|
81
|
+
non_blank and any(not item.strip() for item in value)
|
|
82
|
+
):
|
|
83
|
+
errors.append(f"{prefix} must contain only non-empty strings")
|
|
84
|
+
return
|
|
85
|
+
seen = set()
|
|
86
|
+
for item_id in value:
|
|
87
|
+
if item_id in seen:
|
|
88
|
+
errors.append(f"{prefix} contains duplicate id: {item_id}")
|
|
89
|
+
seen.add(item_id)
|
|
90
|
+
if item_id not in known_ids:
|
|
91
|
+
errors.append(f"{prefix} references unknown {target_name} id: {item_id}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_criterion_refs(
|
|
95
|
+
prefix: str,
|
|
96
|
+
value: Any,
|
|
97
|
+
criterion_ids: set[str],
|
|
98
|
+
errors: List[str],
|
|
99
|
+
) -> None:
|
|
100
|
+
_validate_trace_refs(
|
|
101
|
+
prefix,
|
|
102
|
+
value,
|
|
103
|
+
criterion_ids,
|
|
104
|
+
"criterion",
|
|
105
|
+
"acceptance criterion",
|
|
106
|
+
errors,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_requirements(plan: Dict[str, Any], errors: List[str]) -> set[str]:
|
|
111
|
+
if "requirements" not in plan:
|
|
112
|
+
return set()
|
|
113
|
+
requirements = plan["requirements"]
|
|
114
|
+
if not isinstance(requirements, list) or not requirements:
|
|
115
|
+
errors.append("requirements must contain at least one requirement")
|
|
116
|
+
return set()
|
|
117
|
+
|
|
118
|
+
requirement_ids: set[str] = set()
|
|
119
|
+
criterion_ids: set[str] = set()
|
|
120
|
+
for requirement_index, requirement in enumerate(requirements, start=1):
|
|
121
|
+
prefix = f"requirements[{requirement_index}]"
|
|
122
|
+
if not isinstance(requirement, dict):
|
|
123
|
+
errors.append(f"{prefix} must be an object")
|
|
124
|
+
continue
|
|
125
|
+
requirement_id = requirement.get("id")
|
|
126
|
+
if not isinstance(requirement_id, str) or not _TRACE_ID_RE.fullmatch(requirement_id):
|
|
127
|
+
errors.append(f"{prefix}.id has invalid stable id: {requirement_id}")
|
|
128
|
+
elif requirement_id in requirement_ids:
|
|
129
|
+
errors.append(f"duplicate requirement id: {requirement_id}")
|
|
130
|
+
else:
|
|
131
|
+
requirement_ids.add(requirement_id)
|
|
132
|
+
if not isinstance(requirement.get("text"), str) or not requirement["text"].strip():
|
|
133
|
+
errors.append(f"{prefix}.text must be a non-empty string")
|
|
134
|
+
|
|
135
|
+
criteria = requirement.get("acceptance_criteria")
|
|
136
|
+
if not isinstance(criteria, list) or not criteria:
|
|
137
|
+
errors.append(f"{prefix}.acceptance_criteria must contain at least one criterion")
|
|
138
|
+
continue
|
|
139
|
+
for criterion_index, criterion in enumerate(criteria, start=1):
|
|
140
|
+
criterion_prefix = f"{prefix}.acceptance_criteria[{criterion_index}]"
|
|
141
|
+
if not isinstance(criterion, dict):
|
|
142
|
+
errors.append(f"{criterion_prefix} must be an object")
|
|
143
|
+
continue
|
|
144
|
+
criterion_id = criterion.get("id")
|
|
145
|
+
if not isinstance(criterion_id, str) or not _TRACE_ID_RE.fullmatch(criterion_id):
|
|
146
|
+
errors.append(f"{criterion_prefix}.id has invalid stable id: {criterion_id}")
|
|
147
|
+
elif criterion_id in criterion_ids:
|
|
148
|
+
errors.append(f"duplicate acceptance criterion id: {criterion_id}")
|
|
149
|
+
else:
|
|
150
|
+
criterion_ids.add(criterion_id)
|
|
151
|
+
if not isinstance(criterion.get("text"), str) or not criterion["text"].strip():
|
|
152
|
+
errors.append(f"{criterion_prefix}.text must be a non-empty string")
|
|
153
|
+
if "review" in criterion:
|
|
154
|
+
review = criterion["review"]
|
|
155
|
+
if not isinstance(review, dict):
|
|
156
|
+
errors.append(f"{criterion_prefix}.review must be an object")
|
|
157
|
+
elif review.get("minimum_depth") not in ("spec_quality", "deep"):
|
|
158
|
+
errors.append(
|
|
159
|
+
f"{criterion_prefix}.review.minimum_depth must be one of: "
|
|
160
|
+
"spec_quality, deep"
|
|
161
|
+
)
|
|
162
|
+
return criterion_ids
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_requirement_traceability(plan: Dict[str, Any]) -> List[str]:
|
|
166
|
+
"""Validate the optional requirement/criterion extension in isolation."""
|
|
167
|
+
errors: List[str] = []
|
|
168
|
+
criterion_ids = _validate_requirements(plan, errors)
|
|
169
|
+
steps = plan.get("steps", [])
|
|
170
|
+
if not isinstance(steps, list):
|
|
171
|
+
if "requirements" in plan:
|
|
172
|
+
errors.append("steps must be a list for requirement traceability")
|
|
173
|
+
return errors
|
|
174
|
+
|
|
175
|
+
mapped_criterion_ids: set[str] = set()
|
|
176
|
+
for step_index, step in enumerate(steps, start=1):
|
|
177
|
+
if not isinstance(step, dict):
|
|
178
|
+
continue
|
|
179
|
+
prefix = f"steps[{step_index}]"
|
|
180
|
+
step_criterion_ids: set[str] = set()
|
|
181
|
+
if "criterion_ids" in step:
|
|
182
|
+
refs = step["criterion_ids"]
|
|
183
|
+
_validate_criterion_refs(
|
|
184
|
+
f"{prefix}.criterion_ids", refs, criterion_ids, errors
|
|
185
|
+
)
|
|
186
|
+
if isinstance(refs, list):
|
|
187
|
+
step_criterion_ids = {
|
|
188
|
+
item for item in refs if isinstance(item, str)
|
|
189
|
+
}
|
|
190
|
+
mapped_criterion_ids.update(step_criterion_ids)
|
|
191
|
+
gates = step.get("gates", [])
|
|
192
|
+
if not isinstance(gates, list):
|
|
193
|
+
continue
|
|
194
|
+
for gate_index, gate in enumerate(gates, start=1):
|
|
195
|
+
if isinstance(gate, dict) and "criterion_ids" in gate:
|
|
196
|
+
gate_prefix = f"{prefix}.gates[{gate_index}].criterion_ids"
|
|
197
|
+
gate_refs = gate["criterion_ids"]
|
|
198
|
+
_validate_criterion_refs(
|
|
199
|
+
gate_prefix,
|
|
200
|
+
gate_refs,
|
|
201
|
+
criterion_ids,
|
|
202
|
+
errors,
|
|
203
|
+
)
|
|
204
|
+
for criterion_id in gate_refs if isinstance(gate_refs, list) else []:
|
|
205
|
+
if (
|
|
206
|
+
isinstance(criterion_id, str)
|
|
207
|
+
and criterion_id in criterion_ids
|
|
208
|
+
and criterion_id not in step_criterion_ids
|
|
209
|
+
):
|
|
210
|
+
errors.append(
|
|
211
|
+
f"{gate_prefix} must be a subset of "
|
|
212
|
+
f"{prefix}.criterion_ids; invalid id: {criterion_id}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
for criterion_id in sorted(criterion_ids):
|
|
216
|
+
if criterion_id not in mapped_criterion_ids:
|
|
217
|
+
errors.append(
|
|
218
|
+
f"acceptance criterion {criterion_id} is not mapped to any step"
|
|
219
|
+
)
|
|
220
|
+
return errors
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _uses_design_decision_fields(plan: Dict[str, Any]) -> bool:
|
|
224
|
+
if "design_decisions" in plan:
|
|
225
|
+
return True
|
|
226
|
+
steps = plan.get("steps")
|
|
227
|
+
return isinstance(steps, list) and any(
|
|
228
|
+
isinstance(step, dict) and "design_decision_ids" in step for step in steps
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
DESIGN_DECISION_MINIMUM_VERSION = (0, 4)
|
|
233
|
+
|
|
234
|
+
# The same boundary expressed for the published plan schema, whose declarative
|
|
235
|
+
# conditional cannot compare version tuples. Intersected with the schema's own
|
|
236
|
+
# `^0\.[0-4]\.[0-9]+$` version pattern this is exactly the pre-0.4 range.
|
|
237
|
+
# tests/test_schema_contracts.py pins the two together; #29 closed the drift
|
|
238
|
+
# that appeared when PR #26 added the fields to the schema without a gate.
|
|
239
|
+
LEGACY_DESIGN_DECISION_VERSION_PATTERN = r"^0\.[0-3]\.[0-9]+$"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _design_decision_schema_is_legacy(plan: Dict[str, Any]) -> bool:
|
|
243
|
+
recorded = plan.get("schema_version")
|
|
244
|
+
if not isinstance(recorded, str):
|
|
245
|
+
return False
|
|
246
|
+
try:
|
|
247
|
+
version = parse_schema_version(recorded)
|
|
248
|
+
except ValueError:
|
|
249
|
+
return False
|
|
250
|
+
return (version.major, version.minor) < DESIGN_DECISION_MINIMUM_VERSION
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def validate_design_decision_traceability(plan: Dict[str, Any]) -> List[str]:
|
|
254
|
+
"""Validate optional locked-plan design decisions and step references."""
|
|
255
|
+
if not _uses_design_decision_fields(plan):
|
|
256
|
+
return []
|
|
257
|
+
if _design_decision_schema_is_legacy(plan):
|
|
258
|
+
return [
|
|
259
|
+
"design decision fields require plan-lock schema_version "
|
|
260
|
+
"0.4.0 or newer"
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
errors: List[str] = []
|
|
264
|
+
decision_ids: set[str] = set()
|
|
265
|
+
if "design_decisions" in plan:
|
|
266
|
+
decisions = plan["design_decisions"]
|
|
267
|
+
if not isinstance(decisions, list) or not decisions:
|
|
268
|
+
errors.append("design_decisions must contain at least one design decision")
|
|
269
|
+
else:
|
|
270
|
+
for index, decision in enumerate(decisions, start=1):
|
|
271
|
+
prefix = f"design_decisions[{index}]"
|
|
272
|
+
if not isinstance(decision, dict):
|
|
273
|
+
errors.append(f"{prefix} must be an object")
|
|
274
|
+
continue
|
|
275
|
+
decision_id = decision.get("id")
|
|
276
|
+
if (
|
|
277
|
+
not isinstance(decision_id, str)
|
|
278
|
+
or not _TRACE_ID_RE.fullmatch(decision_id)
|
|
279
|
+
):
|
|
280
|
+
errors.append(f"{prefix}.id has invalid stable id: {decision_id}")
|
|
281
|
+
elif decision_id in decision_ids:
|
|
282
|
+
errors.append(f"duplicate design decision id: {decision_id}")
|
|
283
|
+
else:
|
|
284
|
+
decision_ids.add(decision_id)
|
|
285
|
+
text = decision.get("text")
|
|
286
|
+
if not isinstance(text, str) or not text.strip():
|
|
287
|
+
errors.append(f"{prefix}.text must be a non-empty string")
|
|
288
|
+
if "references" in decision:
|
|
289
|
+
references = decision["references"]
|
|
290
|
+
if not isinstance(references, list):
|
|
291
|
+
errors.append(f"{prefix}.references must be a list")
|
|
292
|
+
elif any(
|
|
293
|
+
not isinstance(reference, str) or not reference.strip()
|
|
294
|
+
for reference in references
|
|
295
|
+
):
|
|
296
|
+
errors.append(
|
|
297
|
+
f"{prefix}.references must contain only non-empty strings"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
steps = plan.get("steps", [])
|
|
301
|
+
if not isinstance(steps, list):
|
|
302
|
+
errors.append("steps must be a list for design decision traceability")
|
|
303
|
+
return errors
|
|
304
|
+
for index, step in enumerate(steps, start=1):
|
|
305
|
+
if isinstance(step, dict) and "design_decision_ids" in step:
|
|
306
|
+
_validate_trace_refs(
|
|
307
|
+
f"steps[{index}].design_decision_ids",
|
|
308
|
+
step["design_decision_ids"],
|
|
309
|
+
decision_ids,
|
|
310
|
+
"design decision",
|
|
311
|
+
"design decision",
|
|
312
|
+
errors,
|
|
313
|
+
non_blank=True,
|
|
314
|
+
)
|
|
315
|
+
return errors
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _validate_gate(
|
|
319
|
+
prefix: str,
|
|
320
|
+
gate: Any,
|
|
321
|
+
evidence_ids: set[str],
|
|
322
|
+
errors: List[str],
|
|
323
|
+
) -> None:
|
|
324
|
+
if not isinstance(gate, dict):
|
|
325
|
+
errors.append(f"{prefix} must be an object")
|
|
326
|
+
return
|
|
327
|
+
kind = gate.get("kind")
|
|
328
|
+
if kind not in ("command", "inspection"):
|
|
329
|
+
errors.append(f"{prefix}.kind must be one of: command, inspection")
|
|
330
|
+
return
|
|
331
|
+
if kind == "command":
|
|
332
|
+
run = gate.get("run")
|
|
333
|
+
if not _is_required_non_empty_string_list(run):
|
|
334
|
+
errors.append(f"{prefix}.run must contain at least one command argument")
|
|
335
|
+
if "timeout_seconds" in gate:
|
|
336
|
+
timeout = gate["timeout_seconds"]
|
|
337
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 1:
|
|
338
|
+
errors.append(f"{prefix}.timeout_seconds must be a positive integer")
|
|
339
|
+
if kind == "inspection":
|
|
340
|
+
evidence_id = gate.get("evidence_id")
|
|
341
|
+
describe = gate.get("describe")
|
|
342
|
+
if not isinstance(evidence_id, str) or not evidence_id:
|
|
343
|
+
errors.append(f"{prefix}.evidence_id must be a non-empty string")
|
|
344
|
+
elif evidence_id not in evidence_ids:
|
|
345
|
+
errors.append(f"{prefix}.evidence_id references unknown evidence id: {evidence_id}")
|
|
346
|
+
if not isinstance(describe, str) or not describe.strip():
|
|
347
|
+
errors.append(f"{prefix}.describe must be a non-empty string")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _detect_depends_on_errors(steps: List[Dict[str, Any]]) -> List[str]:
|
|
351
|
+
errors: List[str] = []
|
|
352
|
+
ids = [step.get("id") for step in steps if isinstance(step, dict)]
|
|
353
|
+
known_ids = {step_id for step_id in ids if isinstance(step_id, str)}
|
|
354
|
+
graph: Dict[str, List[str]] = {}
|
|
355
|
+
for index, step in enumerate(steps, start=1):
|
|
356
|
+
if not isinstance(step, dict):
|
|
357
|
+
continue
|
|
358
|
+
step_id = step.get("id")
|
|
359
|
+
if not isinstance(step_id, str):
|
|
360
|
+
continue
|
|
361
|
+
depends_on = step.get("depends_on", [])
|
|
362
|
+
if not isinstance(depends_on, list):
|
|
363
|
+
errors.append(f"steps[{index}].depends_on must be a list")
|
|
364
|
+
continue
|
|
365
|
+
graph[step_id] = []
|
|
366
|
+
for dependency in depends_on:
|
|
367
|
+
if not isinstance(dependency, str) or not dependency:
|
|
368
|
+
errors.append(f"steps[{index}].depends_on must contain only non-empty strings")
|
|
369
|
+
continue
|
|
370
|
+
if dependency not in known_ids:
|
|
371
|
+
errors.append(f"steps[{index}].depends_on references unknown step id: {dependency}")
|
|
372
|
+
graph[step_id].append(dependency)
|
|
373
|
+
|
|
374
|
+
visiting: set[str] = set()
|
|
375
|
+
visited: set[str] = set()
|
|
376
|
+
|
|
377
|
+
def visit(step_id: str, chain: List[str]) -> None:
|
|
378
|
+
if step_id in visited:
|
|
379
|
+
return
|
|
380
|
+
if step_id in visiting:
|
|
381
|
+
cycle = " -> ".join([*chain, step_id])
|
|
382
|
+
errors.append(f"depends_on cycle detected: {cycle}")
|
|
383
|
+
return
|
|
384
|
+
visiting.add(step_id)
|
|
385
|
+
for dependency in graph.get(step_id, []):
|
|
386
|
+
if dependency in graph:
|
|
387
|
+
visit(dependency, [*chain, step_id])
|
|
388
|
+
visiting.remove(step_id)
|
|
389
|
+
visited.add(step_id)
|
|
390
|
+
|
|
391
|
+
for step_id in sorted(graph):
|
|
392
|
+
visit(step_id, [])
|
|
393
|
+
return errors
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def validate_plan(plan: Any) -> List[str]:
|
|
397
|
+
errors: List[str] = []
|
|
398
|
+
|
|
399
|
+
if not isinstance(plan, dict):
|
|
400
|
+
return ["plan must be an object"]
|
|
401
|
+
|
|
402
|
+
for field, expected_type in REQUIRED_PLAN_FIELDS.items():
|
|
403
|
+
if field not in plan:
|
|
404
|
+
errors.append(f"missing required field: {field}")
|
|
405
|
+
continue
|
|
406
|
+
if not isinstance(plan[field], expected_type):
|
|
407
|
+
errors.append(f"{field} must be {expected_type.__name__}")
|
|
408
|
+
|
|
409
|
+
if errors:
|
|
410
|
+
return errors
|
|
411
|
+
|
|
412
|
+
errors.extend(
|
|
413
|
+
validate_schema_version(plan["schema_version"], PLAN_SCHEMA_VERSION, "plan-lock")
|
|
414
|
+
)
|
|
415
|
+
if not plan["objective"].strip():
|
|
416
|
+
errors.append("objective must not be empty")
|
|
417
|
+
if plan["risk_level"] not in RISK_LEVELS:
|
|
418
|
+
errors.append(f"risk_level must be one of: {', '.join(RISK_LEVELS)}")
|
|
419
|
+
if not _is_required_non_empty_string_list(plan["scope"]):
|
|
420
|
+
errors.append("scope must contain at least one non-empty string")
|
|
421
|
+
if not _is_string_list(plan["non_goals"]):
|
|
422
|
+
errors.append("non_goals must contain only strings")
|
|
423
|
+
if not _is_required_non_empty_string_list(plan["invariants"]):
|
|
424
|
+
errors.append("invariants must contain at least one non-empty string")
|
|
425
|
+
if not _is_required_non_empty_string_list(plan["allowed_files"]):
|
|
426
|
+
errors.append("allowed_files must contain at least one non-empty string")
|
|
427
|
+
if not _is_string_list(plan["blocked_files"]):
|
|
428
|
+
errors.append("blocked_files must contain only strings")
|
|
429
|
+
if not _is_required_non_empty_string_list(plan["validation_gates"]):
|
|
430
|
+
errors.append("validation_gates must contain at least one non-empty string")
|
|
431
|
+
if not plan["rollback_plan"].strip():
|
|
432
|
+
errors.append("rollback_plan must not be empty")
|
|
433
|
+
|
|
434
|
+
drift_budget = plan["drift_budget"]
|
|
435
|
+
for field in ("unrelated_edits", "new_dependencies", "formatting_drift", "architecture_drift"):
|
|
436
|
+
if field not in drift_budget:
|
|
437
|
+
errors.append(f"drift_budget missing field: {field}")
|
|
438
|
+
for field in ("unrelated_edits", "new_dependencies", "test_weakening"):
|
|
439
|
+
if field in drift_budget and not _is_non_negative_int(drift_budget[field]):
|
|
440
|
+
errors.append(f"drift_budget.{field} must be a non-negative integer")
|
|
441
|
+
for field in ("formatting_drift", "architecture_drift"):
|
|
442
|
+
if field in drift_budget and not isinstance(drift_budget[field], str):
|
|
443
|
+
errors.append(f"drift_budget.{field} must be a string")
|
|
444
|
+
|
|
445
|
+
if "context_budget" in plan:
|
|
446
|
+
context_budget = plan["context_budget"]
|
|
447
|
+
if not isinstance(context_budget, dict):
|
|
448
|
+
errors.append("context_budget must be an object")
|
|
449
|
+
else:
|
|
450
|
+
for field in ("max_files", "max_total_bytes", "max_log_lines_per_failure"):
|
|
451
|
+
if field in context_budget and not _is_non_negative_int(
|
|
452
|
+
context_budget[field]
|
|
453
|
+
):
|
|
454
|
+
errors.append(f"context_budget.{field} must be a non-negative integer")
|
|
455
|
+
if "receipts_required" in context_budget and not isinstance(
|
|
456
|
+
context_budget["receipts_required"], bool
|
|
457
|
+
):
|
|
458
|
+
errors.append("context_budget.receipts_required must be boolean")
|
|
459
|
+
|
|
460
|
+
if "quality_gates" in plan and not _is_non_empty_string_list(plan["quality_gates"]):
|
|
461
|
+
errors.append("quality_gates must contain only non-empty strings")
|
|
462
|
+
|
|
463
|
+
if "runtime_routes" in plan and not isinstance(plan["runtime_routes"], dict):
|
|
464
|
+
errors.append("runtime_routes must be an object")
|
|
465
|
+
|
|
466
|
+
if "locked" in plan and not isinstance(plan["locked"], bool):
|
|
467
|
+
errors.append("locked must be boolean")
|
|
468
|
+
if "locked_at" in plan and not (
|
|
469
|
+
isinstance(plan["locked_at"], str) or plan["locked_at"] is None
|
|
470
|
+
):
|
|
471
|
+
errors.append("locked_at must be a string or null")
|
|
472
|
+
|
|
473
|
+
errors.extend(validate_requirement_traceability(plan))
|
|
474
|
+
errors.extend(validate_design_decision_traceability(plan))
|
|
475
|
+
|
|
476
|
+
evidence_values = plan["evidence_ids"]
|
|
477
|
+
if any(not isinstance(item, str) or not item for item in evidence_values):
|
|
478
|
+
errors.append("evidence_ids must contain only non-empty strings")
|
|
479
|
+
evidence_ids = {
|
|
480
|
+
item for item in evidence_values if isinstance(item, str) and item
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
step_ids = set()
|
|
484
|
+
for index, step in enumerate(plan["steps"], start=1):
|
|
485
|
+
prefix = f"steps[{index}]"
|
|
486
|
+
if not isinstance(step, dict):
|
|
487
|
+
errors.append(f"{prefix} must be an object")
|
|
488
|
+
continue
|
|
489
|
+
step_id = step.get("id")
|
|
490
|
+
if not isinstance(step_id, str) or not step_id:
|
|
491
|
+
errors.append(f"{prefix}.id must be a non-empty string")
|
|
492
|
+
elif step_id in step_ids:
|
|
493
|
+
errors.append(f"duplicate step id: {step_id}")
|
|
494
|
+
else:
|
|
495
|
+
step_ids.add(step_id)
|
|
496
|
+
for field in ("action", "preconditions", "expected_diff", "validation", "evidence_ids"):
|
|
497
|
+
if field not in step:
|
|
498
|
+
errors.append(f"{prefix} missing field: {field}")
|
|
499
|
+
if not isinstance(step.get("action"), str) or not step.get("action", "").strip():
|
|
500
|
+
errors.append(f"{prefix}.action must be a non-empty string")
|
|
501
|
+
if not _is_required_non_empty_string_list(step.get("files")):
|
|
502
|
+
errors.append(f"{prefix}.files must contain at least one file")
|
|
503
|
+
if not _is_required_non_empty_string_list(step.get("validation")):
|
|
504
|
+
errors.append(f"{prefix}.validation must contain at least one command or inspection")
|
|
505
|
+
for field in ("preconditions", "expected_diff", "evidence_ids"):
|
|
506
|
+
if field in step and not _is_string_list(step[field]):
|
|
507
|
+
errors.append(f"{prefix}.{field} must contain only strings")
|
|
508
|
+
execution_mode = step.get("execution_mode")
|
|
509
|
+
if "execution_mode" in step and execution_mode not in EXECUTION_MODES:
|
|
510
|
+
errors.append(
|
|
511
|
+
f"{prefix}.execution_mode must be one of: {', '.join(EXECUTION_MODES)}"
|
|
512
|
+
)
|
|
513
|
+
authority = step.get("authority")
|
|
514
|
+
if "authority" in step and authority not in AUTHORITIES:
|
|
515
|
+
errors.append(f"{prefix}.authority must be one of: {', '.join(AUTHORITIES)}")
|
|
516
|
+
runtime_role = step.get("runtime_role")
|
|
517
|
+
if "runtime_role" in step and (
|
|
518
|
+
not isinstance(runtime_role, str) or not runtime_role.strip()
|
|
519
|
+
):
|
|
520
|
+
errors.append(f"{prefix}.runtime_role must be a non-empty string")
|
|
521
|
+
step_evidence_ids = step.get("evidence_ids", [])
|
|
522
|
+
if isinstance(step_evidence_ids, list):
|
|
523
|
+
for evidence_id in step_evidence_ids:
|
|
524
|
+
if isinstance(evidence_id, str) and evidence_id not in evidence_ids:
|
|
525
|
+
errors.append(
|
|
526
|
+
f"{prefix}.evidence_ids references unknown evidence id: "
|
|
527
|
+
f"{evidence_id}"
|
|
528
|
+
)
|
|
529
|
+
if "gates" in step:
|
|
530
|
+
gates = step["gates"]
|
|
531
|
+
if not isinstance(gates, list) or not gates:
|
|
532
|
+
errors.append(f"{prefix}.gates must contain at least one gate")
|
|
533
|
+
else:
|
|
534
|
+
for gate_index, gate in enumerate(gates, start=1):
|
|
535
|
+
_validate_gate(
|
|
536
|
+
f"{prefix}.gates[{gate_index}]",
|
|
537
|
+
gate,
|
|
538
|
+
evidence_ids,
|
|
539
|
+
errors,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
errors.extend(
|
|
543
|
+
_detect_depends_on_errors(
|
|
544
|
+
[step for step in plan["steps"] if isinstance(step, dict)]
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
return errors
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def matches_path(path: str, patterns: Sequence[str]) -> bool:
|
|
551
|
+
for pattern in patterns:
|
|
552
|
+
normalized = pattern.strip()
|
|
553
|
+
if not normalized:
|
|
554
|
+
continue
|
|
555
|
+
if normalized.endswith("/"):
|
|
556
|
+
if path.startswith(normalized):
|
|
557
|
+
return True
|
|
558
|
+
if path == normalized or fnmatch.fnmatch(path, normalized):
|
|
559
|
+
return True
|
|
560
|
+
return False
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def effective_scope(plan: Dict[str, Any], step_id: str) -> Tuple[List[str], List[str]]:
|
|
564
|
+
"""Return ``(effective_allowed, blocked)`` for a step.
|
|
565
|
+
|
|
566
|
+
``effective_allowed`` is ``step.files`` intersected with top-level
|
|
567
|
+
``allowed_files``; ``blocked`` is the top-level ``blocked_files``. Mirrors
|
|
568
|
+
the file-receipt scope so risk screening and file receipts agree.
|
|
569
|
+
"""
|
|
570
|
+
step_files: List[str] = []
|
|
571
|
+
for step in plan.get("steps", []):
|
|
572
|
+
if isinstance(step, dict) and step.get("id") == step_id:
|
|
573
|
+
step_files = [item for item in step.get("files", []) if isinstance(item, str)]
|
|
574
|
+
break
|
|
575
|
+
allowed = [item for item in plan.get("allowed_files", []) if isinstance(item, str)]
|
|
576
|
+
blocked = [item for item in plan.get("blocked_files", []) if isinstance(item, str)]
|
|
577
|
+
effective_allowed = [
|
|
578
|
+
item
|
|
579
|
+
for item in step_files
|
|
580
|
+
if any(matches_path(item, [allowed_item]) for allowed_item in allowed)
|
|
581
|
+
]
|
|
582
|
+
return effective_allowed, blocked
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def path_in_effective_scope(plan: Dict[str, Any], step_id: str, path: str) -> bool:
|
|
586
|
+
allowed, blocked = effective_scope(plan, step_id)
|
|
587
|
+
return matches_path(path, allowed) and not matches_path(path, blocked)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def audit_drift(root: Path, plan: Dict[str, Any]) -> Dict[str, Any]:
|
|
591
|
+
report = {
|
|
592
|
+
"schema_version": DRIFT_REPORT_SCHEMA_VERSION,
|
|
593
|
+
"status": "pass",
|
|
594
|
+
"changed_files": [],
|
|
595
|
+
"unmapped_hunks": [],
|
|
596
|
+
"out_of_scope_files": [],
|
|
597
|
+
"blocked_files_changed": [],
|
|
598
|
+
"dependency_changes": [],
|
|
599
|
+
"test_weakening": [],
|
|
600
|
+
"notes": [],
|
|
601
|
+
"generated_at": utc_now(),
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if not is_git_repo(root):
|
|
605
|
+
report["status"] = "warning"
|
|
606
|
+
report["notes"].append("Not a git repository; drift audit could not inspect changed files.")
|
|
607
|
+
return report
|
|
608
|
+
|
|
609
|
+
files = changed_files(root)
|
|
610
|
+
report["changed_files"] = files
|
|
611
|
+
|
|
612
|
+
allowed = plan.get("allowed_files", [])
|
|
613
|
+
blocked = plan.get("blocked_files", [])
|
|
614
|
+
if not allowed:
|
|
615
|
+
report["status"] = "warning"
|
|
616
|
+
report["notes"].append("Plan has no allowed_files entries; every changed file is unaudited.")
|
|
617
|
+
report["out_of_scope_files"] = files
|
|
618
|
+
else:
|
|
619
|
+
report["out_of_scope_files"] = [path for path in files if not matches_path(path, allowed)]
|
|
620
|
+
|
|
621
|
+
report["blocked_files_changed"] = [path for path in files if matches_path(path, blocked)]
|
|
622
|
+
report["dependency_changes"] = [
|
|
623
|
+
path
|
|
624
|
+
for path in files
|
|
625
|
+
if path in {"pyproject.toml", "package.json", "package-lock.json", "pnpm-lock.yaml", "uv.lock"}
|
|
626
|
+
or path.endswith((".lock", "requirements.txt"))
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
if report["dependency_changes"]:
|
|
630
|
+
report["notes"].append("Dependency-related files changed; verify dependency budget manually.")
|
|
631
|
+
|
|
632
|
+
if report["out_of_scope_files"] or report["blocked_files_changed"]:
|
|
633
|
+
report["status"] = "fail"
|
|
634
|
+
|
|
635
|
+
hunk_policy = effective_hunk_policy(root)
|
|
636
|
+
ignore = [".agent/", ".git/"]
|
|
637
|
+
contract_path = root / ".agent/execution.contract.json"
|
|
638
|
+
if contract_path.exists():
|
|
639
|
+
try:
|
|
640
|
+
contract, _ = try_read_json(contract_path)
|
|
641
|
+
except OSError:
|
|
642
|
+
contract = None
|
|
643
|
+
if contract:
|
|
644
|
+
ignore = contract.get("concurrency", {}).get("reconcile_ignore", ignore)
|
|
645
|
+
if hunk_policy in ("enforce", "observe") and allowed:
|
|
646
|
+
candidates = [
|
|
647
|
+
record
|
|
648
|
+
for record in changed_file_records(root)
|
|
649
|
+
if not any(record["path"].startswith(prefix) for prefix in ignore)
|
|
650
|
+
and matches_path(record["path"], allowed)
|
|
651
|
+
and not matches_path(record["path"], blocked)
|
|
652
|
+
]
|
|
653
|
+
unmapped = unmapped_hunks(root, candidates)
|
|
654
|
+
if unmapped:
|
|
655
|
+
report["unmapped_hunks"] = unmapped
|
|
656
|
+
if hunk_policy == "enforce":
|
|
657
|
+
report["status"] = "fail"
|
|
658
|
+
report["notes"].append(
|
|
659
|
+
f"{len(unmapped)} unmapped hunk(s) not covered by any file receipt."
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
if report["status"] != "fail":
|
|
663
|
+
report["status"] = "warning"
|
|
664
|
+
report["notes"].append(
|
|
665
|
+
f"{len(unmapped)} unmapped hunk(s) detected (observe mode; non-blocking)."
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if not files:
|
|
669
|
+
report["notes"].append("No changed files detected by git status.")
|
|
670
|
+
|
|
671
|
+
# #20: enforce-mode stale-attempt note. Purely informational -- it never
|
|
672
|
+
# changes the pass/fail verdict; reclaim-step/fail-step is the operator resolution.
|
|
673
|
+
report["stale_attempts"] = []
|
|
674
|
+
if lease_policy(root) == "enforce":
|
|
675
|
+
state = read_step_state(root)
|
|
676
|
+
now = datetime.now(timezone.utc)
|
|
677
|
+
grace = lease_grace_seconds(root)
|
|
678
|
+
for step_id, step in state["steps"].items():
|
|
679
|
+
for aid in step.get("open_attempts", []):
|
|
680
|
+
attempt = state["attempts"].get(aid, {})
|
|
681
|
+
if attempt.get("lease_expires_at") and attempt_is_expired(attempt, now, grace):
|
|
682
|
+
report["stale_attempts"].append(
|
|
683
|
+
{
|
|
684
|
+
"step_id": step_id,
|
|
685
|
+
"attempt_id": aid,
|
|
686
|
+
"owner": attempt.get("agent_id"),
|
|
687
|
+
"expired_at": attempt.get("lease_expires_at"),
|
|
688
|
+
"note": "reclaim-step or fail-step",
|
|
689
|
+
}
|
|
690
|
+
)
|
|
691
|
+
report["notes"].append(
|
|
692
|
+
f"stale attempt {aid} owned by {attempt.get('agent_id')} "
|
|
693
|
+
f"expired at {attempt.get('lease_expires_at')}; reclaim or fail"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
return report
|