agent-lifecycle-kit 1.50.1__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.
Files changed (227) hide show
  1. agent_lifecycle/__init__.py +5 -0
  2. agent_lifecycle/__main__.py +5 -0
  3. agent_lifecycle/_version.py +3 -0
  4. agent_lifecycle/adapter_sessions/__init__.py +41 -0
  5. agent_lifecycle/adapter_sessions/contracts.py +133 -0
  6. agent_lifecycle/adapter_sessions/env.py +44 -0
  7. agent_lifecycle/adapter_sessions/launcher.py +101 -0
  8. agent_lifecycle/adapter_sessions/process.py +39 -0
  9. agent_lifecycle/adapter_sessions/redaction.py +30 -0
  10. agent_lifecycle/adapter_sessions/session_store.py +64 -0
  11. agent_lifecycle/adapter_sessions/task_intake.py +450 -0
  12. agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
  13. agent_lifecycle/audit/__init__.py +67 -0
  14. agent_lifecycle/audit/bug_forensics.py +94 -0
  15. agent_lifecycle/audit/implementation.py +516 -0
  16. agent_lifecycle/audit/ownership.py +119 -0
  17. agent_lifecycle/audit/proof_integrity.py +814 -0
  18. agent_lifecycle/audit/review_verdict.py +116 -0
  19. agent_lifecycle/changesets/__init__.py +5 -0
  20. agent_lifecycle/changesets/git.py +35 -0
  21. agent_lifecycle/cli/__init__.py +5 -0
  22. agent_lifecycle/cli/adapter.py +360 -0
  23. agent_lifecycle/cli/dispatch.py +1220 -0
  24. agent_lifecycle/cli/followup.py +89 -0
  25. agent_lifecycle/cli/main.py +41 -0
  26. agent_lifecycle/cli/metrics_parser.py +49 -0
  27. agent_lifecycle/cli/parsers.py +618 -0
  28. agent_lifecycle/cli/policy.py +101 -0
  29. agent_lifecycle/cli/progress_hooks.py +117 -0
  30. agent_lifecycle/cli/worktree.py +74 -0
  31. agent_lifecycle/compiler/__init__.py +17 -0
  32. agent_lifecycle/compiler/small_model_packets.py +322 -0
  33. agent_lifecycle/compiler/task_packets.py +229 -0
  34. agent_lifecycle/context/__init__.py +20 -0
  35. agent_lifecycle/context/episode_retrieval.py +30 -0
  36. agent_lifecycle/context/external_memory.py +71 -0
  37. agent_lifecycle/context/profiles.py +104 -0
  38. agent_lifecycle/context/rendering.py +226 -0
  39. agent_lifecycle/contracts/__init__.py +25 -0
  40. agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
  41. agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
  42. agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
  43. agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
  44. agent_lifecycle/contracts/audit_schemas.py +133 -0
  45. agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
  46. agent_lifecycle/contracts/canonical.py +55 -0
  47. agent_lifecycle/contracts/compatibility.py +242 -0
  48. agent_lifecycle/contracts/context_model_schemas.py +265 -0
  49. agent_lifecycle/contracts/core_schemas.py +252 -0
  50. agent_lifecycle/contracts/cross_check_schemas.py +121 -0
  51. agent_lifecycle/contracts/errors.py +26 -0
  52. agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
  53. agent_lifecycle/contracts/host_capability_schemas.py +64 -0
  54. agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
  55. agent_lifecycle/contracts/metric_schemas.py +315 -0
  56. agent_lifecycle/contracts/paths.py +31 -0
  57. agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
  58. agent_lifecycle/contracts/policy_schemas.py +350 -0
  59. agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
  60. agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
  61. agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
  62. agent_lifecycle/contracts/release_contract_schemas.py +476 -0
  63. agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
  64. agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
  65. agent_lifecycle/contracts/review_quality_schemas.py +109 -0
  66. agent_lifecycle/contracts/runner_schemas.py +91 -0
  67. agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
  68. agent_lifecycle/contracts/sandbox_schemas.py +159 -0
  69. agent_lifecycle/contracts/schema_builders.py +23 -0
  70. agent_lifecycle/contracts/schemas.py +93 -0
  71. agent_lifecycle/contracts/status_goal_schemas.py +469 -0
  72. agent_lifecycle/contracts/task_template_schemas.py +96 -0
  73. agent_lifecycle/contracts/usage_export_schemas.py +68 -0
  74. agent_lifecycle/diagnostics/__init__.py +17 -0
  75. agent_lifecycle/diagnostics/bundles.py +124 -0
  76. agent_lifecycle/diagnostics/readiness.py +733 -0
  77. agent_lifecycle/evidence_index/__init__.py +39 -0
  78. agent_lifecycle/evidence_index/core.py +272 -0
  79. agent_lifecycle/evidence_index/episode_index.py +329 -0
  80. agent_lifecycle/evidence_index/external_context.py +276 -0
  81. agent_lifecycle/followup/__init__.py +23 -0
  82. agent_lifecycle/followup/records.py +404 -0
  83. agent_lifecycle/freeze/__init__.py +5 -0
  84. agent_lifecycle/freeze/locks.py +23 -0
  85. agent_lifecycle/goal/__init__.py +15 -0
  86. agent_lifecycle/goal/records.py +244 -0
  87. agent_lifecycle/goal/view.py +109 -0
  88. agent_lifecycle/host_protocol/__init__.py +65 -0
  89. agent_lifecycle/host_protocol/acp_capability.py +195 -0
  90. agent_lifecycle/host_protocol/capabilities.py +268 -0
  91. agent_lifecycle/host_protocol/contracts.py +214 -0
  92. agent_lifecycle/host_protocol/event_capture.py +390 -0
  93. agent_lifecycle/host_protocol/events.py +196 -0
  94. agent_lifecycle/host_protocol/inspection.py +194 -0
  95. agent_lifecycle/host_protocol/inspection_common.py +310 -0
  96. agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
  97. agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
  98. agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
  99. agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
  100. agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
  101. agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
  102. agent_lifecycle/host_protocol/receipts.py +58 -0
  103. agent_lifecycle/host_protocol/scaffold.py +329 -0
  104. agent_lifecycle/host_protocol/validation.py +229 -0
  105. agent_lifecycle/imports/__init__.py +76 -0
  106. agent_lifecycle/imports/agentskills_profile.py +54 -0
  107. agent_lifecycle/imports/bmad_profile.py +49 -0
  108. agent_lifecycle/imports/constitution_adr.py +91 -0
  109. agent_lifecycle/imports/dialect_profiles.py +121 -0
  110. agent_lifecycle/imports/external_agent.py +28 -0
  111. agent_lifecycle/imports/external_dialects.py +486 -0
  112. agent_lifecycle/imports/external_workflow.py +28 -0
  113. agent_lifecycle/imports/multi_markdown.py +111 -0
  114. agent_lifecycle/imports/openspec_profile.py +49 -0
  115. agent_lifecycle/imports/planning.py +536 -0
  116. agent_lifecycle/imports/spec_kit_profile.py +49 -0
  117. agent_lifecycle/imports/spec_kitty_profile.py +49 -0
  118. agent_lifecycle/metrics/__init__.py +60 -0
  119. agent_lifecycle/metrics/cost_collection.py +315 -0
  120. agent_lifecycle/metrics/costs.py +203 -0
  121. agent_lifecycle/metrics/outcome_index.py +330 -0
  122. agent_lifecycle/metrics/phase_resources.py +208 -0
  123. agent_lifecycle/metrics/recommendations.py +507 -0
  124. agent_lifecycle/metrics/regression_signals.py +64 -0
  125. agent_lifecycle/metrics/usage_export.py +390 -0
  126. agent_lifecycle/model_routing/__init__.py +19 -0
  127. agent_lifecycle/model_routing/profiles.py +323 -0
  128. agent_lifecycle/model_routing/receipts.py +140 -0
  129. agent_lifecycle/model_routing/resolver.py +566 -0
  130. agent_lifecycle/neutrality/__init__.py +12 -0
  131. agent_lifecycle/neutrality/__main__.py +6 -0
  132. agent_lifecycle/neutrality/authority.py +265 -0
  133. agent_lifecycle/neutrality/canonical.py +28 -0
  134. agent_lifecycle/neutrality/cli.py +294 -0
  135. agent_lifecycle/neutrality/ed25519.py +124 -0
  136. agent_lifecycle/neutrality/errors.py +89 -0
  137. agent_lifecycle/neutrality/gate.py +269 -0
  138. agent_lifecycle/neutrality/paths.py +92 -0
  139. agent_lifecycle/neutrality/policy.py +76 -0
  140. agent_lifecycle/neutrality/receipt.py +135 -0
  141. agent_lifecycle/neutrality/scanner.py +310 -0
  142. agent_lifecycle/neutrality/test_runner.py +116 -0
  143. agent_lifecycle/planning/__init__.py +47 -0
  144. agent_lifecycle/planning/acceptance_markdown.py +159 -0
  145. agent_lifecycle/planning/completeness.py +362 -0
  146. agent_lifecycle/planning/continuity.py +255 -0
  147. agent_lifecycle/planning/templates.py +294 -0
  148. agent_lifecycle/planning/tiering.py +87 -0
  149. agent_lifecycle/planning/validation.py +72 -0
  150. agent_lifecycle/policy/__init__.py +37 -0
  151. agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
  152. agent_lifecycle/policy/apply.py +44 -0
  153. agent_lifecycle/policy/proposals.py +189 -0
  154. agent_lifecycle/policy/quality_floor.py +144 -0
  155. agent_lifecycle/policy/runtime_receipts.py +160 -0
  156. agent_lifecycle/py.typed +1 -0
  157. agent_lifecycle/quality/__init__.py +89 -0
  158. agent_lifecycle/quality/bug_forensics.py +727 -0
  159. agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
  160. agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
  161. agent_lifecycle/quality/cross_check.py +437 -0
  162. agent_lifecycle/quality/failure_classification.py +318 -0
  163. agent_lifecycle/quality/packs.py +265 -0
  164. agent_lifecycle/reporting/__init__.py +37 -0
  165. agent_lifecycle/reporting/change_summary.py +156 -0
  166. agent_lifecycle/reporting/event_feed.py +101 -0
  167. agent_lifecycle/reporting/progress_bridge.py +153 -0
  168. agent_lifecycle/reporting/progress_hooks.py +161 -0
  169. agent_lifecycle/reporting/progress_terminal.py +95 -0
  170. agent_lifecycle/reporting/progress_view.py +284 -0
  171. agent_lifecycle/reporting/status_view.py +125 -0
  172. agent_lifecycle/reporting/usage_export.py +59 -0
  173. agent_lifecycle/review/__init__.py +5 -0
  174. agent_lifecycle/review/validation.py +29 -0
  175. agent_lifecycle/review_mesh/__init__.py +83 -0
  176. agent_lifecycle/review_mesh/assignments.py +160 -0
  177. agent_lifecycle/review_mesh/contracts.py +685 -0
  178. agent_lifecycle/review_mesh/operator_templates.py +320 -0
  179. agent_lifecycle/review_mesh/quorum.py +38 -0
  180. agent_lifecycle/review_mesh/recommendation.py +487 -0
  181. agent_lifecycle/review_mesh/results.py +98 -0
  182. agent_lifecycle/review_mesh/synthesis.py +102 -0
  183. agent_lifecycle/runner/__init__.py +47 -0
  184. agent_lifecycle/runner/attempt_snapshots.py +244 -0
  185. agent_lifecycle/runner/core.py +653 -0
  186. agent_lifecycle/runner/sandbox_receipts.py +628 -0
  187. agent_lifecycle/specification/__init__.py +23 -0
  188. agent_lifecycle/specification/completion_check.py +135 -0
  189. agent_lifecycle/specification/completion_gate.py +501 -0
  190. agent_lifecycle/specification/completion_signal.py +75 -0
  191. agent_lifecycle/specification/validation.py +48 -0
  192. agent_lifecycle/workflow/__init__.py +79 -0
  193. agent_lifecycle/workflow/artifacts.py +74 -0
  194. agent_lifecycle/workflow/budget_decisions.py +370 -0
  195. agent_lifecycle/workflow/budget_policy.py +140 -0
  196. agent_lifecycle/workflow/budget_receipts.py +70 -0
  197. agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
  198. agent_lifecycle/workflow/controller.py +45 -0
  199. agent_lifecycle/workflow/events.py +50 -0
  200. agent_lifecycle/workflow/final_proof_integrity.py +74 -0
  201. agent_lifecycle/workflow/finalization.py +422 -0
  202. agent_lifecycle/workflow/gates.py +185 -0
  203. agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
  204. agent_lifecycle/workflow/leases.py +220 -0
  205. agent_lifecycle/workflow/lineage.py +108 -0
  206. agent_lifecycle/workflow/managed_runner.py +167 -0
  207. agent_lifecycle/workflow/model_usage.py +106 -0
  208. agent_lifecycle/workflow/next_action.py +86 -0
  209. agent_lifecycle/workflow/operation_kernel.py +98 -0
  210. agent_lifecycle/workflow/plan_adoption.py +439 -0
  211. agent_lifecycle/workflow/query.py +52 -0
  212. agent_lifecycle/workflow/review_mesh_gate.py +93 -0
  213. agent_lifecycle/workflow/reviews.py +168 -0
  214. agent_lifecycle/workflow/run_transitions.py +163 -0
  215. agent_lifecycle/workflow/sandbox_policy.py +214 -0
  216. agent_lifecycle/workflow/selectors.py +57 -0
  217. agent_lifecycle/workflow/state.py +130 -0
  218. agent_lifecycle/workflow/task_transitions.py +421 -0
  219. agent_lifecycle/worktree/__init__.py +17 -0
  220. agent_lifecycle/worktree/isolation.py +407 -0
  221. agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
  222. agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
  223. agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
  224. agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
  225. agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
  226. agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
  227. agent_lifecycle_kit-1.50.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,390 @@
