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.
- agent_lifecycle/__init__.py +5 -0
- agent_lifecycle/__main__.py +5 -0
- agent_lifecycle/_version.py +3 -0
- agent_lifecycle/adapter_sessions/__init__.py +41 -0
- agent_lifecycle/adapter_sessions/contracts.py +133 -0
- agent_lifecycle/adapter_sessions/env.py +44 -0
- agent_lifecycle/adapter_sessions/launcher.py +101 -0
- agent_lifecycle/adapter_sessions/process.py +39 -0
- agent_lifecycle/adapter_sessions/redaction.py +30 -0
- agent_lifecycle/adapter_sessions/session_store.py +64 -0
- agent_lifecycle/adapter_sessions/task_intake.py +450 -0
- agent_lifecycle/adapter_sessions/workflow_bridge.py +154 -0
- agent_lifecycle/audit/__init__.py +67 -0
- agent_lifecycle/audit/bug_forensics.py +94 -0
- agent_lifecycle/audit/implementation.py +516 -0
- agent_lifecycle/audit/ownership.py +119 -0
- agent_lifecycle/audit/proof_integrity.py +814 -0
- agent_lifecycle/audit/review_verdict.py +116 -0
- agent_lifecycle/changesets/__init__.py +5 -0
- agent_lifecycle/changesets/git.py +35 -0
- agent_lifecycle/cli/__init__.py +5 -0
- agent_lifecycle/cli/adapter.py +360 -0
- agent_lifecycle/cli/dispatch.py +1220 -0
- agent_lifecycle/cli/followup.py +89 -0
- agent_lifecycle/cli/main.py +41 -0
- agent_lifecycle/cli/metrics_parser.py +49 -0
- agent_lifecycle/cli/parsers.py +618 -0
- agent_lifecycle/cli/policy.py +101 -0
- agent_lifecycle/cli/progress_hooks.py +117 -0
- agent_lifecycle/cli/worktree.py +74 -0
- agent_lifecycle/compiler/__init__.py +17 -0
- agent_lifecycle/compiler/small_model_packets.py +322 -0
- agent_lifecycle/compiler/task_packets.py +229 -0
- agent_lifecycle/context/__init__.py +20 -0
- agent_lifecycle/context/episode_retrieval.py +30 -0
- agent_lifecycle/context/external_memory.py +71 -0
- agent_lifecycle/context/profiles.py +104 -0
- agent_lifecycle/context/rendering.py +226 -0
- agent_lifecycle/contracts/__init__.py +25 -0
- agent_lifecycle/contracts/adapter_contract_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_event_schemas.py +118 -0
- agent_lifecycle/contracts/adapter_session_schemas.py +130 -0
- agent_lifecycle/contracts/adapter_task_schemas.py +90 -0
- agent_lifecycle/contracts/audit_schemas.py +133 -0
- agent_lifecycle/contracts/bug_forensics_schemas.py +400 -0
- agent_lifecycle/contracts/canonical.py +55 -0
- agent_lifecycle/contracts/compatibility.py +242 -0
- agent_lifecycle/contracts/context_model_schemas.py +265 -0
- agent_lifecycle/contracts/core_schemas.py +252 -0
- agent_lifecycle/contracts/cross_check_schemas.py +121 -0
- agent_lifecycle/contracts/errors.py +26 -0
- agent_lifecycle/contracts/evidence_import_schemas.py +152 -0
- agent_lifecycle/contracts/host_capability_schemas.py +64 -0
- agent_lifecycle/contracts/import_dialect_schemas.py +166 -0
- agent_lifecycle/contracts/metric_schemas.py +315 -0
- agent_lifecycle/contracts/paths.py +31 -0
- agent_lifecycle/contracts/plan_contract_schemas.py +148 -0
- agent_lifecycle/contracts/policy_schemas.py +350 -0
- agent_lifecycle/contracts/progress_bridge_schemas.py +114 -0
- agent_lifecycle/contracts/progress_hook_schemas.py +119 -0
- agent_lifecycle/contracts/proof_integrity_schemas.py +240 -0
- agent_lifecycle/contracts/release_contract_schemas.py +476 -0
- agent_lifecycle/contracts/review_mesh_recommendation_schemas.py +64 -0
- agent_lifecycle/contracts/review_mesh_schemas.py +222 -0
- agent_lifecycle/contracts/review_quality_schemas.py +109 -0
- agent_lifecycle/contracts/runner_schemas.py +91 -0
- agent_lifecycle/contracts/runner_worktree_schemas.py +389 -0
- agent_lifecycle/contracts/sandbox_schemas.py +159 -0
- agent_lifecycle/contracts/schema_builders.py +23 -0
- agent_lifecycle/contracts/schemas.py +93 -0
- agent_lifecycle/contracts/status_goal_schemas.py +469 -0
- agent_lifecycle/contracts/task_template_schemas.py +96 -0
- agent_lifecycle/contracts/usage_export_schemas.py +68 -0
- agent_lifecycle/diagnostics/__init__.py +17 -0
- agent_lifecycle/diagnostics/bundles.py +124 -0
- agent_lifecycle/diagnostics/readiness.py +733 -0
- agent_lifecycle/evidence_index/__init__.py +39 -0
- agent_lifecycle/evidence_index/core.py +272 -0
- agent_lifecycle/evidence_index/episode_index.py +329 -0
- agent_lifecycle/evidence_index/external_context.py +276 -0
- agent_lifecycle/followup/__init__.py +23 -0
- agent_lifecycle/followup/records.py +404 -0
- agent_lifecycle/freeze/__init__.py +5 -0
- agent_lifecycle/freeze/locks.py +23 -0
- agent_lifecycle/goal/__init__.py +15 -0
- agent_lifecycle/goal/records.py +244 -0
- agent_lifecycle/goal/view.py +109 -0
- agent_lifecycle/host_protocol/__init__.py +65 -0
- agent_lifecycle/host_protocol/acp_capability.py +195 -0
- agent_lifecycle/host_protocol/capabilities.py +268 -0
- agent_lifecycle/host_protocol/contracts.py +214 -0
- agent_lifecycle/host_protocol/event_capture.py +390 -0
- agent_lifecycle/host_protocol/events.py +196 -0
- agent_lifecycle/host_protocol/inspection.py +194 -0
- agent_lifecycle/host_protocol/inspection_common.py +310 -0
- agent_lifecycle/host_protocol/inspection_cursor.py +164 -0
- agent_lifecycle/host_protocol/inspection_gemini_cli.py +163 -0
- agent_lifecycle/host_protocol/inspection_hermes.py +161 -0
- agent_lifecycle/host_protocol/inspection_kimi_code.py +169 -0
- agent_lifecycle/host_protocol/inspection_opencode.py +147 -0
- agent_lifecycle/host_protocol/inspection_qwen_code.py +143 -0
- agent_lifecycle/host_protocol/receipts.py +58 -0
- agent_lifecycle/host_protocol/scaffold.py +329 -0
- agent_lifecycle/host_protocol/validation.py +229 -0
- agent_lifecycle/imports/__init__.py +76 -0
- agent_lifecycle/imports/agentskills_profile.py +54 -0
- agent_lifecycle/imports/bmad_profile.py +49 -0
- agent_lifecycle/imports/constitution_adr.py +91 -0
- agent_lifecycle/imports/dialect_profiles.py +121 -0
- agent_lifecycle/imports/external_agent.py +28 -0
- agent_lifecycle/imports/external_dialects.py +486 -0
- agent_lifecycle/imports/external_workflow.py +28 -0
- agent_lifecycle/imports/multi_markdown.py +111 -0
- agent_lifecycle/imports/openspec_profile.py +49 -0
- agent_lifecycle/imports/planning.py +536 -0
- agent_lifecycle/imports/spec_kit_profile.py +49 -0
- agent_lifecycle/imports/spec_kitty_profile.py +49 -0
- agent_lifecycle/metrics/__init__.py +60 -0
- agent_lifecycle/metrics/cost_collection.py +315 -0
- agent_lifecycle/metrics/costs.py +203 -0
- agent_lifecycle/metrics/outcome_index.py +330 -0
- agent_lifecycle/metrics/phase_resources.py +208 -0
- agent_lifecycle/metrics/recommendations.py +507 -0
- agent_lifecycle/metrics/regression_signals.py +64 -0
- agent_lifecycle/metrics/usage_export.py +390 -0
- agent_lifecycle/model_routing/__init__.py +19 -0
- agent_lifecycle/model_routing/profiles.py +323 -0
- agent_lifecycle/model_routing/receipts.py +140 -0
- agent_lifecycle/model_routing/resolver.py +566 -0
- agent_lifecycle/neutrality/__init__.py +12 -0
- agent_lifecycle/neutrality/__main__.py +6 -0
- agent_lifecycle/neutrality/authority.py +265 -0
- agent_lifecycle/neutrality/canonical.py +28 -0
- agent_lifecycle/neutrality/cli.py +294 -0
- agent_lifecycle/neutrality/ed25519.py +124 -0
- agent_lifecycle/neutrality/errors.py +89 -0
- agent_lifecycle/neutrality/gate.py +269 -0
- agent_lifecycle/neutrality/paths.py +92 -0
- agent_lifecycle/neutrality/policy.py +76 -0
- agent_lifecycle/neutrality/receipt.py +135 -0
- agent_lifecycle/neutrality/scanner.py +310 -0
- agent_lifecycle/neutrality/test_runner.py +116 -0
- agent_lifecycle/planning/__init__.py +47 -0
- agent_lifecycle/planning/acceptance_markdown.py +159 -0
- agent_lifecycle/planning/completeness.py +362 -0
- agent_lifecycle/planning/continuity.py +255 -0
- agent_lifecycle/planning/templates.py +294 -0
- agent_lifecycle/planning/tiering.py +87 -0
- agent_lifecycle/planning/validation.py +72 -0
- agent_lifecycle/policy/__init__.py +37 -0
- agent_lifecycle/policy/adaptive_lifecycle.py +416 -0
- agent_lifecycle/policy/apply.py +44 -0
- agent_lifecycle/policy/proposals.py +189 -0
- agent_lifecycle/policy/quality_floor.py +144 -0
- agent_lifecycle/policy/runtime_receipts.py +160 -0
- agent_lifecycle/py.typed +1 -0
- agent_lifecycle/quality/__init__.py +89 -0
- agent_lifecycle/quality/bug_forensics.py +727 -0
- agent_lifecycle/quality/bug_forensics_advisor.py +88 -0
- agent_lifecycle/quality/bug_forensics_recipes.py +239 -0
- agent_lifecycle/quality/cross_check.py +437 -0
- agent_lifecycle/quality/failure_classification.py +318 -0
- agent_lifecycle/quality/packs.py +265 -0
- agent_lifecycle/reporting/__init__.py +37 -0
- agent_lifecycle/reporting/change_summary.py +156 -0
- agent_lifecycle/reporting/event_feed.py +101 -0
- agent_lifecycle/reporting/progress_bridge.py +153 -0
- agent_lifecycle/reporting/progress_hooks.py +161 -0
- agent_lifecycle/reporting/progress_terminal.py +95 -0
- agent_lifecycle/reporting/progress_view.py +284 -0
- agent_lifecycle/reporting/status_view.py +125 -0
- agent_lifecycle/reporting/usage_export.py +59 -0
- agent_lifecycle/review/__init__.py +5 -0
- agent_lifecycle/review/validation.py +29 -0
- agent_lifecycle/review_mesh/__init__.py +83 -0
- agent_lifecycle/review_mesh/assignments.py +160 -0
- agent_lifecycle/review_mesh/contracts.py +685 -0
- agent_lifecycle/review_mesh/operator_templates.py +320 -0
- agent_lifecycle/review_mesh/quorum.py +38 -0
- agent_lifecycle/review_mesh/recommendation.py +487 -0
- agent_lifecycle/review_mesh/results.py +98 -0
- agent_lifecycle/review_mesh/synthesis.py +102 -0
- agent_lifecycle/runner/__init__.py +47 -0
- agent_lifecycle/runner/attempt_snapshots.py +244 -0
- agent_lifecycle/runner/core.py +653 -0
- agent_lifecycle/runner/sandbox_receipts.py +628 -0
- agent_lifecycle/specification/__init__.py +23 -0
- agent_lifecycle/specification/completion_check.py +135 -0
- agent_lifecycle/specification/completion_gate.py +501 -0
- agent_lifecycle/specification/completion_signal.py +75 -0
- agent_lifecycle/specification/validation.py +48 -0
- agent_lifecycle/workflow/__init__.py +79 -0
- agent_lifecycle/workflow/artifacts.py +74 -0
- agent_lifecycle/workflow/budget_decisions.py +370 -0
- agent_lifecycle/workflow/budget_policy.py +140 -0
- agent_lifecycle/workflow/budget_receipts.py +70 -0
- agent_lifecycle/workflow/bug_forensics_gates.py +284 -0
- agent_lifecycle/workflow/controller.py +45 -0
- agent_lifecycle/workflow/events.py +50 -0
- agent_lifecycle/workflow/final_proof_integrity.py +74 -0
- agent_lifecycle/workflow/finalization.py +422 -0
- agent_lifecycle/workflow/gates.py +185 -0
- agent_lifecycle/workflow/implementation_audit_gate.py +126 -0
- agent_lifecycle/workflow/leases.py +220 -0
- agent_lifecycle/workflow/lineage.py +108 -0
- agent_lifecycle/workflow/managed_runner.py +167 -0
- agent_lifecycle/workflow/model_usage.py +106 -0
- agent_lifecycle/workflow/next_action.py +86 -0
- agent_lifecycle/workflow/operation_kernel.py +98 -0
- agent_lifecycle/workflow/plan_adoption.py +439 -0
- agent_lifecycle/workflow/query.py +52 -0
- agent_lifecycle/workflow/review_mesh_gate.py +93 -0
- agent_lifecycle/workflow/reviews.py +168 -0
- agent_lifecycle/workflow/run_transitions.py +163 -0
- agent_lifecycle/workflow/sandbox_policy.py +214 -0
- agent_lifecycle/workflow/selectors.py +57 -0
- agent_lifecycle/workflow/state.py +130 -0
- agent_lifecycle/workflow/task_transitions.py +421 -0
- agent_lifecycle/worktree/__init__.py +17 -0
- agent_lifecycle/worktree/isolation.py +407 -0
- agent_lifecycle_kit-1.50.1.dist-info/METADATA +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/RECORD +227 -0
- agent_lifecycle_kit-1.50.1.dist-info/WHEEL +5 -0
- agent_lifecycle_kit-1.50.1.dist-info/entry_points.txt +3 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/LICENSE +201 -0
- agent_lifecycle_kit-1.50.1.dist-info/licenses/NOTICE +5 -0
- 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
|
+
}
|