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.
@@ -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