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,135 @@
1
+ """Detached neutrality receipt generation and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .authority import RECEIPT_DOMAIN
9
+ from .canonical import canonical_bytes, load_json, sha256_hex
10
+ from .ed25519 import fingerprint, verify
11
+ from .errors import NeutralityError
12
+ from .paths import stable_read_bytes
13
+
14
+
15
+ def build_claims(
16
+ *,
17
+ operation: dict[str, Any],
18
+ report: dict[str, Any],
19
+ authority_digest: str,
20
+ primary_path: str,
21
+ receipt_path: str,
22
+ policy: dict[str, Any],
23
+ profile: dict[str, Any],
24
+ ) -> dict[str, Any]:
25
+ zero_counters = {
26
+ key: int(report["counters"].get(key, 0))
27
+ for key in [
28
+ "findings",
29
+ "skippedInputs",
30
+ "opaqueInputs",
31
+ "readRaces",
32
+ "incompleteScans",
33
+ "unsupportedArchives",
34
+ "archiveLimitBreaches",
35
+ "occupiedOutputConflicts",
36
+ "pathAliasConflicts",
37
+ ]
38
+ }
39
+ output_manifest = [
40
+ {
41
+ "publicationOrder": 1,
42
+ "role": "primary-artifact",
43
+ "path": primary_path,
44
+ "required": True,
45
+ "mustBeUnique": True,
46
+ "contentIdentityRule": "sha256",
47
+ },
48
+ {
49
+ "publicationOrder": 2,
50
+ "role": "detached-commit-receipt",
51
+ "path": receipt_path,
52
+ "required": True,
53
+ "mustBeUnique": True,
54
+ "contentIdentityRule": "canonical-signed-envelope",
55
+ },
56
+ ]
57
+ return {
58
+ "schemaVersion": "agent-neutrality-claims.v3",
59
+ "operation": operation,
60
+ "projectionSchemaVersion": "agent-neutrality-subject-projection.v2",
61
+ "profileDigest": sha256_hex(canonical_bytes(profile)),
62
+ "scopeProfileDigest": sha256_hex(canonical_bytes({"scope": report["scope"]})),
63
+ "archivePolicyDigest": sha256_hex(canonical_bytes(policy.get("archives", {}))),
64
+ "externalAuthorityContractDigest": sha256_hex(canonical_bytes({"contract": "AGENT_LIFECYCLE_NEUTRALITY_*"})),
65
+ "operationOutputManifestDigest": sha256_hex(canonical_bytes(output_manifest)),
66
+ "primaryArtifactManifestDigest": sha256_hex(canonical_bytes({"primary": primary_path})),
67
+ "workingTreeDigest": report["digests"]["workingTreeDigest"],
68
+ "gitObjectSetDigest": report["digests"]["gitObjectSetDigest"],
69
+ "subjectDigest": report["digests"]["subjectDigest"],
70
+ "authorityDigest": authority_digest,
71
+ "zeroCounters": zero_counters,
72
+ }
73
+
74
+
75
+ def build_receipt(
76
+ *,
77
+ operation: dict[str, Any],
78
+ claims: dict[str, Any],
79
+ primary_sha256: str,
80
+ primary_bytes: int,
81
+ signer_fingerprint: str,
82
+ signature: str,
83
+ ) -> dict[str, Any]:
84
+ claims_digest = sha256_hex(canonical_bytes(claims))
85
+ return {
86
+ "schemaVersion": "agent-neutrality-detached-receipt.v3",
87
+ "operation": operation,
88
+ "claimsDigest": claims_digest,
89
+ "primaryArtifact": {"sha256": primary_sha256, "bytes": primary_bytes},
90
+ "signer": {"algorithm": "Ed25519", "fingerprint": signer_fingerprint},
91
+ "signature": signature,
92
+ }
93
+
94
+
95
+ def verify_existing_receipt(
96
+ *,
97
+ receipt_path: Path,
98
+ primary_path: Path,
99
+ expected_operation: dict[str, Any] | None,
100
+ trust_root_path: Path,
101
+ expected_signer_fingerprint: str,
102
+ ) -> bool:
103
+ receipt = load_json(stable_read_bytes(receipt_path))
104
+ primary = stable_read_bytes(primary_path)
105
+ if receipt.get("schemaVersion") != "agent-neutrality-detached-receipt.v3":
106
+ return False
107
+ if expected_operation is not None and receipt.get("operation") != expected_operation:
108
+ return False
109
+ primary_identity = receipt.get("primaryArtifact")
110
+ if not isinstance(primary_identity, dict):
111
+ return False
112
+ if primary_identity.get("sha256") != sha256_hex(primary):
113
+ return False
114
+ if primary_identity.get("bytes") != len(primary):
115
+ return False
116
+ trust_root = load_json(stable_read_bytes(trust_root_path))
117
+ public_key = _public_key_for_fingerprint(trust_root, expected_signer_fingerprint)
118
+ claims_digest = receipt.get("claimsDigest")
119
+ signature = receipt.get("signature")
120
+ if not isinstance(claims_digest, str) or not isinstance(signature, str):
121
+ return False
122
+ return verify(public_key, RECEIPT_DOMAIN + claims_digest.encode("ascii"), bytes.fromhex(signature))
123
+
124
+
125
+ def _public_key_for_fingerprint(trust_root: dict[str, Any], expected_fingerprint: str) -> bytes:
126
+ if trust_root.get("schemaVersion") != "agent-neutrality-trust-root.v1":
127
+ raise NeutralityError("unsupported trust root schemaVersion")
128
+ for signer in trust_root.get("authorizedSigners", []):
129
+ if not isinstance(signer, dict) or signer.get("fingerprint") != expected_fingerprint:
130
+ continue
131
+ public_key = bytes.fromhex(str(signer.get("publicKeyHex")))
132
+ if fingerprint(public_key) != expected_fingerprint:
133
+ raise NeutralityError("trust root signer fingerprint mismatch")
134
+ return public_key
135
+ raise NeutralityError("expected signer is not authorized by trust root")
@@ -0,0 +1,310 @@
1
+ """Repository neutrality scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import stat
8
+ import subprocess
9
+ import zipfile
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Iterable, Pattern
13
+
14
+ from .canonical import canonical_bytes, sha256_hex
15
+ from .errors import NeutralityError
16
+ from .paths import stable_read_bytes, validate_output_paths, validate_repository_relative_path
17
+ from .policy import NeutralityPolicy
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class NeutralityFinding:
22
+ source: str
23
+ ruleId: str
24
+ category: str
25
+
26
+ def to_json(self) -> dict[str, str]:
27
+ return {"source": self.source, "ruleId": self.ruleId, "category": self.category}
28
+
29
+
30
+ @dataclass
31
+ class NeutralityReport:
32
+ scope: str
33
+ findings: list[NeutralityFinding] = field(default_factory=list)
34
+ skipped_inputs: int = 0
35
+ opaque_inputs: int = 0
36
+ read_races: int = 0
37
+ incomplete_scans: int = 0
38
+ unsupported_archives: int = 0
39
+ archive_limit_breaches: int = 0
40
+ occupied_output_conflicts: int = 0
41
+ path_alias_conflicts: int = 0
42
+ scanned_files: int = 0
43
+ scanned_git_objects: int = 0
44
+ working_tree_digest: str = "0" * 64
45
+ git_object_set_digest: str = "0" * 64
46
+
47
+ def to_json(self, operation: dict[str, Any]) -> dict[str, Any]:
48
+ counters = {
49
+ "findings": len(self.findings),
50
+ "skippedInputs": self.skipped_inputs,
51
+ "opaqueInputs": self.opaque_inputs,
52
+ "readRaces": self.read_races,
53
+ "incompleteScans": self.incomplete_scans,
54
+ "unsupportedArchives": self.unsupported_archives,
55
+ "archiveLimitBreaches": self.archive_limit_breaches,
56
+ "occupiedOutputConflicts": self.occupied_output_conflicts,
57
+ "pathAliasConflicts": self.path_alias_conflicts,
58
+ }
59
+ subject = {
60
+ "scope": self.scope,
61
+ "counters": counters,
62
+ "workingTreeDigest": self.working_tree_digest,
63
+ "gitObjectSetDigest": self.git_object_set_digest,
64
+ "findingIds": [finding.ruleId for finding in self.findings],
65
+ }
66
+ return {
67
+ "schemaVersion": "agent-neutrality-report.v1",
68
+ "operation": operation,
69
+ "scope": self.scope,
70
+ "counters": counters,
71
+ "scanned": {
72
+ "files": self.scanned_files,
73
+ "gitObjects": self.scanned_git_objects,
74
+ },
75
+ "digests": {
76
+ "workingTreeDigest": self.working_tree_digest,
77
+ "gitObjectSetDigest": self.git_object_set_digest,
78
+ "subjectDigest": sha256_hex(canonical_bytes(subject)),
79
+ },
80
+ "findings": [finding.to_json() for finding in self.findings],
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class ArchiveScanBudget:
86
+ archives: int = 0
87
+ entries: int = 0
88
+ expanded_bytes: int = 0
89
+
90
+
91
+ def scan_repository(
92
+ *,
93
+ workspace_root: Path,
94
+ policy: NeutralityPolicy,
95
+ deny_literals: Iterable[str],
96
+ deny_regexes: Iterable[str],
97
+ scope: str,
98
+ output_paths: list[Path],
99
+ ) -> NeutralityReport:
100
+ report = NeutralityReport(scope=scope)
101
+ try:
102
+ normalized_outputs = validate_output_paths(output_paths)
103
+ except NeutralityError:
104
+ report.path_alias_conflicts += 1
105
+ normalized_outputs = []
106
+ for output_path in output_paths:
107
+ if (workspace_root / output_path).exists():
108
+ report.occupied_output_conflicts += 1
109
+
110
+ literal_rules = tuple(deny_literals) + policy.deny_literals
111
+ regex_rules = tuple(re.compile(value) for value in deny_regexes) + policy.deny_regexes
112
+ archive_budget = ArchiveScanBudget()
113
+
114
+ worktree_entries: list[dict[str, Any]] = []
115
+ for rel_path in _walk_repository_files(workspace_root, policy):
116
+ path = workspace_root / rel_path
117
+ try:
118
+ validate_repository_relative_path(rel_path)
119
+ data = stable_read_bytes(path, max_bytes=policy.max_file_bytes)
120
+ except NeutralityError:
121
+ report.read_races += 1
122
+ continue
123
+ report.scanned_files += 1
124
+ report.findings.extend(_match_data(rel_path, data, literal_rules, regex_rules))
125
+ _scan_archive(rel_path, data, policy, report, literal_rules, regex_rules, archive_budget, depth=1)
126
+ worktree_entries.append({"path": rel_path, "sha256": sha256_hex(data), "bytes": len(data)})
127
+
128
+ git_entries: list[dict[str, Any]] = []
129
+ if scope == "full-repository" and (workspace_root / ".git").exists():
130
+ for object_id, data in _git_objects(workspace_root, policy):
131
+ report.scanned_git_objects += 1
132
+ report.findings.extend(_match_data(f"git:{object_id}", data, literal_rules, regex_rules))
133
+ git_entries.append({"object": object_id, "sha256": sha256_hex(data), "bytes": len(data)})
134
+
135
+ report.working_tree_digest = sha256_hex(canonical_bytes({"files": worktree_entries, "outputs": normalized_outputs}))
136
+ report.git_object_set_digest = sha256_hex(canonical_bytes({"objects": git_entries}))
137
+ return report
138
+
139
+
140
+ def _walk_repository_files(workspace_root: Path, policy: NeutralityPolicy) -> Iterable[str]:
141
+ for root, dirnames, filenames in os.walk(workspace_root):
142
+ root_path = Path(root)
143
+ rel_root = root_path.relative_to(workspace_root).as_posix()
144
+ dirnames[:] = [
145
+ dirname
146
+ for dirname in dirnames
147
+ if not _excluded(_join_rel(rel_root, dirname), policy.path_excludes)
148
+ ]
149
+ for filename in filenames:
150
+ rel_path = _join_rel(rel_root, filename)
151
+ if _excluded(rel_path, policy.path_excludes):
152
+ continue
153
+ mode = os.lstat(root_path / filename).st_mode
154
+ if stat.S_ISREG(mode):
155
+ yield rel_path
156
+
157
+
158
+ def _join_rel(root: str, name: str) -> str:
159
+ return name if root == "." else f"{root}/{name}"
160
+
161
+
162
+ def _excluded(path: str, excludes: tuple[Pattern[str], ...]) -> bool:
163
+ return any(pattern.search(path) for pattern in excludes)
164
+
165
+
166
+ def _match_data(
167
+ source: str,
168
+ data: bytes,
169
+ literal_rules: tuple[str, ...],
170
+ regex_rules: tuple[Pattern[str], ...],
171
+ ) -> list[NeutralityFinding]:
172
+ findings: list[NeutralityFinding] = []
173
+ text = data.decode("utf-8", errors="ignore")
174
+ for index, literal in enumerate(literal_rules):
175
+ if literal and literal in text:
176
+ findings.append(NeutralityFinding(source=source, ruleId=f"literal:{index}", category="deny-literal"))
177
+ for index, pattern in enumerate(regex_rules):
178
+ if pattern.search(text):
179
+ findings.append(NeutralityFinding(source=source, ruleId=f"regex:{index}", category="deny-regex"))
180
+ return findings
181
+
182
+
183
+ def _scan_archive(
184
+ source: str,
185
+ data: bytes,
186
+ policy: NeutralityPolicy,
187
+ report: NeutralityReport,
188
+ literal_rules: tuple[str, ...],
189
+ regex_rules: tuple[Pattern[str], ...],
190
+ archive_budget: ArchiveScanBudget,
191
+ *,
192
+ depth: int,
193
+ ) -> None:
194
+ if not data.startswith(b"PK\x03\x04"):
195
+ return
196
+ import io
197
+
198
+ if depth > policy.max_archive_nesting_depth:
199
+ report.archive_limit_breaches += 1
200
+ return
201
+ archive_budget.archives += 1
202
+ if archive_budget.archives > policy.max_archives_per_subject:
203
+ report.archive_limit_breaches += 1
204
+ return
205
+ if len(data) > policy.max_compressed_bytes_per_archive:
206
+ report.archive_limit_breaches += 1
207
+ return
208
+
209
+ try:
210
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
211
+ names: set[str] = set()
212
+ infos = archive.infolist()
213
+ if len(infos) > policy.max_archive_entries:
214
+ report.archive_limit_breaches += 1
215
+ return
216
+ expanded_bytes = sum(info.file_size for info in infos)
217
+ compressed_bytes = sum(info.compress_size for info in infos)
218
+ if expanded_bytes > policy.max_expanded_bytes_per_archive:
219
+ report.archive_limit_breaches += 1
220
+ return
221
+ if _ratio_exceeds(expanded_bytes, compressed_bytes, policy.max_compression_ratio):
222
+ report.archive_limit_breaches += 1
223
+ return
224
+ if archive_budget.entries + len(infos) > policy.max_entries_per_subject:
225
+ report.archive_limit_breaches += 1
226
+ return
227
+ if archive_budget.expanded_bytes + expanded_bytes > policy.max_expanded_bytes_per_subject:
228
+ report.archive_limit_breaches += 1
229
+ return
230
+ archive_budget.entries += len(infos)
231
+ archive_budget.expanded_bytes += expanded_bytes
232
+ for info in infos:
233
+ key = info.filename.casefold()
234
+ if key in names or info.filename.startswith("/") or ".." in Path(info.filename).parts:
235
+ report.archive_limit_breaches += 1
236
+ return
237
+ names.add(key)
238
+ if info.file_size > policy.max_expanded_entry_bytes:
239
+ report.archive_limit_breaches += 1
240
+ return
241
+ if _ratio_exceeds(info.file_size, info.compress_size, policy.max_compression_ratio):
242
+ report.archive_limit_breaches += 1
243
+ return
244
+ if info.is_dir():
245
+ continue
246
+ content = archive.read(info, pwd=None)
247
+ report.findings.extend(_match_data(f"{source}!{info.filename}", content, literal_rules, regex_rules))
248
+ _scan_archive(
249
+ f"{source}!{info.filename}",
250
+ content,
251
+ policy,
252
+ report,
253
+ literal_rules,
254
+ regex_rules,
255
+ archive_budget,
256
+ depth=depth + 1,
257
+ )
258
+ except (zipfile.BadZipFile, RuntimeError):
259
+ report.unsupported_archives += 1
260
+
261
+
262
+ def _ratio_exceeds(expanded_bytes: int, compressed_bytes: int, max_ratio: int) -> bool:
263
+ if expanded_bytes <= 0:
264
+ return False
265
+ if compressed_bytes <= 0:
266
+ return True
267
+ return expanded_bytes > compressed_bytes * max_ratio
268
+
269
+
270
+ def _git_objects(workspace_root: Path, policy: NeutralityPolicy) -> Iterable[tuple[str, bytes]]:
271
+ try:
272
+ objects = subprocess.run(
273
+ ["git", "rev-list", "--objects", "--all", "--reflog"],
274
+ cwd=workspace_root,
275
+ check=True,
276
+ stdout=subprocess.PIPE,
277
+ stderr=subprocess.PIPE,
278
+ text=True,
279
+ ).stdout.splitlines()
280
+ except (OSError, subprocess.CalledProcessError):
281
+ return
282
+ seen: set[str] = set()
283
+ for line in objects:
284
+ object_id = line.split(" ", 1)[0]
285
+ if object_id in seen:
286
+ continue
287
+ seen.add(object_id)
288
+ try:
289
+ size = int(
290
+ subprocess.run(
291
+ ["git", "cat-file", "-s", object_id],
292
+ cwd=workspace_root,
293
+ check=True,
294
+ stdout=subprocess.PIPE,
295
+ stderr=subprocess.PIPE,
296
+ text=True,
297
+ ).stdout.strip()
298
+ )
299
+ if size > policy.max_object_bytes:
300
+ continue
301
+ data = subprocess.run(
302
+ ["git", "cat-file", "-p", object_id],
303
+ cwd=workspace_root,
304
+ check=True,
305
+ stdout=subprocess.PIPE,
306
+ stderr=subprocess.PIPE,
307
+ ).stdout
308
+ except (OSError, subprocess.CalledProcessError, ValueError):
309
+ continue
310
+ yield object_id, data
@@ -0,0 +1,116 @@
1
+ """Stdlib unittest runner for controller validation commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import unittest
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ if __package__ in (None, ""):
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
14
+
15
+ from agent_lifecycle.neutrality.canonical import load_json, write_json_create
16
+ from agent_lifecycle.neutrality.errors import NeutralityError, write_neutrality_error
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog="agent-lifecycle-unittest-runner")
21
+ parser.add_argument("--operation-request", required=True)
22
+ parser.add_argument("--start-directory", required=True)
23
+ parser.add_argument("--pattern", default="test*.py")
24
+ parser.add_argument("--report", required=True)
25
+ args = parser.parse_args(argv)
26
+
27
+ try:
28
+ _require_protocol_output(
29
+ request_path=args.operation_request,
30
+ output_path=args.report,
31
+ schema_version="agent-lifecycle-unittest-report.v1",
32
+ )
33
+ result = _run_suite(args.start_directory, args.pattern)
34
+ report = _build_report(args.operation_request, args.start_directory, args.pattern, result)
35
+ write_json_create(Path(args.report), report)
36
+ return 0 if report["verdict"] == "PASS" else 1
37
+ except NeutralityError as exc:
38
+ write_neutrality_error(exc, sys.stdout)
39
+ return 2
40
+
41
+
42
+ def _run_suite(start_directory: str, pattern: str) -> unittest.TestResult:
43
+ loader = unittest.TestLoader()
44
+ suite = loader.discover(start_dir=start_directory, pattern=pattern)
45
+ runner = unittest.TextTestRunner(stream=sys.stderr, verbosity=1)
46
+ return runner.run(suite)
47
+
48
+
49
+ def _build_report(
50
+ operation_request_path: str,
51
+ start_directory: str,
52
+ pattern: str,
53
+ result: unittest.TestResult,
54
+ ) -> dict[str, Any]:
55
+ request = load_json(Path(operation_request_path).read_bytes())
56
+ claims = request.get("claims")
57
+ if not isinstance(claims, dict):
58
+ raise NeutralityError("operation request claims are missing")
59
+ operation_keys = [
60
+ "runId",
61
+ "packageId",
62
+ "taskId",
63
+ "attempt",
64
+ "operationId",
65
+ "commandId",
66
+ "commandOperationId",
67
+ "planDigest",
68
+ "sourceRevision",
69
+ "projectionDigest",
70
+ ]
71
+ return {
72
+ "schemaVersion": "agent-lifecycle-unittest-report.v1",
73
+ "operation": {
74
+ "schemaVersion": "agent-local-unittest-operation.v1",
75
+ "requestClaimsDigest": request.get("claimsDigest"),
76
+ **{key: claims.get(key) for key in operation_keys},
77
+ },
78
+ "suite": {
79
+ "startDirectory": start_directory,
80
+ "pattern": pattern,
81
+ "testsRun": result.testsRun,
82
+ "failures": len(result.failures),
83
+ "errors": len(result.errors),
84
+ "skipped": len(result.skipped),
85
+ "expectedFailures": len(result.expectedFailures),
86
+ "unexpectedSuccesses": len(result.unexpectedSuccesses),
87
+ },
88
+ "verdict": "PASS" if result.wasSuccessful() else "FAIL",
89
+ "generatedAt": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
90
+ }
91
+
92
+
93
+ def _require_protocol_output(
94
+ *,
95
+ request_path: str,
96
+ output_path: str,
97
+ schema_version: str,
98
+ ) -> None:
99
+ request = load_json(Path(request_path).read_bytes())
100
+ claims = request.get("claims")
101
+ outputs = claims.get("protocolOutputs") if isinstance(claims, dict) else None
102
+ if not isinstance(outputs, list):
103
+ raise NeutralityError("operation request protocolOutputs are missing")
104
+ for item in outputs:
105
+ if (
106
+ isinstance(item, dict)
107
+ and item.get("path") == output_path
108
+ and item.get("schemaVersion") == schema_version
109
+ and item.get("createNoReplace") is True
110
+ ):
111
+ return
112
+ raise NeutralityError("report path is not authorized by operation request")
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
@@ -0,0 +1,47 @@
1
+ """Agent-ready plan validation primitives."""
2
+
3
+ from agent_lifecycle.planning.acceptance_markdown import validate_acceptance_checklist
4
+ from agent_lifecycle.planning.continuity import (
5
+ build_plan_snapshot,
6
+ reconcile_plan_snapshot,
7
+ render_plan_handoff,
8
+ require_reconciliation_pass,
9
+ require_repository_references_pass,
10
+ validate_repository_references,
11
+ )
12
+ from agent_lifecycle.planning.completeness import (
13
+ build_plan_completeness_profile,
14
+ load_plan_completeness_profile,
15
+ require_plan_completeness_pass,
16
+ validate_plan_completeness,
17
+ validate_plan_completeness_profile,
18
+ )
19
+ from agent_lifecycle.planning.tiering import resolve_sdd_tier
20
+ from agent_lifecycle.planning.templates import (
21
+ build_task_template_library,
22
+ render_task_template,
23
+ require_task_template_validation_pass,
24
+ validate_task_template_library,
25
+ )
26
+ from agent_lifecycle.planning.validation import validate_plan_manifest
27
+
28
+ __all__ = [
29
+ "build_plan_snapshot",
30
+ "build_plan_completeness_profile",
31
+ "build_task_template_library",
32
+ "load_plan_completeness_profile",
33
+ "reconcile_plan_snapshot",
34
+ "render_task_template",
35
+ "render_plan_handoff",
36
+ "require_reconciliation_pass",
37
+ "require_repository_references_pass",
38
+ "require_plan_completeness_pass",
39
+ "require_task_template_validation_pass",
40
+ "resolve_sdd_tier",
41
+ "validate_acceptance_checklist",
42
+ "validate_plan_completeness",
43
+ "validate_plan_completeness_profile",
44
+ "validate_plan_manifest",
45
+ "validate_repository_references",
46
+ "validate_task_template_library",
47
+ ]