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,159 @@
1
+ """Validate human acceptance checklists against plan manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from agent_lifecycle.contracts import LifecycleError
10
+
11
+
12
+ _BACKTICK_RE = re.compile(r"`([^`]+)`")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AcceptanceChecklistRow:
17
+ id: str
18
+ requirement_ids: tuple[str, ...]
19
+ evidence_ids: tuple[str, ...]
20
+
21
+
22
+ def validate_acceptance_checklist(manifest: dict[str, Any], markdown: str) -> dict[str, Any]:
23
+ """Validate checklist criterion IDs and links against manifest authority."""
24
+
25
+ package_id = _package_id(manifest)
26
+ manifest_rows = _manifest_rows(manifest)
27
+ markdown_rows = _markdown_rows(markdown)
28
+
29
+ manifest_by_id = {row.id: row for row in manifest_rows}
30
+ markdown_by_id = {row.id: row for row in markdown_rows}
31
+ duplicate_markdown_ids = _duplicates([row.id for row in markdown_rows])
32
+ missing_in_markdown = [row.id for row in manifest_rows if row.id not in markdown_by_id]
33
+ extra_in_markdown = [row.id for row in markdown_rows if row.id not in manifest_by_id]
34
+ link_mismatches = _link_mismatches(manifest_rows, markdown_by_id)
35
+
36
+ result = {
37
+ "schemaVersion": "agent-acceptance-checklist-validation.v1",
38
+ "packageId": package_id,
39
+ "criterionCount": len(manifest_rows),
40
+ "markdownCriterionCount": len(markdown_rows),
41
+ "missingInMarkdown": missing_in_markdown,
42
+ "extraInMarkdown": extra_in_markdown,
43
+ "duplicateMarkdownIds": duplicate_markdown_ids,
44
+ "linkMismatches": link_mismatches,
45
+ "status": "PASS",
46
+ }
47
+ if missing_in_markdown or extra_in_markdown or duplicate_markdown_ids or link_mismatches:
48
+ result["status"] = "FAIL"
49
+ raise LifecycleError("acceptance-checklist-mismatch", "acceptance checklist does not match manifest", result)
50
+ return result
51
+
52
+
53
+ def _package_id(manifest: dict[str, Any]) -> str:
54
+ package = manifest.get("package")
55
+ if not isinstance(package, dict) or not isinstance(package.get("id"), str):
56
+ raise LifecycleError("invalid-plan-manifest", "package.id is required")
57
+ return package["id"]
58
+
59
+
60
+ def _manifest_rows(manifest: dict[str, Any]) -> list[AcceptanceChecklistRow]:
61
+ acceptance = manifest.get("acceptance")
62
+ criteria = acceptance.get("criteria") if isinstance(acceptance, dict) else None
63
+ if not isinstance(criteria, list) or not criteria:
64
+ raise LifecycleError("invalid-plan-manifest", "acceptance.criteria are required")
65
+ rows: list[AcceptanceChecklistRow] = []
66
+ for item in criteria:
67
+ if not isinstance(item, dict) or not isinstance(item.get("id"), str):
68
+ raise LifecycleError("invalid-plan-manifest", "acceptance criteria require id")
69
+ rows.append(
70
+ AcceptanceChecklistRow(
71
+ id=item["id"],
72
+ requirement_ids=_string_tuple(item.get("requirementIds"), label=f"{item['id']}.requirementIds"),
73
+ evidence_ids=_string_tuple(item.get("evidenceIds"), label=f"{item['id']}.evidenceIds"),
74
+ )
75
+ )
76
+ return rows
77
+
78
+
79
+ def _markdown_rows(markdown: str) -> list[AcceptanceChecklistRow]:
80
+ rows: list[AcceptanceChecklistRow] = []
81
+ for line_number, line in enumerate(markdown.splitlines(), start=1):
82
+ stripped = line.strip()
83
+ if not stripped.startswith("|"):
84
+ continue
85
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
86
+ if len(cells) < 4:
87
+ continue
88
+ criterion_id = _first_backtick(cells[0])
89
+ if criterion_id is None:
90
+ continue
91
+ if not criterion_id.startswith("AC"):
92
+ continue
93
+ rows.append(
94
+ AcceptanceChecklistRow(
95
+ id=criterion_id,
96
+ requirement_ids=_backtick_tuple(cells[1], line_number=line_number, column="Requirements"),
97
+ evidence_ids=_backtick_tuple(cells[2], line_number=line_number, column="Evidence"),
98
+ )
99
+ )
100
+ if not rows:
101
+ raise LifecycleError("invalid-acceptance-checklist", "acceptance checklist table has no AC rows")
102
+ return rows
103
+
104
+
105
+ def _string_tuple(value: object, *, label: str) -> tuple[str, ...]:
106
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
107
+ raise LifecycleError("invalid-plan-manifest", f"{label} must be a list of strings")
108
+ return tuple(value)
109
+
110
+
111
+ def _first_backtick(cell: str) -> str | None:
112
+ match = _BACKTICK_RE.search(cell)
113
+ return match.group(1) if match else None
114
+
115
+
116
+ def _backtick_tuple(cell: str, *, line_number: int, column: str) -> tuple[str, ...]:
117
+ values = tuple(match.group(1).strip() for match in _BACKTICK_RE.finditer(cell))
118
+ if not values:
119
+ raise LifecycleError(
120
+ "invalid-acceptance-checklist",
121
+ f"{column} cell has no backticked IDs",
122
+ {"line": line_number, "column": column},
123
+ )
124
+ return values
125
+
126
+
127
+ def _duplicates(values: list[str]) -> list[str]:
128
+ seen: set[str] = set()
129
+ duplicates: list[str] = []
130
+ for value in values:
131
+ if value in seen and value not in duplicates:
132
+ duplicates.append(value)
133
+ seen.add(value)
134
+ return duplicates
135
+
136
+
137
+ def _link_mismatches(
138
+ manifest_rows: list[AcceptanceChecklistRow],
139
+ markdown_by_id: dict[str, AcceptanceChecklistRow],
140
+ ) -> list[dict[str, object]]:
141
+ mismatches: list[dict[str, object]] = []
142
+ for manifest_row in manifest_rows:
143
+ markdown_row = markdown_by_id.get(manifest_row.id)
144
+ if markdown_row is None:
145
+ continue
146
+ if (
147
+ manifest_row.requirement_ids != markdown_row.requirement_ids
148
+ or manifest_row.evidence_ids != markdown_row.evidence_ids
149
+ ):
150
+ mismatches.append(
151
+ {
152
+ "id": manifest_row.id,
153
+ "manifestRequirementIds": list(manifest_row.requirement_ids),
154
+ "markdownRequirementIds": list(markdown_row.requirement_ids),
155
+ "manifestEvidenceIds": list(manifest_row.evidence_ids),
156
+ "markdownEvidenceIds": list(markdown_row.evidence_ids),
157
+ }
158
+ )
159
+ return mismatches
@@ -0,0 +1,362 @@
1
+ """Structural completeness checks for SDD plan tiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agent_lifecycle.contracts import LifecycleError, canonical_digest, read_json_object
10
+
11
+ PROFILE_SCHEMA = "agent-plan-completeness-profile.v1"
12
+ VALIDATION_SCHEMA = "agent-plan-completeness-validation.v1"
13
+ SUPPORTED_TIERS = {"S0", "S1", "S2"}
14
+
15
+ DEFAULT_PROFILE: dict[str, Any] = {
16
+ "schemaVersion": PROFILE_SCHEMA,
17
+ "profileId": "alk-default-plan-completeness",
18
+ "profiles": {
19
+ "S0": {
20
+ "description": "Bounded mechanical work with exact write scope and one validation route.",
21
+ "requiredChecks": [
22
+ "single-workstream",
23
+ "write-ownership",
24
+ "validation-command",
25
+ ],
26
+ },
27
+ "S1": {
28
+ "description": "Standard single-owner work with requirements, acceptance, evidence and validation.",
29
+ "requiredChecks": [
30
+ "requirements",
31
+ "acceptance",
32
+ "evidence-route",
33
+ "write-ownership",
34
+ "validation-command",
35
+ "release-impact",
36
+ ],
37
+ },
38
+ "S2": {
39
+ "description": "Large or risky work with complete authority, evidence, budgets and final gates.",
40
+ "requiredChecks": [
41
+ "requirements",
42
+ "acceptance",
43
+ "evidence-route",
44
+ "write-ownership",
45
+ "dag",
46
+ "budget-policy",
47
+ "context-limits",
48
+ "security-release-gates",
49
+ "final-audit-gates",
50
+ ],
51
+ },
52
+ },
53
+ }
54
+
55
+
56
+ def build_plan_completeness_profile() -> dict[str, Any]:
57
+ body = deepcopy(DEFAULT_PROFILE)
58
+ return {**body, "profileDigest": canonical_digest(body)}
59
+
60
+
61
+ def load_plan_completeness_profile(path: Path | None = None) -> dict[str, Any]:
62
+ if path is None:
63
+ return build_plan_completeness_profile()
64
+ profile = read_json_object(path, label="plan completeness profile")
65
+ validation = validate_plan_completeness_profile(profile)
66
+ if validation["status"] != "PASS":
67
+ raise LifecycleError(
68
+ "plan-completeness-profile-invalid",
69
+ "plan completeness profile validation failed",
70
+ {"validation": validation},
71
+ )
72
+ return profile
73
+
74
+
75
+ def validate_plan_completeness_profile(profile: dict[str, Any]) -> dict[str, Any]:
76
+ blockers: list[dict[str, Any]] = []
77
+ if profile.get("schemaVersion") != PROFILE_SCHEMA:
78
+ blockers.append(_blocker("invalid-profile-schema", "profile schemaVersion is unsupported"))
79
+ profiles = profile.get("profiles")
80
+ if not isinstance(profiles, dict):
81
+ blockers.append(_blocker("missing-tier-profiles", "profiles must contain S0, S1 and S2"))
82
+ else:
83
+ missing = sorted(SUPPORTED_TIERS.difference(profiles))
84
+ if missing:
85
+ blockers.append(_blocker("missing-tier-profiles", "profiles must contain S0, S1 and S2", {"missing": missing}))
86
+ for tier in sorted(SUPPORTED_TIERS.intersection(profiles)):
87
+ required = profiles[tier].get("requiredChecks") if isinstance(profiles[tier], dict) else None
88
+ if not isinstance(required, list) or not all(isinstance(item, str) and item for item in required):
89
+ blockers.append(_blocker("invalid-required-checks", "tier profile requiredChecks must be a string list", {"tier": tier}))
90
+ expected_digest = profile.get("profileDigest")
91
+ if expected_digest is not None:
92
+ body = {key: value for key, value in profile.items() if key != "profileDigest"}
93
+ if expected_digest != canonical_digest(body):
94
+ blockers.append(_blocker("profile-digest-mismatch", "profileDigest does not match profile body"))
95
+ body = {
96
+ "schemaVersion": "agent-plan-completeness-profile-validation.v1",
97
+ "status": "PASS" if not blockers else "FAIL",
98
+ "profileId": profile.get("profileId"),
99
+ "blockers": blockers,
100
+ "profileDigest": profile.get("profileDigest"),
101
+ }
102
+ return {**body, "validationDigest": canonical_digest(body)}
103
+
104
+
105
+ def validate_plan_completeness(
106
+ manifest: dict[str, Any],
107
+ *,
108
+ profile: dict[str, Any] | None = None,
109
+ ) -> dict[str, Any]:
110
+ active_profile = profile or build_plan_completeness_profile()
111
+ profile_validation = validate_plan_completeness_profile(active_profile)
112
+ blockers = list(profile_validation["blockers"])
113
+ tier = _tier(manifest)
114
+ if tier not in SUPPORTED_TIERS:
115
+ blockers.append(_blocker("invalid-plan-tier", "plan tier must be S0, S1 or S2", {"tier": tier}))
116
+ tier = "S2"
117
+ required_checks = _required_checks(active_profile, tier)
118
+ for check in required_checks:
119
+ _CHECKS[check](manifest, tier, blockers)
120
+ body = {
121
+ "schemaVersion": VALIDATION_SCHEMA,
122
+ "status": "PASS" if not blockers else "FAIL",
123
+ "packageId": _package_id(manifest),
124
+ "tier": tier,
125
+ "requiredChecks": required_checks,
126
+ "blockers": blockers,
127
+ "profileDigest": active_profile.get("profileDigest") or canonical_digest(active_profile),
128
+ "planDigest": canonical_digest(manifest),
129
+ }
130
+ return {**body, "validationDigest": canonical_digest(body)}
131
+
132
+
133
+ def require_plan_completeness_pass(validation: dict[str, Any]) -> dict[str, Any]:
134
+ if validation.get("status") != "PASS":
135
+ raise LifecycleError(
136
+ "plan-completeness-failed",
137
+ "plan completeness check failed",
138
+ {"validation": validation},
139
+ )
140
+ return validation
141
+
142
+
143
+ def _tier(manifest: dict[str, Any]) -> str:
144
+ specification = manifest.get("specification")
145
+ if isinstance(specification, dict) and isinstance(specification.get("tier"), str):
146
+ return specification["tier"]
147
+ raw = manifest.get("tier")
148
+ return raw if isinstance(raw, str) else "S1"
149
+
150
+
151
+ def _package_id(manifest: dict[str, Any]) -> str | None:
152
+ package = manifest.get("package")
153
+ return package.get("id") if isinstance(package, dict) and isinstance(package.get("id"), str) else None
154
+
155
+
156
+ def _required_checks(profile: dict[str, Any], tier: str) -> list[str]:
157
+ profiles = profile.get("profiles") if isinstance(profile.get("profiles"), dict) else {}
158
+ tier_profile = profiles.get(tier) if isinstance(profiles, dict) else None
159
+ required = tier_profile.get("requiredChecks") if isinstance(tier_profile, dict) else []
160
+ return [item for item in required if isinstance(item, str)]
161
+
162
+
163
+ def _check_single_workstream(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
164
+ workstreams = _workstreams(manifest)
165
+ if len(workstreams) != 1:
166
+ blockers.append(_blocker("s0-single-workstream-missing", "S0 plans must contain exactly one workstream", {"tier": tier}))
167
+
168
+
169
+ def _check_requirements(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
170
+ specification = manifest.get("specification")
171
+ requirements = specification.get("requirements") if isinstance(specification, dict) else None
172
+ if not isinstance(requirements, list) or not requirements:
173
+ blockers.append(_blocker("missing-requirements", "plan specification must contain requirements", {"tier": tier}))
174
+
175
+
176
+ def _check_acceptance(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
177
+ criteria = _acceptance_criteria(manifest)
178
+ if not criteria:
179
+ blockers.append(_blocker("missing-acceptance", "plan must contain acceptance criteria", {"tier": tier}))
180
+ return
181
+ for criterion in criteria:
182
+ if not isinstance(criterion.get("id"), str) or not criterion["id"]:
183
+ blockers.append(_blocker("missing-acceptance-id", "acceptance criterion id is required", {"tier": tier}))
184
+
185
+
186
+ def _check_evidence_route(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
187
+ criteria = _acceptance_criteria(manifest)
188
+ evidence_ids = set()
189
+ for criterion in criteria:
190
+ ids = _strings(criterion.get("evidenceIds"))
191
+ if not ids:
192
+ blockers.append(_blocker("missing-evidence-route", "acceptance criterion must reference evidence ids", {"criterionId": criterion.get("id")}))
193
+ evidence_ids.update(ids)
194
+ routed = set()
195
+ for workstream in _workstreams(manifest):
196
+ routed.update(_strings(workstream.get("evidenceIds")))
197
+ validation = manifest.get("validation")
198
+ if isinstance(validation, dict):
199
+ routed.update(_strings(validation.get("extraEvidence")))
200
+ missing = sorted(evidence_ids.difference(routed))
201
+ if missing:
202
+ blockers.append(_blocker("missing-evidence-route", "evidence ids must be routed to workstreams or validation evidence", {"missing": missing}))
203
+
204
+
205
+ def _check_write_ownership(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
206
+ workstreams = _workstreams(manifest)
207
+ if not workstreams:
208
+ blockers.append(_blocker("missing-write-ownership", "plan must contain workstreams", {"tier": tier}))
209
+ return
210
+ for workstream in workstreams:
211
+ writes = _strings(workstream.get("writes"))
212
+ if not isinstance(workstream.get("id"), str) or not workstream["id"]:
213
+ blockers.append(_blocker("missing-workstream-id", "workstream id is required", {"tier": tier}))
214
+ if not writes:
215
+ blockers.append(_blocker("missing-write-ownership", "workstream writes are required", {"workstreamId": workstream.get("id")}))
216
+
217
+
218
+ def _check_validation_command(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
219
+ validation = manifest.get("validation")
220
+ commands = validation.get("commands") if isinstance(validation, dict) else None
221
+ if not isinstance(commands, list) or not any(isinstance(item, str) and item.strip() for item in commands):
222
+ blockers.append(_blocker("missing-validation-command", "plan validation.commands must contain at least one command", {"tier": tier}))
223
+
224
+
225
+ def _check_release_impact(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
226
+ if not any(key in manifest for key in ("releaseTarget", "releaseImpact", "nonGoals", "finalAuditGates")):
227
+ blockers.append(_blocker("missing-release-impact", "S1 plans must state release impact or non-release boundary", {"tier": tier}))
228
+
229
+
230
+ def _check_dag(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
231
+ workstreams = _workstreams(manifest)
232
+ ids = [item.get("id") for item in workstreams if isinstance(item.get("id"), str)]
233
+ duplicates = sorted({item for item in ids if ids.count(item) > 1})
234
+ if duplicates:
235
+ blockers.append(_blocker("duplicate-workstream-id", "workstream ids must be unique", {"duplicates": duplicates}))
236
+ id_set = set(ids)
237
+ for workstream in workstreams:
238
+ depends_on = workstream.get("dependsOn")
239
+ if not isinstance(depends_on, list):
240
+ blockers.append(_blocker("missing-dag", "S2 workstreams must declare dependsOn", {"workstreamId": workstream.get("id")}))
241
+ continue
242
+ unknown = sorted(item for item in _strings(depends_on) if item not in id_set)
243
+ if unknown:
244
+ blockers.append(_blocker("unknown-workstream-dependency", "workstream dependsOn references unknown ids", {"workstreamId": workstream.get("id"), "unknown": unknown}))
245
+ if _has_cycle(workstreams):
246
+ blockers.append(_blocker("cyclic-workstream-dag", "workstream DAG must be acyclic"))
247
+
248
+
249
+ def _check_budget_policy(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
250
+ validation = manifest.get("validation")
251
+ has_budget = any(
252
+ _has_path(manifest, path)
253
+ for path in (
254
+ ("budgets",),
255
+ ("budgetPolicy",),
256
+ ("budgetTargets",),
257
+ ("resourceBudgets",),
258
+ ("validation", "budgetPolicy"),
259
+ ("validation", "budgetTargets"),
260
+ )
261
+ )
262
+ if isinstance(validation, dict):
263
+ has_budget = has_budget or any("budget" in str(item).lower() for item in _strings(validation.get("commands")))
264
+ if not has_budget:
265
+ blockers.append(_blocker("missing-budget-policy", "S2 plans must declare token/resource budget policy or validation", {"tier": tier}))
266
+
267
+
268
+ def _check_context_limits(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
269
+ specification = manifest.get("specification")
270
+ tier_request = specification.get("tierResolutionRequest") if isinstance(specification, dict) else None
271
+ has_limits = any(
272
+ _has_path(manifest, path)
273
+ for path in (
274
+ ("contextLimits",),
275
+ ("contextBudget",),
276
+ ("smallContext",),
277
+ ("validation", "contextLimits"),
278
+ )
279
+ )
280
+ if isinstance(tier_request, dict):
281
+ has_limits = has_limits or isinstance(tier_request.get("requirementsBytes"), int)
282
+ if not has_limits:
283
+ blockers.append(_blocker("missing-context-limits", "S2 plans must define context limits or tier byte bounds", {"tier": tier}))
284
+
285
+
286
+ def _check_security_release_gates(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
287
+ has_security = bool(_strings(manifest.get("forbiddenWrites"))) or any(key in manifest for key in ("securityGates", "sandbox", "runtimePolicy"))
288
+ has_release = "releaseTarget" not in manifest or bool(_strings(manifest.get("finalAuditGates"))) or _has_path(manifest, ("validation", "commands"))
289
+ if not has_security:
290
+ blockers.append(_blocker("missing-security-gate", "S2 plans must declare security or containment gates", {"tier": tier}))
291
+ if not has_release:
292
+ blockers.append(_blocker("missing-release-gate", "release S2 plans must declare release validation gates", {"tier": tier}))
293
+
294
+
295
+ def _check_final_audit_gates(manifest: dict[str, Any], tier: str, blockers: list[dict[str, Any]]) -> None:
296
+ if not _strings(manifest.get("finalAuditGates")):
297
+ blockers.append(_blocker("s2-final-audit-gate-missing", "S2 plans must declare final audit gates", {"tier": tier}))
298
+
299
+
300
+ def _workstreams(manifest: dict[str, Any]) -> list[dict[str, Any]]:
301
+ workstreams = manifest.get("workstreams")
302
+ return [item for item in workstreams if isinstance(item, dict)] if isinstance(workstreams, list) else []
303
+
304
+
305
+ def _acceptance_criteria(manifest: dict[str, Any]) -> list[dict[str, Any]]:
306
+ acceptance = manifest.get("acceptance")
307
+ criteria = acceptance.get("criteria") if isinstance(acceptance, dict) else manifest.get("acceptanceCriteria")
308
+ return [item for item in criteria if isinstance(item, dict)] if isinstance(criteria, list) else []
309
+
310
+
311
+ def _strings(value: Any) -> list[str]:
312
+ return [item for item in value if isinstance(item, str) and item] if isinstance(value, list) else []
313
+
314
+
315
+ def _has_path(payload: dict[str, Any], path: tuple[str, ...]) -> bool:
316
+ current: Any = payload
317
+ for part in path:
318
+ if not isinstance(current, dict) or part not in current:
319
+ return False
320
+ current = current[part]
321
+ return current not in (None, {}, [], "")
322
+
323
+
324
+ def _has_cycle(workstreams: list[dict[str, Any]]) -> bool:
325
+ graph = {item.get("id"): _strings(item.get("dependsOn")) for item in workstreams if isinstance(item.get("id"), str)}
326
+ visiting: set[str] = set()
327
+ visited: set[str] = set()
328
+
329
+ def visit(node: str) -> bool:
330
+ if node in visiting:
331
+ return True
332
+ if node in visited:
333
+ return False
334
+ visiting.add(node)
335
+ for child in graph.get(node, []):
336
+ if child in graph and visit(child):
337
+ return True
338
+ visiting.remove(node)
339
+ visited.add(node)
340
+ return False
341
+
342
+ return any(visit(node) for node in graph)
343
+
344
+
345
+ def _blocker(code: str, message: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
346
+ return {"code": code, "message": message, "context": context or {}}
347
+
348
+
349
+ _CHECKS = {
350
+ "single-workstream": _check_single_workstream,
351
+ "requirements": _check_requirements,
352
+ "acceptance": _check_acceptance,
353
+ "evidence-route": _check_evidence_route,
354
+ "write-ownership": _check_write_ownership,
355
+ "validation-command": _check_validation_command,
356
+ "release-impact": _check_release_impact,
357
+ "dag": _check_dag,
358
+ "budget-policy": _check_budget_policy,
359
+ "context-limits": _check_context_limits,
360
+ "security-release-gates": _check_security_release_gates,
361
+ "final-audit-gates": _check_final_audit_gates,
362
+ }