1
+ """Event capture receipts for neutral adapter event streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
9
+ from agent_lifecycle.host_protocol.events import validate_adapter_event_stream
10
+
11
+ EVENT_CAPTURE_OPERATION = "adapter-event-stream"
12
+ EVENT_CAPTURE_STATUS = "DECLARED"
13
+ EVENT_STREAM_RECEIPT_SCHEMA = "agent-adapter-event-stream-receipt.v1"
14
+ EVENT_CAPTURE_VALIDATION_SCHEMA = "agent-adapter-event-capture-validation.v1"
15
+ EVENT_CATEGORIES = (
16
+ "command",
17
+ "file-change",
18
+ "lifecycle-transition",
19
+ "model-usage",
20
+ "user-decision",
21
+ "validation",
22
+ )
23
+
24
+
25
+ def adapter_declares_event_capture(
26
+ *,
27
+ descriptor: dict[str, Any] | None = None,
28
+ projection: dict[str, Any] | None = None,
29
+ capability_manifest: dict[str, Any] | None = None,
30
+ ) -> bool:
31
+ """Return whether adapter metadata declares neutral event capture."""
32
+
33
+ return any(
34
+ (
35
+ _descriptor_declares(descriptor),
36
+ _projection_declares(projection),
37
+ _capability_manifest_declares(capability_manifest),
38
+ )
39
+ )
40
+
41
+
42
+ def event_capture_declaration() -> dict[str, Any]:
43
+ return {
44
+ "status": EVENT_CAPTURE_STATUS,
45
+ "portableEventSchema": "agent-adapter-event.v1",
46
+ "categories": list(EVENT_CATEGORIES),
47
+ "producerBoundary": "adapter-owned",
48
+ "promotionRequired": False,
49
+ }
50
+
51
+
52
+ def build_adapter_event_stream(
53
+ *,
54
+ host: str,
55
+ adapter_id: str,
56
+ run_id: str,
57
+ task_id: str,
58
+ operation_id: str,
59
+ command: str,
60
+ exit_code: int,
61
+ changed_files: list[str] | None = None,
62
+ usage: dict[str, Any] | None = None,
63
+ result_path: str | None = None,
64
+ blocker: str | None = None,
65
+ recorded_at: str = "2026-01-01T00:00:00Z",
66
+ ) -> list[dict[str, Any]]:
67
+ """Build a compact neutral stream from one bounded host operation."""
68
+
69
+ _required_string(host, "host")
70
+ _required_string(adapter_id, "adapterId")
71
+ _required_string(run_id, "runId")
72
+ _required_string(task_id, "taskId")
73
+ _required_string(operation_id, "operationId")
74
+ _required_string(command, "command")
75
+ if not isinstance(exit_code, int) or isinstance(exit_code, bool):
76
+ raise LifecycleError("invalid-adapter-event-producer-input", "exitCode must be an integer")
77
+ files = list(changed_files or [])
78
+ stream = [
79
+ _event(1, "session.started", "INFO", host, adapter_id, run_id, task_id, operation_id, recorded_at, {"category": "lifecycle-transition"}),
80
+ _event(2, "task.launched", "PASS", host, adapter_id, run_id, task_id, operation_id, recorded_at, {"category": "lifecycle-transition"}),
81
+ _event(
82
+ 3,
83
+ "command.completed",
84
+ "PASS" if exit_code == 0 else "FAIL",
85
+ host,
86
+ adapter_id,
87
+ run_id,
88
+ task_id,
89
+ operation_id,
90
+ recorded_at,
91
+ {"category": "command", "command": command, "exitCode": exit_code},
92
+ ),
93
+ _event(
94
+ 4,
95
+ "writes.summarized",
96
+ "PASS",
97
+ host,
98
+ adapter_id,
99
+ run_id,
100
+ task_id,
101
+ operation_id,
102
+ recorded_at,
103
+ {"category": "file-change", "changedFiles": files},
104
+ ),
105
+ ]
106
+ sequence = 5
107
+ if usage is not None:
108
+ if not isinstance(usage, dict):
109
+ raise LifecycleError("invalid-adapter-event-producer-input", "usage must be an object")
110
+ stream.append(
111
+ _event(
112
+ sequence,
113
+ "usage.reported",
114
+ "PASS",
115
+ host,
116
+ adapter_id,
117
+ run_id,
118
+ task_id,
119
+ operation_id,
120
+ recorded_at,
121
+ {"category": "model-usage", **usage},
122
+ )
123
+ )
124
+ sequence += 1
125
+ if exit_code == 0 and blocker is None:
126
+ _required_string(result_path, "resultPath")
127
+ stream.append(
128
+ _event(
129
+ sequence,
130
+ "task.completed",
131
+ "PASS",
132
+ host,
133
+ adapter_id,
134
+ run_id,
135
+ task_id,
136
+ operation_id,
137
+ recorded_at,
138
+ {"category": "lifecycle-transition", "resultPath": result_path},
139
+ )
140
+ )
141
+ else:
142
+ stream.append(
143
+ _event(
144
+ sequence,
145
+ "task.blocked",
146
+ "BLOCKED",
147
+ host,
148
+ adapter_id,
149
+ run_id,
150
+ task_id,
151
+ operation_id,
152
+ recorded_at,
153
+ {"category": "lifecycle-transition", "blocker": blocker or f"command exited {exit_code}"},
154
+ )
155
+ )
156
+ return stream
157
+
158
+
159
+ def build_event_stream_receipt(
160
+ events: list[dict[str, Any]],
161
+ *,
162
+ descriptor: dict[str, Any],
163
+ producer_id: str,
164
+ emitted_at: str | None = None,
165
+ ) -> dict[str, Any]:
166
+ validation = validate_adapter_event_stream(events)
167
+ if validation["status"] != "PASS":
168
+ raise LifecycleError("adapter-event-validation-failed", "adapter event stream validation failed", {"validation": validation})
169
+ _required_string(producer_id, "producer.id")
170
+ host = _required_string(descriptor.get("host"), "descriptor.host")
171
+ adapter_id = _required_string(descriptor.get("adapterId"), "descriptor.adapterId")
172
+ if validation["host"] != host or validation["adapterId"] != adapter_id:
173
+ raise LifecycleError("adapter-event-receipt-lineage-mismatch", "event stream does not match adapter descriptor")
174
+ body = {
175
+ "schemaVersion": EVENT_STREAM_RECEIPT_SCHEMA,
176
+ "status": "PASS",
177
+ "adapterId": adapter_id,
178
+ "host": host,
179
+ "runId": validation["runId"],
180
+ "taskId": validation["taskId"],
181
+ "operationId": events[0]["operationId"],
182
+ "producer": {
183
+ "id": producer_id,
184
+ "boundary": "adapter-owned",
185
+ "portableEventSchema": "agent-adapter-event.v1",
186
+ },
187
+ "descriptorDigest": canonical_digest(descriptor),
188
+ "eventStreamDigest": canonical_digest(events),
189
+ "eventCount": validation["eventCount"],
190
+ "eventTypes": validation["eventTypes"],
191
+ "terminalEvent": validation["terminalEvent"],
192
+ "emittedAt": emitted_at or _now_iso(),
193
+ "productionPromotionClaimed": False,
194
+ }
195
+ return {**body, "receiptDigest": canonical_digest(body)}
196
+
197
+
198
+ def validate_event_capture_receipt(
199
+ receipt: dict[str, Any],
200
+ events: list[dict[str, Any]],
201
+ *,
202
+ descriptor: dict[str, Any] | None = None,
203
+ ) -> dict[str, Any]:
204
+ blockers: list[dict[str, Any]] = []
205
+ _validate_receipt_shape(receipt, blockers)
206
+ stream_validation = validate_adapter_event_stream(events)
207
+ if stream_validation["status"] == "FAIL":
208
+ blockers.extend(_tag_blockers(stream_validation["blockers"], "event-stream"))
209
+ if receipt.get("eventStreamDigest") != canonical_digest(events):
210
+ blockers.append({"code": "adapter-event-stream-stale", "message": "event stream digest does not match receipt"})
211
+ stored_digest = receipt.get("receiptDigest")
212
+ if stored_digest is not None and stored_digest != canonical_digest({key: value for key, value in receipt.items() if key != "receiptDigest"}):
213
+ blockers.append({"code": "adapter-event-receipt-digest-mismatch", "message": "receiptDigest does not match receipt"})
214
+ if receipt.get("eventCount") != len(events):
215
+ blockers.append({"code": "adapter-event-count-mismatch", "message": "event count does not match receipt"})
216
+ if events:
217
+ first = events[0]
218
+ for key in ("adapterId", "host", "runId", "taskId", "operationId"):
219
+ if receipt.get(key) != first.get(key):
220
+ blockers.append({"code": "adapter-event-receipt-lineage-mismatch", "field": key})
221
+ if descriptor is not None:
222
+ if receipt.get("descriptorDigest") != canonical_digest(descriptor):
223
+ blockers.append({"code": "adapter-event-descriptor-stale", "message": "descriptor digest does not match receipt"})
224
+ for key in ("adapterId", "host"):
225
+ if receipt.get(key) != descriptor.get(key):
226
+ blockers.append({"code": "adapter-event-receipt-descriptor-mismatch", "field": key})
227
+ status = "PASS" if not blockers else "FAIL"
228
+ body = {
229
+ "schemaVersion": EVENT_CAPTURE_VALIDATION_SCHEMA,
230
+ "status": status,
231
+ "adapterId": receipt.get("adapterId"),
232
+ "host": receipt.get("host"),
233
+ "runId": receipt.get("runId"),
234
+ "taskId": receipt.get("taskId"),
235
+ "operationId": receipt.get("operationId"),
236
+ "declaredEventCapture": True,
237
+ "eventCount": len(events),
238
+ "streamValidation": stream_validation,
239
+ "blockers": blockers,
240
+ "productionPromotionClaimed": False,
241
+ }
242
+ return {**body, "validationDigest": canonical_digest(body)}
243
+
244
+
245
+ def validate_event_capture_conformance(
246
+ *,
247
+ descriptor: dict[str, Any],
248
+ events: list[dict[str, Any]] | None = None,
249
+ receipt: dict[str, Any] | None = None,
250
+ projection: dict[str, Any] | None = None,
251
+ capability_manifest: dict[str, Any] | None = None,
252
+ ) -> dict[str, Any]:
253
+ declared = adapter_declares_event_capture(
254
+ descriptor=descriptor,
255
+ projection=projection,
256
+ capability_manifest=capability_manifest,
257
+ )
258
+ blockers: list[dict[str, Any]] = []
259
+ receipt_validation: dict[str, Any] | None = None
260
+ if declared and events is None:
261
+ blockers.append({"code": "adapter-event-capture-stream-missing", "message": "declared event capture requires an event stream fixture"})
262
+ if declared and receipt is None:
263
+ blockers.append({"code": "adapter-event-capture-receipt-missing", "message": "declared event capture requires an event stream receipt"})
264
+ if declared and events is not None and receipt is not None:
265
+ receipt_validation = validate_event_capture_receipt(receipt, events, descriptor=descriptor)
266
+ if receipt_validation["status"] == "FAIL":
267
+ blockers.extend(_tag_blockers(receipt_validation["blockers"], "event-capture-receipt"))
268
+ body = {
269
+ "schemaVersion": EVENT_CAPTURE_VALIDATION_SCHEMA,
270
+ "status": "PASS" if not blockers else "FAIL",
271
+ "adapterId": descriptor.get("adapterId"),
272
+ "host": descriptor.get("host"),
273
+ "declaredEventCapture": declared,
274
+ "eventCount": len(events or []),
275
+ "receiptValidation": receipt_validation,
276
+ "blockers": blockers,
277
+ "productionPromotionClaimed": False,
278
+ }
279
+ return {**body, "validationDigest": canonical_digest(body)}
280
+
281
+
282
+ def require_event_capture_pass(payload: dict[str, Any]) -> dict[str, Any]:
283
+ if payload.get("status") == "FAIL":
284
+ raise LifecycleError("adapter-event-capture-validation-failed", "adapter event capture validation failed", {"validation": payload})
285
+ return payload
286
+
287
+
288
+ def _event(
289
+ sequence: int,
290
+ event_type: str,
291
+ status: str,
292
+ host: str,
293
+ adapter_id: str,
294
+ run_id: str,
295
+ task_id: str,
296
+ operation_id: str,
297
+ recorded_at: str,
298
+ payload: dict[str, Any],
299
+ ) -> dict[str, Any]:
300
+ return {
301
+ "schemaVersion": "agent-adapter-event.v1",
302
+ "eventId": f"{operation_id}-{sequence:02d}",
303
+ "host": host,
304
+ "adapterId": adapter_id,
305
+ "runId": run_id,
306
+ "taskId": task_id,
307
+ "operationId": operation_id,
308
+ "sequence": sequence,
309
+ "eventType": event_type,
310
+ "status": status,
311
+ "recordedAt": recorded_at,
312
+ "payload": payload,
313
+ }
314
+
315
+
316
+ def _descriptor_declares(descriptor: dict[str, Any] | None) -> bool:
317
+ if not isinstance(descriptor, dict):
318
+ return False
319
+ capture = descriptor.get("eventCapture")
320
+ if isinstance(capture, dict) and capture.get("status") == EVENT_CAPTURE_STATUS:
321
+ return True
322
+ operations = descriptor.get("operations")
323
+ return isinstance(operations, list) and any(isinstance(item, dict) and item.get("name") == EVENT_CAPTURE_OPERATION for item in operations)
324
+
325
+
326
+ def _projection_declares(projection: dict[str, Any] | None) -> bool:
327
+ if not isinstance(projection, dict):
328
+ return False
329
+ bridge = projection.get("eventBridge")
330
+ return isinstance(bridge, dict) and bridge.get("portableEventSchema") == "agent-adapter-event.v1"
331
+
332
+
333
+ def _capability_manifest_declares(capability_manifest: dict[str, Any] | None) -> bool:
334
+ if not isinstance(capability_manifest, dict):
335
+ return False
336
+ capture = capability_manifest.get("eventCapture")
337
+ if isinstance(capture, dict) and capture.get("status") == EVENT_CAPTURE_STATUS:
338
+ return True
339
+ capabilities = capability_manifest.get("capabilities")
340
+ return isinstance(capabilities, list) and any(isinstance(item, dict) and item.get("name") == EVENT_CAPTURE_OPERATION for item in capabilities)
341
+
342
+
343
+ def _validate_receipt_shape(receipt: dict[str, Any], blockers: list[dict[str, Any]]) -> None:
344
+ if not isinstance(receipt, dict):
345
+ blockers.append({"code": "invalid-adapter-event-receipt", "message": "receipt must be an object"})
346
+ return
347
+ required = {
348
+ "schemaVersion",
349
+ "status",
350
+ "adapterId",
351
+ "host",
352
+ "runId",
353
+ "taskId",
354
+ "operationId",
355
+ "producer",
356
+ "descriptorDigest",
357
+ "eventStreamDigest",
358
+ "eventCount",
359
+ "eventTypes",
360
+ "terminalEvent",
361
+ "emittedAt",
362
+ "productionPromotionClaimed",
363
+ }
364
+ missing = sorted(required.difference(receipt))
365
+ if missing:
366
+ blockers.append({"code": "invalid-adapter-event-receipt", "message": "required receipt fields are missing", "fields": missing})
367
+ if receipt.get("schemaVersion") != EVENT_STREAM_RECEIPT_SCHEMA:
368
+ blockers.append({"code": "invalid-adapter-event-receipt-schema", "message": "unsupported event stream receipt schemaVersion"})
369
+ if receipt.get("status") != "PASS":
370
+ blockers.append({"code": "adapter-event-receipt-not-pass", "message": "event stream receipt must be PASS"})
371
+ if receipt.get("productionPromotionClaimed") is not False:
372
+ blockers.append({"code": "adapter-event-receipt-production-claim", "message": "event stream receipt must not claim production promotion"})
373
+ if not isinstance(receipt.get("eventCount"), int) or isinstance(receipt.get("eventCount"), bool):
374
+ blockers.append({"code": "invalid-adapter-event-receipt", "message": "eventCount must be an integer"})
375
+ if not isinstance(receipt.get("eventTypes"), list):
376
+ blockers.append({"code": "invalid-adapter-event-receipt", "message": "eventTypes must be an array"})
377
+
378
+
379
+ def _tag_blockers(blockers: list[dict[str, Any]], source: str) -> list[dict[str, Any]]:
380
+ return [{**item, "source": source} for item in blockers if isinstance(item, dict)]
381
+
382
+
383
+ def _required_string(value: Any, label: str) -> str:
384
+ if not isinstance(value, str) or not value:
385
+ raise LifecycleError("invalid-adapter-event-producer-input", f"{label} is required")
386
+ return value
387
+
388
+
389
+ def _now_iso() -> str:
390
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,196 @@
1
+ """Validate neutral adapter event streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from agent_lifecycle.contracts import LifecycleError
9
+ from agent_lifecycle.host_protocol.contracts import HostAdapterEvent
10
+
11
+ TERMINAL_EVENTS = {"task.blocked", "task.completed"}
12
+ REQUIRED_COMMON_EVENTS = {"session.started", "task.launched"}
13
+ REQUIRED_COMPLETED_EVENTS = {"command.completed", "writes.summarized", "task.completed"}
14
+ EVENT_CATEGORIES = {"command", "file-change", "lifecycle-transition", "model-usage", "user-decision", "validation"}
15
+
16
+
17
+ def validate_adapter_event_stream(events: list[dict[str, Any]]) -> dict[str, Any]:
18
+ """Validate one host adapter stream without executing host-specific code."""
19
+
20
+ blockers: list[dict[str, Any]] = []
21
+ parsed: list[HostAdapterEvent] = []
22
+ if not events:
23
+ blockers.append({"code": "adapter-event-stream-empty", "message": "adapter event stream is empty"})
24
+ for index, event in enumerate(events):
25
+ if not isinstance(event, dict):
26
+ blockers.append({"code": "invalid-adapter-event", "index": index, "message": "event must be an object"})
27
+ continue
28
+ try:
29
+ parsed.append(HostAdapterEvent.from_json(event))
30
+ except LifecycleError as error:
31
+ blockers.append({"code": error.code, "index": index, "message": error.message})
32
+ if parsed:
33
+ _validate_sequence(parsed, blockers)
34
+ _validate_lineage(parsed, blockers)
35
+ _validate_required_events(parsed, blockers)
36
+ _validate_event_statuses(parsed, blockers)
37
+ _validate_event_payloads(parsed, blockers)
38
+ _validate_time_order(parsed, blockers)
39
+ status = "PASS" if not blockers else "FAIL"
40
+ return {
41
+ "schemaVersion": "agent-adapter-event-stream-validation.v1",
42
+ "status": status,
43
+ "host": parsed[0].host if parsed else None,
44
+ "adapterId": parsed[0].adapter_id if parsed else None,
45
+ "runId": parsed[0].run_id if parsed else None,
46
+ "taskId": parsed[0].task_id if parsed else None,
47
+ "eventCount": len(parsed),
48
+ "eventTypes": [event.event_type for event in parsed],
49
+ "terminalEvent": _terminal_event_type(parsed),
50
+ "blockers": blockers,
51
+ }
52
+
53
+
54
+ def require_adapter_event_stream_pass(payload: dict[str, Any]) -> dict[str, Any]:
55
+ if payload.get("status") == "FAIL":
56
+ raise LifecycleError("adapter-event-validation-failed", "adapter event stream validation failed", {"validation": payload})
57
+ return payload
58
+
59
+
60
+ def _validate_sequence(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
61
+ expected = list(range(1, len(events) + 1))
62
+ actual = [event.sequence for event in events]
63
+ if actual != expected:
64
+ blockers.append({"code": "adapter-event-sequence-gap", "expected": expected, "actual": actual})
65
+ event_ids = [event.event_id for event in events]
66
+ duplicates = sorted({event_id for event_id in event_ids if event_ids.count(event_id) > 1})
67
+ if duplicates:
68
+ blockers.append({"code": "adapter-event-duplicate-id", "eventIds": duplicates})
69
+
70
+
71
+ def _validate_lineage(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
72
+ first = events[0]
73
+ for event in events[1:]:
74
+ drift = {
75
+ key: (expected, actual)
76
+ for key, expected, actual in [
77
+ ("host", first.host, event.host),
78
+ ("adapterId", first.adapter_id, event.adapter_id),
79
+ ("runId", first.run_id, event.run_id),
80
+ ("taskId", first.task_id, event.task_id),
81
+ ]
82
+ if expected != actual
83
+ }
84
+ if drift:
85
+ blockers.append({"code": "adapter-event-lineage-mismatch", "eventId": event.event_id, "drift": drift})
86
+
87
+
88
+ def _validate_required_events(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
89
+ event_types = [event.event_type for event in events]
90
+ provided = set(event_types)
91
+ missing_common = sorted(REQUIRED_COMMON_EVENTS - provided)
92
+ if missing_common:
93
+ blockers.append({"code": "adapter-event-required-missing", "eventTypes": missing_common})
94
+ terminals = [event.event_type for event in events if event.event_type in TERMINAL_EVENTS]
95
+ if len(terminals) != 1:
96
+ blockers.append({"code": "adapter-event-terminal-count", "terminalEvents": terminals})
97
+ return
98
+ if terminals[0] == "task.completed":
99
+ missing_completed = sorted(REQUIRED_COMPLETED_EVENTS - provided)
100
+ if missing_completed:
101
+ blockers.append({"code": "adapter-event-required-missing", "eventTypes": missing_completed})
102
+ terminal_index = event_types.index(terminals[0])
103
+ if terminal_index != len(event_types) - 1:
104
+ blockers.append({"code": "adapter-event-after-terminal", "terminalEvent": terminals[0]})
105
+
106
+
107
+ def _validate_event_statuses(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
108
+ expected_by_type = {
109
+ "session.started": {"INFO"},
110
+ "task.launched": {"INFO", "PASS"},
111
+ "usage.reported": {"INFO", "PASS"},
112
+ "writes.summarized": {"INFO", "PASS"},
113
+ "task.blocked": {"BLOCKED"},
114
+ "task.completed": {"PASS"},
115
+ }
116
+ for event in events:
117
+ expected = expected_by_type.get(event.event_type)
118
+ if expected is not None and event.status not in expected:
119
+ blockers.append(
120
+ {
121
+ "code": "adapter-event-status-mismatch",
122
+ "eventId": event.event_id,
123
+ "eventType": event.event_type,
124
+ "expected": sorted(expected),
125
+ "actual": event.status,
126
+ }
127
+ )
128
+ if event.event_type == "command.completed" and event.status not in {"PASS", "FAIL"}:
129
+ blockers.append(
130
+ {
131
+ "code": "adapter-event-status-mismatch",
132
+ "eventId": event.event_id,
133
+ "eventType": event.event_type,
134
+ "expected": ["FAIL", "PASS"],
135
+ "actual": event.status,
136
+ }
137
+ )
138
+
139
+
140
+ def _validate_event_payloads(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
141
+ command_failures: list[str] = []
142
+ for event in events:
143
+ category = event.payload.get("category")
144
+ if category is not None and category not in EVENT_CATEGORIES:
145
+ blockers.append(
146
+ {
147
+ "code": "adapter-event-category-unsupported",
148
+ "eventId": event.event_id,
149
+ "category": category,
150
+ }
151
+ )
152
+ if event.event_type == "command.completed":
153
+ command = event.payload.get("command")
154
+ exit_code = event.payload.get("exitCode")
155
+ if not isinstance(command, str) or not command:
156
+ blockers.append({"code": "adapter-event-command-missing", "eventId": event.event_id})
157
+ if not isinstance(exit_code, int) or isinstance(exit_code, bool):
158
+ blockers.append({"code": "adapter-event-exit-code-missing", "eventId": event.event_id})
159
+ continue
160
+ if event.status == "PASS" and exit_code != 0:
161
+ blockers.append({"code": "adapter-event-command-status-mismatch", "eventId": event.event_id, "exitCode": exit_code})
162
+ if event.status == "FAIL" and exit_code == 0:
163
+ blockers.append({"code": "adapter-event-command-status-mismatch", "eventId": event.event_id, "exitCode": exit_code})
164
+ if exit_code != 0 or event.status == "FAIL":
165
+ command_failures.append(event.event_id)
166
+ if event.event_type == "writes.summarized" and not isinstance(event.payload.get("changedFiles"), list):
167
+ blockers.append({"code": "adapter-event-writes-missing", "eventId": event.event_id})
168
+ if event.event_type == "task.blocked":
169
+ blocker = event.payload.get("blocker")
170
+ if not isinstance(blocker, str) or not blocker:
171
+ blockers.append({"code": "adapter-event-blocker-missing", "eventId": event.event_id})
172
+ if event.event_type == "task.completed":
173
+ result_path = event.payload.get("resultPath")
174
+ if not isinstance(result_path, str) or not result_path:
175
+ blockers.append({"code": "adapter-event-result-missing", "eventId": event.event_id})
176
+ terminal = _terminal_event_type(events)
177
+ if terminal == "task.completed" and command_failures:
178
+ blockers.append({"code": "adapter-event-failed-command-completed", "eventIds": command_failures})
179
+
180
+
181
+ def _validate_time_order(events: list[HostAdapterEvent], blockers: list[dict[str, Any]]) -> None:
182
+ previous: datetime | None = None
183
+ for event in events:
184
+ try:
185
+ recorded_at = datetime.fromisoformat(event.recorded_at.replace("Z", "+00:00"))
186
+ except ValueError:
187
+ blockers.append({"code": "adapter-event-recorded-at-invalid", "eventId": event.event_id})
188
+ continue
189
+ if previous is not None and recorded_at < previous:
190
+ blockers.append({"code": "adapter-event-recorded-at-regressed", "eventId": event.event_id})
191
+ previous = recorded_at
192
+
193
+
194
+ def _terminal_event_type(events: list[HostAdapterEvent]) -> str | None:
195
+ terminals = [event.event_type for event in events if event.event_type in TERMINAL_EVENTS]
196
+ return terminals[0] if len(terminals) == 1 else None