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,507 @@
1
+ """Advisory lifecycle mode recommendations from cost reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest
8
+ from agent_lifecycle.metrics.outcome_index import QUALITY_COST_SIGNALS_SCHEMA
9
+ from agent_lifecycle.metrics.costs import (
10
+ COST_CATEGORIES,
11
+ DEFAULT_MODE_LIMITS,
12
+ cost_ratios,
13
+ summarize_usage_confidence,
14
+ validate_lifecycle_cost_report,
15
+ )
16
+
17
+ BASELINES_SCHEMA = "agent-lifecycle-baselines.v1"
18
+ BASELINE_VALIDATION_SCHEMA = "agent-lifecycle-baselines-validation.v1"
19
+ STATISTICS_SCHEMA = "agent-lifecycle-overhead-statistics.v1"
20
+ RECOMMENDATION_SCHEMA = "agent-lifecycle-recommendation.v1"
21
+ RECOMMENDATION_SUMMARY_SCHEMA = "agent-lifecycle-recommendation-summary.v1"
22
+ MODE_ORDER = tuple(DEFAULT_MODE_LIMITS)
23
+
24
+
25
+ def validate_lifecycle_baselines(profile: dict[str, Any]) -> dict[str, Any]:
26
+ blockers: list[dict[str, Any]] = []
27
+ if profile.get("schemaVersion") != BASELINES_SCHEMA:
28
+ blockers.append({"code": "baseline-schema", "message": "unsupported lifecycle baseline schemaVersion"})
29
+ if profile.get("productionPromotionClaimed") is not False:
30
+ blockers.append({"code": "baseline-production-claim", "message": "baseline profile must not claim production promotion"})
31
+ minimum_reports = profile.get("minimumReportsForConfidence")
32
+ if not isinstance(minimum_reports, int) or isinstance(minimum_reports, bool) or minimum_reports < 1:
33
+ blockers.append({"code": "baseline-minimum-reports", "message": "minimumReportsForConfidence must be a positive integer"})
34
+ mode_order = profile.get("modeOrder")
35
+ if "modeOrder" in profile and (not isinstance(mode_order, list) or tuple(mode_order) != MODE_ORDER):
36
+ blockers.append({"code": "baseline-mode-order", "message": "modeOrder must match lifecycle modes"})
37
+ risk_floors = profile.get("riskFloors")
38
+ if not isinstance(risk_floors, dict):
39
+ blockers.append({"code": "baseline-risk-floors", "message": "riskFloors must be an object"})
40
+ risk_floors = {}
41
+ for risk, mode in risk_floors.items():
42
+ if not isinstance(risk, str) or mode not in MODE_ORDER:
43
+ blockers.append({"code": "baseline-risk-floor-mode", "risk": risk, "mode": mode})
44
+ task_shapes = profile.get("taskShapes")
45
+ if not isinstance(task_shapes, dict) or not task_shapes:
46
+ blockers.append({"code": "baseline-task-shapes", "message": "taskShapes must be a non-empty object"})
47
+ task_shapes = {}
48
+ for shape, config in task_shapes.items():
49
+ _validate_shape(shape, config, blockers)
50
+ body = {
51
+ "schemaVersion": BASELINE_VALIDATION_SCHEMA,
52
+ "status": "PASS" if not blockers else "FAIL",
53
+ "taskShapeCount": len(task_shapes),
54
+ "blockers": blockers,
55
+ "profileDigest": canonical_digest(profile),
56
+ }
57
+ return {**body, "validationDigest": canonical_digest(body)}
58
+
59
+
60
+ def require_lifecycle_recommendation_pass(recommendation: dict[str, Any]) -> dict[str, Any]:
61
+ if recommendation.get("status") == "FAIL":
62
+ raise LifecycleError(
63
+ "lifecycle-recommendation-failed",
64
+ "lifecycle recommendation failed",
65
+ {"recommendation": recommendation},
66
+ )
67
+ return recommendation
68
+
69
+
70
+ def summarize_lifecycle_overhead(reports: list[dict[str, Any]]) -> dict[str, Any]:
71
+ totals = _empty_totals()
72
+ usage_entries: list[dict[str, Any]] = []
73
+ report_digests: list[str] = []
74
+ blockers: list[dict[str, Any]] = []
75
+ for index, report in enumerate(reports):
76
+ validation = validate_lifecycle_cost_report(report)
77
+ if validation["status"] != "PASS":
78
+ blockers.append({"code": "cost-report-invalid", "index": index, "validation": validation})
79
+ report_digests.append(validation["reportDigest"])
80
+ for category in COST_CATEGORIES:
81
+ totals[category]["tokens"] += validation["totals"][category]["tokens"]
82
+ totals[category]["steps"] += validation["totals"][category]["steps"]
83
+ entries = report.get("entries")
84
+ if isinstance(entries, list):
85
+ usage_entries.extend(item for item in entries if isinstance(item, dict))
86
+ totals["overall"] = {
87
+ "tokens": sum(totals[category]["tokens"] for category in COST_CATEGORIES),
88
+ "steps": sum(totals[category]["steps"] for category in COST_CATEGORIES),
89
+ }
90
+ ratios = cost_ratios(totals)
91
+ body = {
92
+ "schemaVersion": STATISTICS_SCHEMA,
93
+ "status": "PASS" if not blockers else "FAIL",
94
+ "reportCount": len(reports),
95
+ "reportDigests": sorted(report_digests),
96
+ "totals": totals,
97
+ "ratios": ratios,
98
+ "usageConfidence": summarize_usage_confidence(usage_entries),
99
+ "averageProductValidationSteps": _average(totals["productValidation"]["steps"], len(reports)),
100
+ "blockers": blockers,
101
+ }
102
+ return {**body, "statisticsDigest": canonical_digest(body)}
103
+
104
+
105
+ def recommend_lifecycle_mode(
106
+ *,
107
+ reports: list[dict[str, Any]],
108
+ baseline_profile: dict[str, Any],
109
+ task_shape: str = "feature",
110
+ current_mode: str | None = None,
111
+ sdd_tier: str | None = None,
112
+ risk_flags: list[str] | None = None,
113
+ ) -> dict[str, Any]:
114
+ baseline_validation = validate_lifecycle_baselines(baseline_profile)
115
+ stats = summarize_lifecycle_overhead(reports)
116
+ blockers = list(baseline_validation["blockers"])
117
+ if stats["status"] != "PASS":
118
+ blockers.extend(stats["blockers"])
119
+ shape = _task_shape(baseline_profile, task_shape, blockers)
120
+ if baseline_validation["status"] != "PASS" or stats["status"] != "PASS" or shape is None:
121
+ body = _failed_recommendation(task_shape, current_mode, baseline_validation, stats, blockers)
122
+ return {**body, "recommendationDigest": canonical_digest(body)}
123
+
124
+ floor = _quality_floor_mode(
125
+ task_shape=task_shape,
126
+ baseline_profile=baseline_profile,
127
+ sdd_tier=sdd_tier,
128
+ risk_flags=risk_flags or [],
129
+ )
130
+ warnings = _warnings(stats, shape, baseline_profile)
131
+ weak_data = _weak_data(stats, baseline_profile)
132
+ confidence = _confidence(stats, weak_data=weak_data, warning_count=len(warnings))
133
+ recommended = _recommended_mode(shape, floor=floor, current_mode=current_mode, confidence=confidence, warnings=warnings)
134
+ body = {
135
+ "schemaVersion": RECOMMENDATION_SCHEMA,
136
+ "status": "PASS",
137
+ "taskShape": task_shape,
138
+ "currentMode": current_mode,
139
+ "recommendedMode": recommended,
140
+ "confidence": confidence,
141
+ "advisoryOnly": True,
142
+ "autoApply": False,
143
+ "qualityFloor": floor,
144
+ "qualityFloorPreserved": _mode_index(recommended) >= _mode_index(floor),
145
+ "warnings": warnings,
146
+ "reasons": _reasons(confidence, recommended, floor, weak_data, warnings),
147
+ "statistics": stats,
148
+ "baselineValidation": baseline_validation,
149
+ "productionPromotionClaimed": False,
150
+ }
151
+ body["compactSummary"] = build_lifecycle_recommendation_summary(body)
152
+ return {**body, "recommendationDigest": canonical_digest(body)}
153
+
154
+
155
+ def recommend_from_quality_cost_signals(
156
+ *,
157
+ signals: dict[str, Any],
158
+ baseline_profile: dict[str, Any],
159
+ task_shape: str = "feature",
160
+ current_mode: str | None = None,
161
+ sdd_tier: str | None = None,
162
+ risk_flags: list[str] | None = None,
163
+ ) -> dict[str, Any]:
164
+ """Recommend a lifecycle mode from local outcome-index signals."""
165
+
166
+ baseline_validation = validate_lifecycle_baselines(baseline_profile)
167
+ blockers = list(baseline_validation["blockers"])
168
+ if not isinstance(signals, dict) or signals.get("schemaVersion") != QUALITY_COST_SIGNALS_SCHEMA:
169
+ blockers.append({"code": "quality-cost-signals-schema"})
170
+ signal_rows: list[dict[str, Any]] = []
171
+ else:
172
+ signal_rows = [item for item in signals.get("signals", []) if isinstance(item, dict)]
173
+ if signals.get("status") != "PASS":
174
+ blockers.extend(signals.get("blockers", []))
175
+ blockers.extend(_unsafe_quality_cost_signal_blockers(signals))
176
+ shape = _task_shape(baseline_profile, task_shape, blockers)
177
+ if baseline_validation["status"] != "PASS" or shape is None or blockers:
178
+ body = _failed_recommendation(task_shape, current_mode, baseline_validation, {}, blockers)
179
+ body["qualityCostSignals"] = signals if isinstance(signals, dict) else {}
180
+ return {**body, "recommendationDigest": canonical_digest(body)}
181
+
182
+ floor = _quality_floor_mode(
183
+ task_shape=task_shape,
184
+ baseline_profile=baseline_profile,
185
+ sdd_tier=sdd_tier,
186
+ risk_flags=risk_flags or [],
187
+ )
188
+ relevant = [item for item in signal_rows if item.get("taskShape") == task_shape]
189
+ best = _best_quality_cost_signal(relevant)
190
+ warnings = _quality_cost_warnings(best, signals=signals, profile=baseline_profile)
191
+ confidence = _quality_cost_confidence(best, baseline_profile, warning_count=len(warnings))
192
+ current = current_mode if current_mode in MODE_ORDER else None
193
+ candidate = str(best.get("lifecycleMode")) if best and best.get("lifecycleMode") in MODE_ORDER else str(shape["defaultMode"])
194
+ if confidence == "LOW":
195
+ candidate = current or str(shape["defaultMode"])
196
+ recommended = _max_mode(candidate, floor)
197
+ body = {
198
+ "schemaVersion": RECOMMENDATION_SCHEMA,
199
+ "status": "PASS",
200
+ "taskShape": task_shape,
201
+ "currentMode": current_mode,
202
+ "recommendedMode": recommended,
203
+ "confidence": confidence,
204
+ "advisoryOnly": True,
205
+ "autoApply": False,
206
+ "qualityFloor": floor,
207
+ "qualityFloorPreserved": _mode_index(recommended) >= _mode_index(floor),
208
+ "warnings": warnings,
209
+ "reasons": _quality_cost_reasons(confidence, recommended, floor, best, warnings),
210
+ "statistics": {
211
+ "schemaVersion": "agent-quality-cost-learning-statistics.v1",
212
+ "status": signals.get("status"),
213
+ "outcomeIndexDigest": signals.get("outcomeIndexDigest"),
214
+ "signalsDigest": signals.get("signalsDigest") or canonical_digest(signals),
215
+ "groupCount": signals.get("groupCount", 0),
216
+ "taskCount": signals.get("taskCount", 0),
217
+ "selectedSignal": best,
218
+ },
219
+ "baselineValidation": baseline_validation,
220
+ "qualityCostSignals": {
221
+ "signalsDigest": signals.get("signalsDigest") or canonical_digest(signals),
222
+ "outcomeIndexDigest": signals.get("outcomeIndexDigest"),
223
+ "selectedSignal": best,
224
+ },
225
+ "rollback": {
226
+ "strategy": "discard recommendation and keep prior lifecycle routing policy",
227
+ "restore": [{"path": f"taskShapes.{task_shape}.defaultMode", "value": current_mode}],
228
+ "requiresReview": True,
229
+ },
230
+ "productionPromotionClaimed": False,
231
+ }
232
+ body["compactSummary"] = build_lifecycle_recommendation_summary(body)
233
+ return {**body, "recommendationDigest": canonical_digest(body)}
234
+
235
+
236
+ def build_lifecycle_recommendation_summary(recommendation: dict[str, Any]) -> dict[str, Any]:
237
+ stats = recommendation.get("statistics") if isinstance(recommendation.get("statistics"), dict) else {}
238
+ ratios = stats.get("ratios") if isinstance(stats.get("ratios"), dict) else {}
239
+ warnings = recommendation.get("warnings") if isinstance(recommendation.get("warnings"), list) else []
240
+ return {
241
+ "schemaVersion": RECOMMENDATION_SUMMARY_SCHEMA,
242
+ "latestUserIntent": "Choose the lightest lifecycle mode that preserves the required quality floor.",
243
+ "activeDecisions": [
244
+ f"taskShape={recommendation.get('taskShape')}",
245
+ f"recommendedMode={recommendation.get('recommendedMode')}",
246
+ f"confidence={recommendation.get('confidence')}",
247
+ f"pipelineTokenShare={ratios.get('pipelineTokenShare', 0.0)}",
248
+ ],
249
+ "openBlockers": [],
250
+ "acceptedEvidence": [
251
+ {
252
+ "id": "lifecycle-recommendation",
253
+ "status": recommendation.get("status"),
254
+ "warningCount": len(warnings),
255
+ }
256
+ ],
257
+ "changedFiles": [],
258
+ "nextRequiredAction": "review advisory recommendation before changing lifecycle mode",
259
+ "doNotDo": [
260
+ "Do not auto-apply recommendations.",
261
+ "Do not lower required validation for high-risk task classes.",
262
+ ],
263
+ "recommendedMode": recommendation.get("recommendedMode"),
264
+ "confidence": recommendation.get("confidence"),
265
+ "warnings": warnings[:8],
266
+ "qualityFloorPreserved": recommendation.get("qualityFloorPreserved") is True,
267
+ }
268
+
269
+
270
+ def _validate_shape(shape: str, config: Any, blockers: list[dict[str, Any]]) -> None:
271
+ if not isinstance(config, dict):
272
+ blockers.append({"code": "baseline-shape-config", "taskShape": shape})
273
+ return
274
+ for key in ("defaultMode", "minMode"):
275
+ if config.get(key) not in MODE_ORDER:
276
+ blockers.append({"code": "baseline-shape-mode", "taskShape": shape, "field": key})
277
+ for key in ("overheadWarningShare", "coordinationWarningShare"):
278
+ value = config.get(key)
279
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0 or value > 1:
280
+ blockers.append({"code": "baseline-shape-share", "taskShape": shape, "field": key})
281
+ threshold = config.get("reviewStepWarningThreshold")
282
+ if not isinstance(threshold, int) or isinstance(threshold, bool) or threshold < 0:
283
+ blockers.append({"code": "baseline-shape-review-threshold", "taskShape": shape})
284
+ if _mode_index(str(config.get("defaultMode"))) < _mode_index(str(config.get("minMode"))):
285
+ blockers.append({"code": "baseline-shape-default-below-min", "taskShape": shape})
286
+
287
+
288
+ def _empty_totals() -> dict[str, dict[str, int]]:
289
+ return {category: {"tokens": 0, "steps": 0} for category in COST_CATEGORIES}
290
+
291
+
292
+ def _average(value: int, count: int) -> float:
293
+ return round(value / count, 6) if count else 0.0
294
+
295
+
296
+ def _task_shape(profile: dict[str, Any], task_shape: str, blockers: list[dict[str, Any]]) -> dict[str, Any] | None:
297
+ shapes = profile.get("taskShapes")
298
+ if not isinstance(shapes, dict) or not isinstance(shapes.get(task_shape), dict):
299
+ blockers.append({"code": "baseline-task-shape-missing", "taskShape": task_shape})
300
+ return None
301
+ return shapes[task_shape]
302
+
303
+
304
+ def _warnings(stats: dict[str, Any], shape: dict[str, Any], profile: dict[str, Any]) -> list[dict[str, Any]]:
305
+ warnings: list[dict[str, Any]] = []
306
+ ratios = stats.get("ratios", {})
307
+ pipeline_share = float(ratios.get("pipelineTokenShare", 0.0))
308
+ coordination_share = _coordination_share(stats)
309
+ if pipeline_share > float(shape["overheadWarningShare"]):
310
+ warnings.append({"code": "pipeline-token-share-high", "value": pipeline_share, "limit": shape["overheadWarningShare"]})
311
+ if coordination_share > float(shape["coordinationWarningShare"]):
312
+ warnings.append({"code": "coordination-token-share-high", "value": coordination_share, "limit": shape["coordinationWarningShare"]})
313
+ if stats.get("averageProductValidationSteps", 0) > shape["reviewStepWarningThreshold"]:
314
+ warnings.append({"code": "review-step-cost-high", "value": stats["averageProductValidationSteps"], "limit": shape["reviewStepWarningThreshold"]})
315
+ confidence = stats.get("usageConfidence", {})
316
+ if confidence.get("missingEntries", 0) > 0:
317
+ warnings.append({"code": "missing-usage", "entries": confidence["missingEntries"]})
318
+ if stats.get("reportCount", 0) < int(profile.get("minimumReportsForConfidence", 2)):
319
+ warnings.append({"code": "weak-statistics", "reportCount": stats.get("reportCount", 0)})
320
+ if stats.get("status") != "PASS":
321
+ warnings.append({"code": "invalid-cost-report", "blockerCount": len(stats.get("blockers", []))})
322
+ return warnings
323
+
324
+
325
+ def _coordination_share(stats: dict[str, Any]) -> float:
326
+ totals = stats.get("totals", {})
327
+ overall = totals.get("overall", {}).get("tokens", 0)
328
+ coordination = totals.get("coordination", {}).get("tokens", 0)
329
+ return round(coordination / overall, 6) if overall else 0.0
330
+
331
+
332
+ def _weak_data(stats: dict[str, Any], profile: dict[str, Any]) -> bool:
333
+ usage = stats.get("usageConfidence", {})
334
+ minimum = int(profile.get("minimumReportsForConfidence", 2))
335
+ return stats.get("reportCount", 0) < minimum or usage.get("missingEntries", 0) > 0 or stats.get("status") != "PASS"
336
+
337
+
338
+ def _confidence(stats: dict[str, Any], *, weak_data: bool, warning_count: int) -> str:
339
+ if weak_data:
340
+ return "LOW"
341
+ if stats.get("reportCount", 0) >= 3 and warning_count == 0:
342
+ return "HIGH"
343
+ return "MEDIUM"
344
+
345
+
346
+ def _recommended_mode(
347
+ shape: dict[str, Any],
348
+ *,
349
+ floor: str,
350
+ current_mode: str | None,
351
+ confidence: str,
352
+ warnings: list[dict[str, Any]],
353
+ ) -> str:
354
+ current = current_mode if current_mode in MODE_ORDER else None
355
+ if confidence == "LOW":
356
+ return _max_mode(current or str(shape["defaultMode"]), floor)
357
+ if shape.get("highRisk") is True:
358
+ return _max_mode(current or str(shape["defaultMode"]), floor)
359
+ overhead_warning = any(item["code"] in {"pipeline-token-share-high", "coordination-token-share-high"} for item in warnings)
360
+ target = str(shape["minMode"] if overhead_warning else shape["defaultMode"])
361
+ return _max_mode(target, floor)
362
+
363
+
364
+ def _quality_floor_mode(
365
+ *,
366
+ task_shape: str,
367
+ baseline_profile: dict[str, Any],
368
+ sdd_tier: str | None,
369
+ risk_flags: list[str],
370
+ ) -> str:
371
+ from agent_lifecycle.policy.quality_floor import quality_floor_mode
372
+
373
+ return quality_floor_mode(
374
+ task_shape=task_shape,
375
+ baseline_profile=baseline_profile,
376
+ sdd_tier=sdd_tier,
377
+ risk_flags=risk_flags,
378
+ )
379
+
380
+
381
+ def _max_mode(first: str, second: str) -> str:
382
+ from agent_lifecycle.policy.quality_floor import max_mode
383
+
384
+ return max_mode(first, second)
385
+
386
+
387
+ def _mode_index(mode: str) -> int:
388
+ from agent_lifecycle.policy.quality_floor import mode_index
389
+
390
+ return mode_index(mode)
391
+
392
+
393
+ def _best_quality_cost_signal(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
394
+ if not rows:
395
+ return None
396
+ return sorted(
397
+ rows,
398
+ key=lambda item: (
399
+ -float(item.get("successRate", 0.0)),
400
+ float(item.get("blockerRate", 1.0)),
401
+ float(item.get("averageTokens", 0.0)),
402
+ -int(item.get("sampleCount", 0)),
403
+ ),
404
+ )[0]
405
+
406
+
407
+ def _quality_cost_warnings(
408
+ selected: dict[str, Any] | None,
409
+ *,
410
+ signals: dict[str, Any],
411
+ profile: dict[str, Any],
412
+ ) -> list[dict[str, Any]]:
413
+ warnings: list[dict[str, Any]] = []
414
+ if selected is None:
415
+ warnings.append({"code": "quality-cost-no-local-signal"})
416
+ return warnings
417
+ if int(selected.get("sampleCount", 0)) < int(profile.get("minimumReportsForConfidence", 2)):
418
+ warnings.append({"code": "quality-cost-weak-samples", "sampleCount": selected.get("sampleCount", 0)})
419
+ if float(selected.get("successRate", 0.0)) < 0.8:
420
+ warnings.append({"code": "quality-cost-success-rate-low", "successRate": selected.get("successRate", 0.0)})
421
+ if float(selected.get("blockerRate", 0.0)) > 0:
422
+ warnings.append({"code": "quality-cost-blocker-rate-nonzero", "blockerRate": selected.get("blockerRate", 0.0)})
423
+ if signals.get("telemetryStarted") is not False:
424
+ warnings.append({"code": "quality-cost-telemetry-unsafe"})
425
+ if signals.get("providerModelLeaderboard") is not False:
426
+ warnings.append({"code": "quality-cost-provider-leaderboard-unsafe"})
427
+ if signals.get("monetaryFieldsUsed") is not False:
428
+ warnings.append({"code": "quality-cost-monetary-fields-unsafe"})
429
+ if signals.get("productionPromotionClaimed") is not False:
430
+ warnings.append({"code": "quality-cost-production-claim-unsafe"})
431
+ return warnings
432
+
433
+
434
+ def _unsafe_quality_cost_signal_blockers(signals: dict[str, Any]) -> list[dict[str, Any]]:
435
+ blockers = []
436
+ for field, code in (
437
+ ("telemetryStarted", "quality-cost-telemetry-started"),
438
+ ("providerModelLeaderboard", "quality-cost-provider-leaderboard"),
439
+ ("monetaryFieldsUsed", "quality-cost-monetary-fields-used"),
440
+ ("productionPromotionClaimed", "quality-cost-production-promotion-claimed"),
441
+ ):
442
+ if signals.get(field) is not False:
443
+ blockers.append({"code": code, "field": field})
444
+ return blockers
445
+
446
+
447
+ def _quality_cost_confidence(selected: dict[str, Any] | None, profile: dict[str, Any], *, warning_count: int) -> str:
448
+ if selected is None:
449
+ return "LOW"
450
+ samples = int(selected.get("sampleCount", 0))
451
+ success = float(selected.get("successRate", 0.0))
452
+ blocker_rate = float(selected.get("blockerRate", 0.0))
453
+ if samples < int(profile.get("minimumReportsForConfidence", 2)) or success < 0.8 or blocker_rate > 0:
454
+ return "LOW"
455
+ if samples >= 5 and success >= 0.9 and warning_count == 0:
456
+ return "HIGH"
457
+ return "MEDIUM"
458
+
459
+
460
+ def _quality_cost_reasons(
461
+ confidence: str,
462
+ recommended: str,
463
+ floor: str,
464
+ selected: dict[str, Any] | None,
465
+ warnings: list[dict[str, Any]],
466
+ ) -> list[str]:
467
+ reasons = [f"confidence-{confidence.lower()}", f"quality-floor-{floor}", f"recommended-{recommended}"]
468
+ if selected is not None:
469
+ reasons.append(f"local-samples-{selected.get('sampleCount', 0)}")
470
+ reasons.extend(item["code"] for item in warnings[:6] if isinstance(item.get("code"), str))
471
+ return reasons
472
+
473
+
474
+ def _reasons(confidence: str, recommended: str, floor: str, weak_data: bool, warnings: list[dict[str, Any]]) -> list[str]:
475
+ reasons = [f"recommendedMode={recommended}", f"qualityFloor={floor}", f"confidence={confidence}"]
476
+ if weak_data:
477
+ reasons.append("statistics are weak or incomplete, so the safer current/floor mode is retained")
478
+ for warning in warnings:
479
+ reasons.append(f"warning:{warning['code']}")
480
+ return reasons
481
+
482
+
483
+ def _failed_recommendation(
484
+ task_shape: str,
485
+ current_mode: str | None,
486
+ baseline_validation: dict[str, Any],
487
+ stats: dict[str, Any],
488
+ blockers: list[dict[str, Any]],
489
+ ) -> dict[str, Any]:
490
+ return {
491
+ "schemaVersion": RECOMMENDATION_SCHEMA,
492
+ "status": "FAIL",
493
+ "taskShape": task_shape,
494
+ "currentMode": current_mode,
495
+ "recommendedMode": current_mode if current_mode in MODE_ORDER else "standard",
496
+ "confidence": "LOW",
497
+ "advisoryOnly": True,
498
+ "autoApply": False,
499
+ "qualityFloor": "standard",
500
+ "qualityFloorPreserved": True,
501
+ "warnings": [{"code": "recommendation-blocked", "blockerCount": len(blockers)}],
502
+ "reasons": ["baseline profile is invalid or task shape is unknown"],
503
+ "statistics": stats,
504
+ "baselineValidation": baseline_validation,
505
+ "blockers": blockers,
506
+ "productionPromotionClaimed": False,
507
+ }
@@ -0,0 +1,64 @@
1
+ """Summarize lifecycle regression signals for policy decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_lifecycle.contracts import canonical_digest
8
+
9
+ REGRESSION_SCHEMA = "agent-lifecycle-regression-signals.v1"
10
+ BLOCKING_SIGNAL_TYPES = {
11
+ "failedFinalAudit",
12
+ "reopenedWork",
13
+ "rollback",
14
+ "repeatedRemediation",
15
+ }
16
+
17
+
18
+ def summarize_regression_signals(signals: list[dict[str, Any]] | None) -> dict[str, Any]:
19
+ normalized: list[dict[str, Any]] = []
20
+ blockers: list[dict[str, Any]] = []
21
+ for index, signal in enumerate(signals or []):
22
+ item = _normalize_signal(index, signal, blockers)
23
+ if item is not None:
24
+ normalized.append(item)
25
+ blocking_signals = [
26
+ item
27
+ for item in normalized
28
+ if item["count"] > 0 and (item["type"] in BLOCKING_SIGNAL_TYPES or item["severity"] in {"HIGH", "BLOCKER"})
29
+ ]
30
+ body = {
31
+ "schemaVersion": REGRESSION_SCHEMA,
32
+ "status": "FAIL" if blockers else ("BLOCK" if blocking_signals else "PASS"),
33
+ "signalCount": len(normalized),
34
+ "signals": normalized,
35
+ "blockingSignals": blocking_signals,
36
+ "blockers": blockers,
37
+ "productionPromotionClaimed": False,
38
+ }
39
+ return {**body, "signalsDigest": canonical_digest(body)}
40
+
41
+
42
+ def _normalize_signal(index: int, signal: dict[str, Any], blockers: list[dict[str, Any]]) -> dict[str, Any] | None:
43
+ if not isinstance(signal, dict):
44
+ blockers.append({"code": "regression-signal-type", "index": index})
45
+ return None
46
+ signal_type = signal.get("type")
47
+ if not isinstance(signal_type, str) or not signal_type:
48
+ blockers.append({"code": "regression-signal-missing-type", "index": index})
49
+ return None
50
+ count = signal.get("count", 1)
51
+ if not isinstance(count, int) or isinstance(count, bool) or count < 0:
52
+ blockers.append({"code": "regression-signal-count", "index": index, "type": signal_type})
53
+ return None
54
+ severity = signal.get("severity", "MEDIUM")
55
+ if severity not in {"LOW", "MEDIUM", "HIGH", "BLOCKER"}:
56
+ blockers.append({"code": "regression-signal-severity", "index": index, "type": signal_type})
57
+ return None
58
+ return {
59
+ "type": signal_type,
60
+ "count": count,
61
+ "severity": severity,
62
+ "source": signal.get("source"),
63
+ "note": signal.get("note"),
64
+ }