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/artifacts.py ADDED
@@ -0,0 +1,331 @@
1
+ """Artifact defaults and filesystem helpers for Agentflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
12
+
13
+ from .contracts import (
14
+ ARTIFACT_COMPATIBILITY_POLICIES,
15
+ ARTIFACT_PATHS,
16
+ ARTIFACT_SCHEMA_VERSIONS,
17
+ ASSUMPTIONS_SCHEMA_VERSION,
18
+ CONTEXT_RECEIPTS_SCHEMA_VERSION,
19
+ DRIFT_REPORT_SCHEMA_VERSION,
20
+ EVIDENCE_SCHEMA_VERSION,
21
+ FAILURES_SCHEMA_VERSION,
22
+ PLAN_SCHEMA_VERSION,
23
+ PROOF_PACK_SCHEMA_VERSION,
24
+ EXECUTION_ARTIFACT_SCHEMA_VERSIONS,
25
+ )
26
+ from .versioning import validate_schema_version_policy
27
+
28
+
29
+ SCHEMA_VERSION = PLAN_SCHEMA_VERSION
30
+ _VALIDATE_ARTIFACT_VERSIONS = ContextVar(
31
+ "agentflow_validate_artifact_versions", default=True
32
+ )
33
+
34
+
35
+ def utc_now() -> str:
36
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
37
+
38
+
39
+ def dumps_json(data: Dict[str, Any]) -> str:
40
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"
41
+
42
+
43
+ # lock-plan restamps locked/locked_at on every re-lock, so a raw file hash
44
+ # would invalidate plan-bound evidence even when nothing semantic changed.
45
+ _PLAN_LOCK_METADATA_FIELDS = ("locked", "locked_at")
46
+
47
+
48
+ def plan_binding_sha256(plan: Dict[str, Any]) -> str:
49
+ """Canonical content hash used to bind evidence to a plan.
50
+
51
+ Excludes lock bookkeeping so a no-op re-lock keeps existing bindings;
52
+ any semantic plan change still invalidates them.
53
+ """
54
+ content = {
55
+ key: value
56
+ for key, value in plan.items()
57
+ if key not in _PLAN_LOCK_METADATA_FIELDS
58
+ }
59
+ payload = json.dumps(content, sort_keys=True, separators=(",", ":"))
60
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
61
+
62
+
63
+ def default_plan() -> Dict[str, Any]:
64
+ return {
65
+ "schema_version": PLAN_SCHEMA_VERSION,
66
+ "objective": "",
67
+ "scope": [],
68
+ "non_goals": [],
69
+ "invariants": [],
70
+ "allowed_files": [],
71
+ "blocked_files": [],
72
+ "validation_gates": [],
73
+ "rollback_plan": "",
74
+ "risk_level": "low",
75
+ "drift_budget": {
76
+ "unrelated_edits": 0,
77
+ "new_dependencies": 0,
78
+ "formatting_drift": "minimal",
79
+ "architecture_drift": "requires_approval",
80
+ "test_weakening": 0,
81
+ },
82
+ "steps": [],
83
+ "evidence_ids": [],
84
+ "locked": False,
85
+ "locked_at": None,
86
+ }
87
+
88
+
89
+ def default_assumptions() -> Dict[str, Any]:
90
+ return {
91
+ "schema_version": ASSUMPTIONS_SCHEMA_VERSION,
92
+ "assumptions": [],
93
+ }
94
+
95
+
96
+ def default_drift_report() -> Dict[str, Any]:
97
+ return {
98
+ "schema_version": DRIFT_REPORT_SCHEMA_VERSION,
99
+ "status": "warning",
100
+ "changed_files": [],
101
+ "unmapped_hunks": [],
102
+ "out_of_scope_files": [],
103
+ "blocked_files_changed": [],
104
+ "dependency_changes": [],
105
+ "test_weakening": [],
106
+ "notes": ["No drift audit has been run yet."],
107
+ "generated_at": utc_now(),
108
+ }
109
+
110
+
111
+ def default_proof_pack() -> str:
112
+ return """# Proof Pack
113
+
114
+ ## Objective
115
+
116
+ TBD.
117
+
118
+ ## Scope
119
+
120
+ TBD.
121
+
122
+ ## Plan Steps Completed
123
+
124
+ TBD.
125
+
126
+ ## Files Changed
127
+
128
+ TBD.
129
+
130
+ ## Evidence
131
+
132
+ TBD.
133
+
134
+ ## Validation
135
+
136
+ TBD.
137
+
138
+ ## Drift Audit
139
+
140
+ TBD.
141
+
142
+ ## Remaining Risk
143
+
144
+ TBD.
145
+
146
+ ## Follow-Ups
147
+
148
+ TBD.
149
+ """
150
+
151
+
152
+ def model_profile(provider: str, name: str, cost_tier: str) -> Dict[str, Any]:
153
+ return {
154
+ "schema_version": SCHEMA_VERSION,
155
+ "name": name,
156
+ "provider": provider,
157
+ "context_window": 0,
158
+ "supports_tool_calls": False,
159
+ "supports_json_schema": False,
160
+ "supports_vision": False,
161
+ "cost_tier": cost_tier,
162
+ "strengths": [],
163
+ }
164
+
165
+
166
+ def initial_files() -> List[Tuple[str, str]]:
167
+ return [
168
+ (".agent/plan.lock.json", dumps_json(default_plan())),
169
+ (".agent/evidence.jsonl", ""),
170
+ (".agent/assumptions.json", dumps_json(default_assumptions())),
171
+ (".agent/context-receipts.jsonl", ""),
172
+ (".agent/failures.jsonl", ""),
173
+ (".agent/amendments.jsonl", ""),
174
+ (".agent/review-runs.jsonl", ""),
175
+ (".agent/capability-receipts.jsonl", ""),
176
+ (".agent/runtime-snapshots.jsonl", ""),
177
+ (".agent/drift-report.json", dumps_json(default_drift_report())),
178
+ (".agent/proof-pack.md", default_proof_pack()),
179
+ (
180
+ ".agent/model-profiles/openai.example.json",
181
+ dumps_json(model_profile("openai", "model-id", "medium")),
182
+ ),
183
+ (
184
+ ".agent/model-profiles/anthropic.example.json",
185
+ dumps_json(model_profile("anthropic", "model-id", "medium")),
186
+ ),
187
+ (
188
+ ".agent/model-profiles/local.example.json",
189
+ dumps_json(model_profile("local", "model-id", "low")),
190
+ ),
191
+ ]
192
+
193
+
194
+ def _artifact_name(path: Path) -> Optional[str]:
195
+ candidate = path.as_posix()
196
+ paths = {**ARTIFACT_PATHS, "proof-pack": ".agent/proof-pack.json"}
197
+ return next(
198
+ (
199
+ name
200
+ for name, relative in paths.items()
201
+ if candidate == relative or candidate.endswith("/" + relative)
202
+ ),
203
+ None,
204
+ )
205
+
206
+
207
+ def _validate_artifact_version(
208
+ path: Path, data: Any, line_number: Optional[int] = None
209
+ ) -> None:
210
+ if not _VALIDATE_ARTIFACT_VERSIONS.get():
211
+ return
212
+ artifact = _artifact_name(path)
213
+ reader_gated = set(ARTIFACT_COMPATIBILITY_POLICIES) - {
214
+ "aggregation",
215
+ "proof-pack",
216
+ }
217
+ if (
218
+ artifact not in reader_gated
219
+ or not isinstance(data, dict)
220
+ ):
221
+ return
222
+ supported = EXECUTION_ARTIFACT_SCHEMA_VERSIONS.get(
223
+ artifact, ARTIFACT_SCHEMA_VERSIONS.get(artifact)
224
+ )
225
+ if supported is None:
226
+ return
227
+ errors = validate_schema_version_policy(
228
+ data.get("schema_version"),
229
+ supported,
230
+ artifact,
231
+ ARTIFACT_COMPATIBILITY_POLICIES[artifact],
232
+ )
233
+ if errors:
234
+ location = f"{path}:{line_number}" if line_number is not None else str(path)
235
+ raise ValueError(f"{location}: {errors[0]}")
236
+
237
+
238
+ @contextmanager
239
+ def historical_proof_reads() -> Iterable[None]:
240
+ """Let verify-proof read its hash-bound historical source artifacts."""
241
+ token = _VALIDATE_ARTIFACT_VERSIONS.set(False)
242
+ try:
243
+ yield
244
+ finally:
245
+ _VALIDATE_ARTIFACT_VERSIONS.reset(token)
246
+
247
+
248
+ def read_json(path: Path) -> Dict[str, Any]:
249
+ with path.open("r", encoding="utf-8") as handle:
250
+ data = json.load(handle)
251
+ _validate_artifact_version(path, data)
252
+ return data
253
+
254
+
255
+ def try_read_json(path: Path) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
256
+ """Read a JSON object without raising for malformed or non-object JSON."""
257
+ try:
258
+ data = read_json(path)
259
+ except json.JSONDecodeError as exc:
260
+ return None, f"malformed JSON in {path.name}: {exc}"
261
+ except ValueError as exc:
262
+ # Version-gate rejection: the file is well-formed JSON, so calling it
263
+ # malformed would send users chasing a parse error that does not exist.
264
+ return None, str(exc)
265
+ if not isinstance(data, dict):
266
+ return None, f"{path.name} top-level value must be a JSON object"
267
+ return data, None
268
+
269
+
270
+ def write_json(path: Path, data: Dict[str, Any]) -> None:
271
+ path.parent.mkdir(parents=True, exist_ok=True)
272
+ path.write_text(dumps_json(data), encoding="utf-8")
273
+
274
+
275
+ def append_jsonl(path: Path, item: Dict[str, Any]) -> None:
276
+ artifact = _artifact_name(path)
277
+ supported = (
278
+ EXECUTION_ARTIFACT_SCHEMA_VERSIONS.get(
279
+ artifact, ARTIFACT_SCHEMA_VERSIONS.get(artifact)
280
+ )
281
+ if artifact is not None
282
+ else None
283
+ )
284
+ if supported is not None and "schema_version" not in item:
285
+ item = {"schema_version": supported, **item}
286
+ path.parent.mkdir(parents=True, exist_ok=True)
287
+ with path.open("a", encoding="utf-8") as handle:
288
+ handle.write(json.dumps(item, sort_keys=True) + "\n")
289
+
290
+
291
+ def read_jsonl(path: Path) -> List[Dict[str, Any]]:
292
+ if not path.exists():
293
+ return []
294
+
295
+ items: List[Dict[str, Any]] = []
296
+ with path.open("r", encoding="utf-8") as handle:
297
+ for line_number, line in enumerate(handle, start=1):
298
+ stripped = line.strip()
299
+ if not stripped:
300
+ continue
301
+ try:
302
+ item = json.loads(stripped)
303
+ _validate_artifact_version(path, item, line_number)
304
+ items.append(item)
305
+ except json.JSONDecodeError as exc:
306
+ raise ValueError(f"{path}:{line_number}: invalid JSONL: {exc}") from exc
307
+ return items
308
+
309
+
310
+ def create_initial_artifacts(root: Path, force: bool = False) -> Tuple[List[str], List[str]]:
311
+ created: List[str] = []
312
+ skipped: List[str] = []
313
+
314
+ for relative_path, content in initial_files():
315
+ path = root / relative_path
316
+ if path.exists() and not force:
317
+ skipped.append(relative_path)
318
+ continue
319
+ path.parent.mkdir(parents=True, exist_ok=True)
320
+ path.write_text(content, encoding="utf-8")
321
+ created.append(relative_path)
322
+
323
+ return created, skipped
324
+
325
+
326
+ def require_files(paths: Iterable[Path]) -> List[str]:
327
+ missing = []
328
+ for path in paths:
329
+ if not path.exists():
330
+ missing.append(str(path))
331
+ return missing
@@ -0,0 +1,124 @@
1
+ """Capability receipts: record and summarize specialized-practice usage.
2
+
3
+ Agentflow declares required capabilities (via the workflow contract) and records
4
+ evidence that they were used or knowingly waived. It never invokes a provider;
5
+ ``provider`` is a free string.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .artifacts import append_jsonl, read_jsonl, utc_now
14
+ from .contracts import CAPABILITY_RECEIPTS_SCHEMA_VERSION, CAPABILITY_STATUSES
15
+
16
+ CAPABILITY_RECEIPTS_PATH = ".agent/capability-receipts.jsonl"
17
+
18
+ KNOWN_FIELDS = {
19
+ "schema_version",
20
+ "id",
21
+ "capability",
22
+ "status",
23
+ "provider",
24
+ "reason",
25
+ "evidence",
26
+ "recorded_at",
27
+ }
28
+
29
+
30
+ def _non_empty_string(value: Any) -> bool:
31
+ return isinstance(value, str) and bool(value.strip())
32
+
33
+
34
+ def validate_capability_receipt(row: Any) -> List[str]:
35
+ errors: List[str] = []
36
+ if not isinstance(row, dict):
37
+ return ["capability receipt must be a JSON object"]
38
+ for field in sorted(set(row) - KNOWN_FIELDS):
39
+ errors.append(f"unknown capability receipt field: {field}")
40
+ if row.get("schema_version") != CAPABILITY_RECEIPTS_SCHEMA_VERSION:
41
+ errors.append(f"schema_version must be {CAPABILITY_RECEIPTS_SCHEMA_VERSION}")
42
+ for field in ("id", "capability", "reason", "recorded_at"):
43
+ if not _non_empty_string(row.get(field)):
44
+ errors.append(f"{field} must be a non-empty string")
45
+ status = row.get("status")
46
+ if status not in CAPABILITY_STATUSES:
47
+ errors.append("status must be one of: " + ", ".join(CAPABILITY_STATUSES))
48
+ provider = row.get("provider")
49
+ if status == "used":
50
+ if not _non_empty_string(provider):
51
+ errors.append("provider must be a non-empty string when status is used")
52
+ elif provider is not None and not _non_empty_string(provider):
53
+ errors.append("provider must be a non-empty string when present")
54
+ evidence = row.get("evidence", [])
55
+ if not isinstance(evidence, list) or not all(_non_empty_string(item) for item in evidence):
56
+ errors.append("evidence must be a list of non-empty strings")
57
+ return errors
58
+
59
+
60
+ def build_capability_receipt(
61
+ receipt_id: str,
62
+ capability: str,
63
+ status: str,
64
+ reason: str,
65
+ provider: Optional[str] = None,
66
+ evidence: Optional[List[str]] = None,
67
+ ) -> Dict[str, Any]:
68
+ row: Dict[str, Any] = {
69
+ "schema_version": CAPABILITY_RECEIPTS_SCHEMA_VERSION,
70
+ "id": receipt_id,
71
+ "capability": capability,
72
+ "status": status,
73
+ "reason": reason,
74
+ "evidence": list(evidence or []),
75
+ "recorded_at": utc_now(),
76
+ }
77
+ if provider is not None:
78
+ row["provider"] = provider
79
+ errors = validate_capability_receipt(row)
80
+ if errors:
81
+ raise ValueError("; ".join(errors))
82
+ return row
83
+
84
+
85
+ def read_capability_receipts(root: Path) -> List[Dict[str, Any]]:
86
+ rows = read_jsonl(root / CAPABILITY_RECEIPTS_PATH)
87
+ for index, row in enumerate(rows, start=1):
88
+ errors = validate_capability_receipt(row)
89
+ if errors:
90
+ raise ValueError(
91
+ f"capability-receipts.jsonl row {index}: " + "; ".join(errors)
92
+ )
93
+ return rows
94
+
95
+
96
+ def append_capability_receipt(root: Path, row: Dict[str, Any]) -> None:
97
+ append_jsonl(root / CAPABILITY_RECEIPTS_PATH, row)
98
+
99
+
100
+ def capability_summary(root: Path, required_ids: List[str]) -> Dict[str, Any]:
101
+ rows = read_capability_receipts(root)
102
+ used = {row["capability"] for row in rows if row.get("status") == "used"}
103
+ waived = {row["capability"] for row in rows if row.get("status") == "waived"}
104
+ satisfied = used | waived
105
+ return {
106
+ "required": list(required_ids),
107
+ "recorded": sorted(used),
108
+ "waived": sorted(waived),
109
+ "missing": sorted({cap for cap in required_ids if cap not in satisfied}),
110
+ }
111
+
112
+
113
+ def capability_checks(summary: Dict[str, Any]) -> List[Dict[str, Any]]:
114
+ missing = summary.get("missing", [])
115
+ if missing:
116
+ return [
117
+ {
118
+ "id": "required_capabilities_satisfied",
119
+ "status": "warning",
120
+ "count": len(missing),
121
+ "message": "missing required capabilities: " + ", ".join(missing),
122
+ }
123
+ ]
124
+ return [{"id": "required_capabilities_satisfied", "status": "passed", "count": 0}]