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
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Execution verification coverage for Agentflow v0.3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from .artifacts import append_jsonl, read_jsonl, try_read_json, utc_now
|
|
10
|
+
from .contracts import TRUSTED_PROVENANCE_VALUES, VERIFICATION_RUNS_SCHEMA_VERSION
|
|
11
|
+
from .execution import (
|
|
12
|
+
TERMINAL_EVENTS,
|
|
13
|
+
attempt_is_expired,
|
|
14
|
+
lease_grace_seconds,
|
|
15
|
+
lease_policy,
|
|
16
|
+
read_step_events,
|
|
17
|
+
read_step_state,
|
|
18
|
+
resolve_attempt,
|
|
19
|
+
)
|
|
20
|
+
from .git import changed_file_records
|
|
21
|
+
from .hunks import effective_hunk_policy, unmapped_hunks
|
|
22
|
+
from .receipts import (
|
|
23
|
+
command_receipts,
|
|
24
|
+
file_receipts,
|
|
25
|
+
path_in_effective_scope,
|
|
26
|
+
replay_gates,
|
|
27
|
+
sha256_path,
|
|
28
|
+
)
|
|
29
|
+
from .stuck import detect_stuck
|
|
30
|
+
from .validation import audit_drift, matches_path, validate_plan
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _step_by_id(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
|
|
34
|
+
for step in plan.get("steps", []):
|
|
35
|
+
if isinstance(step, dict) and step.get("id") == step_id:
|
|
36
|
+
return step
|
|
37
|
+
raise ValueError(f"unknown step id: {step_id}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _evidence_ids(root: Path) -> set[str]:
|
|
41
|
+
return {
|
|
42
|
+
item["id"]
|
|
43
|
+
for item in read_jsonl(root / ".agent/evidence.jsonl")
|
|
44
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _command_gate_key(gate: Dict[str, Any]) -> str:
|
|
49
|
+
return " ".join(gate["run"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _matching_command_receipt(
|
|
53
|
+
gate: Dict[str, Any],
|
|
54
|
+
receipts: List[Dict[str, Any]],
|
|
55
|
+
receipt_by_gate: Dict[str, Dict[str, Any]],
|
|
56
|
+
) -> Optional[Dict[str, Any]]:
|
|
57
|
+
if isinstance(gate.get("run"), list):
|
|
58
|
+
for receipt in receipts:
|
|
59
|
+
if receipt.get("command") == gate["run"]:
|
|
60
|
+
return receipt
|
|
61
|
+
for alias in gate.get("aliases", [gate.get("gate")]):
|
|
62
|
+
receipt = receipt_by_gate.get(alias)
|
|
63
|
+
if receipt is not None:
|
|
64
|
+
return receipt
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _gates_for_step(step: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
69
|
+
gates = step.get("gates")
|
|
70
|
+
if isinstance(gates, list) and gates:
|
|
71
|
+
resolved = []
|
|
72
|
+
validation = [
|
|
73
|
+
item
|
|
74
|
+
for item in step.get("validation", [])
|
|
75
|
+
if isinstance(item, str)
|
|
76
|
+
]
|
|
77
|
+
for index, gate in enumerate(gates):
|
|
78
|
+
if not isinstance(gate, dict):
|
|
79
|
+
continue
|
|
80
|
+
if gate.get("kind") == "command" and isinstance(gate.get("run"), list):
|
|
81
|
+
aliases = [_command_gate_key(gate)]
|
|
82
|
+
if index < len(validation):
|
|
83
|
+
aliases.append(validation[index])
|
|
84
|
+
resolved.append(
|
|
85
|
+
{
|
|
86
|
+
"kind": "command",
|
|
87
|
+
"gate": aliases[0],
|
|
88
|
+
"aliases": aliases,
|
|
89
|
+
"run": gate["run"],
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
elif gate.get("kind") == "inspection":
|
|
93
|
+
resolved.append(
|
|
94
|
+
{
|
|
95
|
+
"kind": "inspection",
|
|
96
|
+
"evidence_id": gate.get("evidence_id"),
|
|
97
|
+
"describe": gate.get("describe", ""),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return resolved
|
|
101
|
+
return [
|
|
102
|
+
{"kind": "legacy", "gate": item}
|
|
103
|
+
for item in step.get("validation", [])
|
|
104
|
+
if isinstance(item, str)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _add_finding(target: Dict[str, List[Dict[str, str]]], severity: str, message: str) -> None:
|
|
109
|
+
target["errors" if severity == "error" else "warnings"].append(
|
|
110
|
+
{"severity": severity, "message": message}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _receipt_timed_out(receipt: Dict[str, Any]) -> bool:
|
|
115
|
+
return receipt.get("decision") == "timeout" or receipt.get("timed_out") is True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _timeout_message(gate: str, receipt: Dict[str, Any]) -> str:
|
|
119
|
+
timeout_seconds = receipt.get("timeout_seconds")
|
|
120
|
+
return f"gate {gate} timed out after {timeout_seconds} seconds"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _proof_policy(root: Path) -> Dict[str, Any]:
|
|
124
|
+
contract_path = root / ".agent/execution.contract.json"
|
|
125
|
+
if not contract_path.exists():
|
|
126
|
+
return {}
|
|
127
|
+
try:
|
|
128
|
+
contract, _ = try_read_json(contract_path)
|
|
129
|
+
except OSError:
|
|
130
|
+
return {}
|
|
131
|
+
if contract is None:
|
|
132
|
+
return {}
|
|
133
|
+
policy = contract.get("proof_policy", {})
|
|
134
|
+
return policy if isinstance(policy, dict) else {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _verification_run_id(root: Path) -> str:
|
|
138
|
+
path = root / ".agent/verification-runs.jsonl"
|
|
139
|
+
return f"VR{len(read_jsonl(path)) + 1}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _append_verification(
|
|
143
|
+
root: Path,
|
|
144
|
+
scope: str,
|
|
145
|
+
step_id: Optional[str],
|
|
146
|
+
attempt_id: Optional[str],
|
|
147
|
+
strict: bool,
|
|
148
|
+
replay: bool,
|
|
149
|
+
result: Dict[str, Any],
|
|
150
|
+
) -> None:
|
|
151
|
+
entry: Dict[str, Any] = {
|
|
152
|
+
"schema_version": VERIFICATION_RUNS_SCHEMA_VERSION,
|
|
153
|
+
"id": _verification_run_id(root),
|
|
154
|
+
"scope": scope,
|
|
155
|
+
"strict": strict,
|
|
156
|
+
"replay": replay,
|
|
157
|
+
"status": result["status"],
|
|
158
|
+
"findings": [*result["errors"], *result["warnings"]],
|
|
159
|
+
"recorded_at": utc_now(),
|
|
160
|
+
}
|
|
161
|
+
if step_id is not None:
|
|
162
|
+
entry["step_id"] = step_id
|
|
163
|
+
if attempt_id is not None:
|
|
164
|
+
entry["attempt_id"] = attempt_id
|
|
165
|
+
append_jsonl(root / ".agent/verification-runs.jsonl", entry)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def verify_step(
|
|
169
|
+
root: Path,
|
|
170
|
+
plan: Dict[str, Any],
|
|
171
|
+
step_id: str,
|
|
172
|
+
attempt_id: Optional[str],
|
|
173
|
+
strict: bool = False,
|
|
174
|
+
replay: bool = False,
|
|
175
|
+
record: bool = True,
|
|
176
|
+
include_gates: bool = False,
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
step = _step_by_id(plan, step_id)
|
|
179
|
+
resolved_attempt = resolve_attempt(root, step_id, attempt_id)
|
|
180
|
+
result: Dict[str, Any] = {
|
|
181
|
+
"status": "passed",
|
|
182
|
+
"errors": [],
|
|
183
|
+
"warnings": [],
|
|
184
|
+
}
|
|
185
|
+
gate_results: List[Dict[str, Any]] = []
|
|
186
|
+
if include_gates:
|
|
187
|
+
result["gates"] = gate_results
|
|
188
|
+
policy = _proof_policy(root)
|
|
189
|
+
effective_strict = strict or bool(policy.get("strict_by_default"))
|
|
190
|
+
require_command_receipts = effective_strict or bool(
|
|
191
|
+
policy.get("require_command_receipts_for_validation")
|
|
192
|
+
)
|
|
193
|
+
require_inspection_evidence = effective_strict or bool(
|
|
194
|
+
policy.get("require_evidence_for_inspection_gates")
|
|
195
|
+
)
|
|
196
|
+
require_trusted_receipts = effective_strict or bool(
|
|
197
|
+
policy.get("require_managed_receipts_for_validation")
|
|
198
|
+
)
|
|
199
|
+
evidence = _evidence_ids(root)
|
|
200
|
+
receipts = [
|
|
201
|
+
receipt
|
|
202
|
+
for receipt in command_receipts(root)
|
|
203
|
+
if receipt.get("step_id") == step_id and receipt.get("attempt_id") == resolved_attempt
|
|
204
|
+
]
|
|
205
|
+
files = [
|
|
206
|
+
receipt
|
|
207
|
+
for receipt in file_receipts(root)
|
|
208
|
+
if receipt.get("step_id") == step_id and receipt.get("attempt_id") == resolved_attempt
|
|
209
|
+
]
|
|
210
|
+
receipt_by_gate = {
|
|
211
|
+
receipt.get("gate"): receipt
|
|
212
|
+
for receipt in receipts
|
|
213
|
+
if isinstance(receipt.get("gate"), str)
|
|
214
|
+
}
|
|
215
|
+
for gate in _gates_for_step(step):
|
|
216
|
+
gate_result = {
|
|
217
|
+
"kind": gate["kind"],
|
|
218
|
+
"label": gate.get("gate") or gate.get("evidence_id") or gate.get("describe", ""),
|
|
219
|
+
"status": "satisfied",
|
|
220
|
+
"receipt_id": None,
|
|
221
|
+
"evidence_id": gate.get("evidence_id"),
|
|
222
|
+
}
|
|
223
|
+
gate_results.append(gate_result)
|
|
224
|
+
if gate["kind"] == "command":
|
|
225
|
+
receipt = _matching_command_receipt(gate, receipts, receipt_by_gate)
|
|
226
|
+
if receipt is None:
|
|
227
|
+
gate_result["status"] = "missing"
|
|
228
|
+
_add_finding(
|
|
229
|
+
result,
|
|
230
|
+
"error" if require_command_receipts else "warning",
|
|
231
|
+
f"missing command receipt for gate: {gate['gate']}",
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
gate_result["receipt_id"] = receipt.get("id")
|
|
235
|
+
if _receipt_timed_out(receipt):
|
|
236
|
+
gate_result["status"] = "failed"
|
|
237
|
+
_add_finding(result, "error", _timeout_message(gate["gate"], receipt))
|
|
238
|
+
elif receipt.get("exit_code") != 0:
|
|
239
|
+
gate_result["status"] = "failed"
|
|
240
|
+
_add_finding(
|
|
241
|
+
result,
|
|
242
|
+
"error",
|
|
243
|
+
f"gate {gate['gate']} recorded exit code {receipt.get('exit_code')}",
|
|
244
|
+
)
|
|
245
|
+
if require_trusted_receipts and receipt.get("provenance") not in TRUSTED_PROVENANCE_VALUES:
|
|
246
|
+
gate_result["status"] = "failed"
|
|
247
|
+
_add_finding(result, "error", f"gate {gate['gate']} uses attested provenance")
|
|
248
|
+
elif gate["kind"] == "inspection":
|
|
249
|
+
evidence_id = gate.get("evidence_id")
|
|
250
|
+
if evidence_id not in evidence:
|
|
251
|
+
gate_result["status"] = "missing"
|
|
252
|
+
_add_finding(result, "error", f"inspection evidence id {evidence_id} is missing")
|
|
253
|
+
else:
|
|
254
|
+
receipt = receipt_by_gate.get(gate["gate"])
|
|
255
|
+
if receipt:
|
|
256
|
+
gate_result["receipt_id"] = receipt.get("id")
|
|
257
|
+
if _receipt_timed_out(receipt):
|
|
258
|
+
gate_result["status"] = "failed"
|
|
259
|
+
_add_finding(result, "error", _timeout_message(gate["gate"], receipt))
|
|
260
|
+
elif receipt.get("exit_code") != 0:
|
|
261
|
+
gate_result["status"] = "failed"
|
|
262
|
+
_add_finding(
|
|
263
|
+
result,
|
|
264
|
+
"error",
|
|
265
|
+
f"gate {gate['gate']} recorded exit code {receipt.get('exit_code')}",
|
|
266
|
+
)
|
|
267
|
+
if (
|
|
268
|
+
require_trusted_receipts
|
|
269
|
+
and receipt.get("provenance") not in TRUSTED_PROVENANCE_VALUES
|
|
270
|
+
):
|
|
271
|
+
gate_result["status"] = "failed"
|
|
272
|
+
_add_finding(result, "error", f"gate {gate['gate']} uses attested provenance")
|
|
273
|
+
elif not any(evidence_id in evidence for evidence_id in step.get("evidence_ids", [])):
|
|
274
|
+
gate_result["status"] = "missing"
|
|
275
|
+
_add_finding(
|
|
276
|
+
result,
|
|
277
|
+
"error"
|
|
278
|
+
if require_command_receipts or require_inspection_evidence
|
|
279
|
+
else "warning",
|
|
280
|
+
f"legacy inspection gate needs evidence or receipt: {gate['gate']}",
|
|
281
|
+
)
|
|
282
|
+
for receipt in files:
|
|
283
|
+
path = receipt.get("path")
|
|
284
|
+
if isinstance(path, str) and not path_in_effective_scope(plan, step_id, path):
|
|
285
|
+
_add_finding(result, "error", f"{path} is outside effective file scope for {step_id}")
|
|
286
|
+
if replay:
|
|
287
|
+
replay_result = replay_gates(root, plan, step_id=step_id, record=False)
|
|
288
|
+
result["errors"].extend(replay_result["errors"])
|
|
289
|
+
result["warnings"].extend(replay_result["warnings"])
|
|
290
|
+
if result["errors"]:
|
|
291
|
+
result["status"] = "failed"
|
|
292
|
+
elif result["warnings"]:
|
|
293
|
+
result["status"] = "warning"
|
|
294
|
+
if record:
|
|
295
|
+
_append_verification(root, "step", step_id, resolved_attempt, effective_strict, replay, result)
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def build_execution_coverage(
|
|
300
|
+
root: Path,
|
|
301
|
+
plan: Dict[str, Any],
|
|
302
|
+
strict: bool = False,
|
|
303
|
+
*,
|
|
304
|
+
now: Optional[datetime] = None,
|
|
305
|
+
) -> Dict[str, Any]:
|
|
306
|
+
state = read_step_state(root)
|
|
307
|
+
coverage: Dict[str, Any] = {
|
|
308
|
+
"steps": {},
|
|
309
|
+
"attempts": len(state["attempts"]),
|
|
310
|
+
"completed_steps": [],
|
|
311
|
+
"failed_attempts": [],
|
|
312
|
+
}
|
|
313
|
+
# #20 lease diagnostics. abandoned_attempts is a deterministic ledger fact.
|
|
314
|
+
# expired_leases / no_deadline_open_attempts are evaluated against `now` at
|
|
315
|
+
# build time and may legitimately differ before vs. after an expiry -- they
|
|
316
|
+
# are proof facts at build time, not reproducible-across-time invariants.
|
|
317
|
+
coverage["expired_leases"] = []
|
|
318
|
+
coverage["no_deadline_open_attempts"] = []
|
|
319
|
+
coverage["abandoned_attempts"] = [
|
|
320
|
+
{
|
|
321
|
+
"attempt_id": aid,
|
|
322
|
+
"abandoned_by": attempt["events"][-1].get("abandoned_by"),
|
|
323
|
+
"superseded_by": attempt["events"][-1].get("superseded_by"),
|
|
324
|
+
}
|
|
325
|
+
for aid, attempt in state["attempts"].items()
|
|
326
|
+
if attempt.get("status") == "abandoned" and attempt.get("events")
|
|
327
|
+
]
|
|
328
|
+
enforce = lease_policy(root) == "enforce"
|
|
329
|
+
grace = lease_grace_seconds(root)
|
|
330
|
+
current = now or datetime.now(timezone.utc)
|
|
331
|
+
for step in plan.get("steps", []):
|
|
332
|
+
if not isinstance(step, dict) or not isinstance(step.get("id"), str):
|
|
333
|
+
continue
|
|
334
|
+
step_id = step["id"]
|
|
335
|
+
step_state = state["steps"].get(
|
|
336
|
+
step_id,
|
|
337
|
+
{"status": "pending", "completed": False, "open_attempts": []},
|
|
338
|
+
)
|
|
339
|
+
step_result = {"status": step_state["status"], "errors": [], "warnings": []}
|
|
340
|
+
if step_state.get("completed"):
|
|
341
|
+
coverage["completed_steps"].append(step_id)
|
|
342
|
+
if step_state.get("open_attempts"):
|
|
343
|
+
step_result["warnings"].append(
|
|
344
|
+
{"severity": "warning", "message": f"{step_id} has open attempt"}
|
|
345
|
+
)
|
|
346
|
+
for aid in step_state.get("open_attempts", []):
|
|
347
|
+
attempt = state["attempts"].get(aid, {})
|
|
348
|
+
if attempt.get("lease_expires_at") is None:
|
|
349
|
+
if enforce:
|
|
350
|
+
coverage["no_deadline_open_attempts"].append(aid)
|
|
351
|
+
step_result["warnings"].append(
|
|
352
|
+
{
|
|
353
|
+
"severity": "warning",
|
|
354
|
+
"message": f"{step_id}/{aid} has no lease deadline; owner renew-lease or fail-step",
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
elif attempt_is_expired(attempt, current, grace):
|
|
358
|
+
coverage["expired_leases"].append(aid)
|
|
359
|
+
if enforce:
|
|
360
|
+
step_result["errors"].append(
|
|
361
|
+
{
|
|
362
|
+
"severity": "error",
|
|
363
|
+
"message": f"{step_id}/{aid} lease expired at {attempt['lease_expires_at']}; reclaim-step or fail-step",
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
step_result["warnings"].append(
|
|
368
|
+
{
|
|
369
|
+
"severity": "warning",
|
|
370
|
+
"message": f"{step_id}/{aid} lease expired (advisory)",
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
if step_state["status"] == "failed":
|
|
374
|
+
coverage["failed_attempts"].extend(step_state.get("attempts", []))
|
|
375
|
+
coverage["steps"][step_id] = step_result
|
|
376
|
+
return coverage
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _ignored(path: str, prefixes: List[str]) -> bool:
|
|
380
|
+
return any(path.startswith(prefix) for prefix in prefixes)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
|
|
384
|
+
latest: Dict[str, Dict[str, Any]] = {}
|
|
385
|
+
for receipt in file_receipts(root):
|
|
386
|
+
path = receipt.get("path")
|
|
387
|
+
if isinstance(path, str):
|
|
388
|
+
latest[path] = receipt
|
|
389
|
+
return latest
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _reconcile_changed_files(
|
|
393
|
+
root: Path,
|
|
394
|
+
plan: Dict[str, Any],
|
|
395
|
+
require_file_receipts: bool = True,
|
|
396
|
+
) -> List[Dict[str, str]]:
|
|
397
|
+
contract: Dict[str, Any] = {}
|
|
398
|
+
contract_path = root / ".agent/execution.contract.json"
|
|
399
|
+
if contract_path.exists():
|
|
400
|
+
try:
|
|
401
|
+
contract, _ = try_read_json(contract_path)
|
|
402
|
+
except OSError:
|
|
403
|
+
contract = None
|
|
404
|
+
if contract is None:
|
|
405
|
+
contract = {}
|
|
406
|
+
ignore = contract.get("concurrency", {}).get("reconcile_ignore", [".agent/", ".git/"])
|
|
407
|
+
latest = _latest_file_receipts_by_path(root)
|
|
408
|
+
findings: List[Dict[str, str]] = []
|
|
409
|
+
for record in changed_file_records(root):
|
|
410
|
+
path = record["path"]
|
|
411
|
+
if _ignored(path, ignore):
|
|
412
|
+
continue
|
|
413
|
+
receipt = latest.get(path)
|
|
414
|
+
if receipt is None:
|
|
415
|
+
if not require_file_receipts:
|
|
416
|
+
continue
|
|
417
|
+
findings.append(
|
|
418
|
+
{
|
|
419
|
+
"severity": "error",
|
|
420
|
+
"message": f"changed file is not mapped to a step receipt: {path}",
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
continue
|
|
424
|
+
if record["change_kind"] == "deleted":
|
|
425
|
+
if receipt.get("change_kind") != "deleted":
|
|
426
|
+
findings.append(
|
|
427
|
+
{"severity": "error", "message": f"deleted file lacks deleted receipt: {path}"}
|
|
428
|
+
)
|
|
429
|
+
continue
|
|
430
|
+
expected_hash = receipt.get("after_sha256")
|
|
431
|
+
actual_hash = sha256_path(root / path)
|
|
432
|
+
if expected_hash != actual_hash:
|
|
433
|
+
findings.append(
|
|
434
|
+
{"severity": "error", "message": f"out-of-band edit after latest file receipt: {path}"}
|
|
435
|
+
)
|
|
436
|
+
if effective_hunk_policy(root) == "enforce":
|
|
437
|
+
# Scope-filter candidates the same way audit_drift does, so verify-run
|
|
438
|
+
# and audit-drift cannot diverge on which paths are hunk-checked.
|
|
439
|
+
allowed = plan.get("allowed_files", [])
|
|
440
|
+
blocked = plan.get("blocked_files", [])
|
|
441
|
+
candidates = [
|
|
442
|
+
record
|
|
443
|
+
for record in changed_file_records(root)
|
|
444
|
+
if not _ignored(record["path"], ignore)
|
|
445
|
+
and matches_path(record["path"], allowed)
|
|
446
|
+
and not matches_path(record["path"], blocked)
|
|
447
|
+
]
|
|
448
|
+
for entry in unmapped_hunks(root, candidates):
|
|
449
|
+
findings.append(
|
|
450
|
+
{
|
|
451
|
+
"severity": "error",
|
|
452
|
+
"message": (
|
|
453
|
+
f"unmapped hunk in {entry['path']} "
|
|
454
|
+
f"(@@ -{entry['old_start']},{entry['old_count']} "
|
|
455
|
+
f"+{entry['new_start']},{entry['new_count']} @@): no matching file receipt"
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
return findings
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _parse_iso_timestamp(value: str) -> Optional[datetime]:
|
|
463
|
+
normalized = f"{value[:-1]}+00:00" if value.endswith("Z") else value
|
|
464
|
+
try:
|
|
465
|
+
parsed = datetime.fromisoformat(normalized)
|
|
466
|
+
except ValueError:
|
|
467
|
+
try:
|
|
468
|
+
parsed_date = date.fromisoformat(normalized)
|
|
469
|
+
except ValueError:
|
|
470
|
+
return None
|
|
471
|
+
parsed = datetime.combine(parsed_date, datetime.min.time())
|
|
472
|
+
if parsed.tzinfo is None:
|
|
473
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
474
|
+
return parsed.astimezone(timezone.utc)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _terminal_attempt_violations(root: Path) -> List[Dict[str, str]]:
|
|
478
|
+
"""Flag receipts whose attempt is unknown/foreign or terminal-at-write.
|
|
479
|
+
|
|
480
|
+
Backstop for hand-edited ledgers; the write guard is the primary path.
|
|
481
|
+
Every receipt must name an existing attempt for the same step. New-work
|
|
482
|
+
receipts must additionally predate their attempt's terminal event;
|
|
483
|
+
reconstructed/managed command receipts are exempt from that terminal check
|
|
484
|
+
(gate replay legitimately writes them to completed attempts).
|
|
485
|
+
"""
|
|
486
|
+
state = read_step_state(root)
|
|
487
|
+
attempts = state["attempts"]
|
|
488
|
+
terminal_at: Dict[tuple, datetime] = {}
|
|
489
|
+
for event in read_step_events(root):
|
|
490
|
+
if event.get("event") in TERMINAL_EVENTS:
|
|
491
|
+
stamp = event.get("recorded_at")
|
|
492
|
+
if isinstance(stamp, str):
|
|
493
|
+
parsed = _parse_iso_timestamp(stamp)
|
|
494
|
+
if parsed is not None:
|
|
495
|
+
terminal_at[(event.get("step_id"), event.get("attempt_id"))] = parsed
|
|
496
|
+
exempt = {"reconstructed", "managed"}
|
|
497
|
+
findings: List[Dict[str, str]] = []
|
|
498
|
+
|
|
499
|
+
def _check(receipt: Dict[str, Any], stamp_key: str, new_work: bool) -> None:
|
|
500
|
+
step_id = receipt.get("step_id")
|
|
501
|
+
attempt_id = receipt.get("attempt_id")
|
|
502
|
+
receipt_id = receipt.get("id")
|
|
503
|
+
attempt = attempts.get(attempt_id)
|
|
504
|
+
if attempt is None or attempt.get("step_id") != step_id:
|
|
505
|
+
findings.append(
|
|
506
|
+
{
|
|
507
|
+
"severity": "error",
|
|
508
|
+
"message": f"receipt references unknown attempt: {receipt_id}",
|
|
509
|
+
}
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
if not new_work:
|
|
513
|
+
return
|
|
514
|
+
term = terminal_at.get((step_id, attempt_id))
|
|
515
|
+
stamp = receipt.get(stamp_key)
|
|
516
|
+
parsed_stamp = _parse_iso_timestamp(stamp) if isinstance(stamp, str) else None
|
|
517
|
+
if term and parsed_stamp is not None and parsed_stamp > term:
|
|
518
|
+
findings.append(
|
|
519
|
+
{
|
|
520
|
+
"severity": "error",
|
|
521
|
+
"message": f"terminal-attempt receipt violation: {receipt_id}",
|
|
522
|
+
}
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
for receipt in file_receipts(root):
|
|
526
|
+
_check(receipt, "recorded_at", new_work=True)
|
|
527
|
+
for receipt in command_receipts(root):
|
|
528
|
+
_check(receipt, "finished_at", new_work=receipt.get("provenance") not in exempt)
|
|
529
|
+
return findings
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _timeout_receipt_violations(root: Path) -> List[Dict[str, str]]:
|
|
533
|
+
findings: List[Dict[str, str]] = []
|
|
534
|
+
for receipt in command_receipts(root):
|
|
535
|
+
if _receipt_timed_out(receipt):
|
|
536
|
+
findings.append(
|
|
537
|
+
{
|
|
538
|
+
"severity": "error",
|
|
539
|
+
"message": (
|
|
540
|
+
f"command receipt {receipt.get('id')} for {receipt.get('step_id')} "
|
|
541
|
+
f"timed out after {receipt.get('timeout_seconds')} seconds"
|
|
542
|
+
),
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
return findings
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _unresolved_finding_refs(root: Path) -> List[Dict[str, str]]:
|
|
549
|
+
"""Flag amendment finding refs whose review run is not recorded.
|
|
550
|
+
|
|
551
|
+
Backstop for review-cycle integration: an ``amendment_started`` event may
|
|
552
|
+
cite findings from a review run that was never written to
|
|
553
|
+
``.agent/review-runs.jsonl``. Each such reference is surfaced as a finding.
|
|
554
|
+
"""
|
|
555
|
+
recorded = {
|
|
556
|
+
row.get("review_run_id")
|
|
557
|
+
for row in read_jsonl(root / ".agent/review-runs.jsonl")
|
|
558
|
+
if isinstance(row, dict)
|
|
559
|
+
}
|
|
560
|
+
findings: List[Dict[str, str]] = []
|
|
561
|
+
for event in read_step_events(root):
|
|
562
|
+
if event.get("event") != "amendment_started":
|
|
563
|
+
continue
|
|
564
|
+
for ref in event.get("finding_refs", []) or []:
|
|
565
|
+
if not isinstance(ref, dict):
|
|
566
|
+
continue
|
|
567
|
+
if ref.get("review_run_id") not in recorded:
|
|
568
|
+
findings.append(
|
|
569
|
+
{
|
|
570
|
+
"severity": "warning",
|
|
571
|
+
"message": (
|
|
572
|
+
f"unresolved finding ref {ref.get('review_run_id')}#"
|
|
573
|
+
f"{ref.get('finding_id')} on {event.get('step_id')}/"
|
|
574
|
+
f"{event.get('attempt_id')}"
|
|
575
|
+
),
|
|
576
|
+
}
|
|
577
|
+
)
|
|
578
|
+
return findings
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def verify_run(
|
|
582
|
+
root: Path,
|
|
583
|
+
plan: Dict[str, Any],
|
|
584
|
+
strict: bool = False,
|
|
585
|
+
record: bool = True,
|
|
586
|
+
*,
|
|
587
|
+
now: Optional[datetime] = None,
|
|
588
|
+
) -> Dict[str, Any]:
|
|
589
|
+
result: Dict[str, Any] = {"status": "passed", "errors": [], "warnings": []}
|
|
590
|
+
policy = _proof_policy(root)
|
|
591
|
+
effective_strict = strict or bool(policy.get("strict_by_default"))
|
|
592
|
+
require_file_receipts = effective_strict or bool(
|
|
593
|
+
policy.get("require_file_receipts_for_changed_files", True)
|
|
594
|
+
)
|
|
595
|
+
for error in validate_plan(plan):
|
|
596
|
+
result["errors"].append({"severity": "error", "message": error})
|
|
597
|
+
drift = audit_drift(root, plan)
|
|
598
|
+
if drift.get("status") == "fail":
|
|
599
|
+
for path in drift.get("out_of_scope_files", []):
|
|
600
|
+
if not path.startswith(".agent/"):
|
|
601
|
+
result["errors"].append(
|
|
602
|
+
{"severity": "error", "message": f"out-of-scope changed file: {path}"}
|
|
603
|
+
)
|
|
604
|
+
for path in drift.get("blocked_files_changed", []):
|
|
605
|
+
result["errors"].append(
|
|
606
|
+
{"severity": "error", "message": f"blocked file changed: {path}"}
|
|
607
|
+
)
|
|
608
|
+
result["errors"].extend(_reconcile_changed_files(root, plan, require_file_receipts))
|
|
609
|
+
coverage = build_execution_coverage(root, plan, effective_strict, now=now)
|
|
610
|
+
for step_id, step in coverage["steps"].items():
|
|
611
|
+
for warning in step.get("warnings", []):
|
|
612
|
+
result["warnings"].append(warning)
|
|
613
|
+
# #20: an unrecovered expired lease under enforce is a coverage error and
|
|
614
|
+
# must gate the run (advisory downgrades it to a warning above).
|
|
615
|
+
for error in step.get("errors", []):
|
|
616
|
+
result["errors"].append(error)
|
|
617
|
+
if step["status"] == "failed":
|
|
618
|
+
result["errors"].append({"severity": "error", "message": f"{step_id} has failed attempt"})
|
|
619
|
+
if effective_strict and step["status"] != "completed":
|
|
620
|
+
result["errors"].append({"severity": "error", "message": f"{step_id} is not completed"})
|
|
621
|
+
result["errors"].extend(_terminal_attempt_violations(root))
|
|
622
|
+
result["errors"].extend(_timeout_receipt_violations(root))
|
|
623
|
+
for finding in _unresolved_finding_refs(root):
|
|
624
|
+
if effective_strict:
|
|
625
|
+
result["errors"].append(
|
|
626
|
+
{"severity": "error", "message": finding["message"]}
|
|
627
|
+
)
|
|
628
|
+
else:
|
|
629
|
+
result["warnings"].append(finding)
|
|
630
|
+
for finding in detect_stuck(root, plan)["findings"]:
|
|
631
|
+
result["warnings"].append(
|
|
632
|
+
{
|
|
633
|
+
"severity": "warning",
|
|
634
|
+
"message": finding["message"],
|
|
635
|
+
"rule": finding["rule"],
|
|
636
|
+
"step_id": finding["step_id"],
|
|
637
|
+
"attempt_id": finding["attempt_id"],
|
|
638
|
+
}
|
|
639
|
+
)
|
|
640
|
+
if result["errors"]:
|
|
641
|
+
result["status"] = "failed"
|
|
642
|
+
elif result["warnings"]:
|
|
643
|
+
result["status"] = "warning"
|
|
644
|
+
if record:
|
|
645
|
+
_append_verification(root, "run", None, None, effective_strict, False, result)
|
|
646
|
+
return result
|