okstra 0.163.2 → 0.164.0

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 (112) hide show
  1. package/README.md +7 -5
  2. package/docs/architecture.md +12 -7
  3. package/docs/cli.md +8 -5
  4. package/docs/project-structure-overview.md +7 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/claude-worker.md +1 -0
  8. package/runtime/bin/lib/okstra/cli.sh +5 -0
  9. package/runtime/bin/lib/okstra/globals.sh +2 -0
  10. package/runtime/bin/lib/okstra/usage.sh +5 -5
  11. package/runtime/bin/okstra.sh +87 -91
  12. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  13. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  14. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  15. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  16. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  17. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  18. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  19. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  20. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  21. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  22. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  23. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  24. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  25. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  26. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  27. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  28. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  29. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
  30. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  31. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  32. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  33. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  34. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  35. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
  36. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  37. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  39. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  41. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
  43. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  44. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
  45. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  46. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
  47. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  48. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
  49. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  50. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
  51. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  52. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  53. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  54. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  55. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  56. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  57. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  58. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  59. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  60. package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
  61. package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
  62. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  63. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  64. package/runtime/python/okstra_ctl/domain/provider.py +66 -0
  65. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  66. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  67. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  68. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  69. package/runtime/python/okstra_ctl/models.py +54 -269
  70. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  71. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  72. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  73. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  74. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  75. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  76. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  77. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  78. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  79. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  80. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  81. package/runtime/python/okstra_ctl/render.py +145 -47
  82. package/runtime/python/okstra_ctl/run.py +80 -58
  83. package/runtime/python/okstra_ctl/session.py +1 -1
  84. package/runtime/python/okstra_ctl/team.py +44 -32
  85. package/runtime/python/okstra_ctl/wizard.py +162 -64
  86. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  87. package/runtime/python/okstra_token_usage/collect.py +34 -6
  88. package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
  89. package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
  90. package/runtime/skills/okstra-run/SKILL.md +73 -28
  91. package/runtime/templates/implementation-worker-preamble.md +1 -1
  92. package/runtime/templates/reports/final-report.template.md +3 -3
  93. package/runtime/templates/reports/html/i18n/en.json +3 -1
  94. package/runtime/templates/reports/html/i18n/ko.json +3 -1
  95. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
  96. package/runtime/templates/worker-prompt-preamble.md +1 -1
  97. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  98. package/runtime/validators/validate-report-views.py +0 -39
  99. package/runtime/validators/validate-run.py +8 -7
  100. package/runtime/validators/validate_session_conformance.py +7 -2
  101. package/src/commands/execute/render-bundle.mjs +4 -4
  102. package/src/commands/execute/run.mjs +8 -25
  103. package/src/commands/execute/wizard.mjs +33 -13
  104. package/src/commands/lifecycle/doctor.mjs +10 -10
  105. package/src/commands/lifecycle/install.mjs +53 -30
  106. package/src/commands/lifecycle/preflight.mjs +14 -4
  107. package/src/lib/host-registry-client.mjs +176 -0
  108. package/src/lib/runtime-manifest.mjs +6 -8
  109. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  110. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  111. package/src/lib/runtime-readiness.mjs +0 -90
  112. package/src/lib/runtime-resolver.mjs +0 -123
@@ -0,0 +1,124 @@
1
+ """Discover host manifests inside explicitly trusted adapter roots."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..domain.host import HostAdapterContractError
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HostManifest:
14
+ path: Path
15
+ host_id: str
16
+ factory_ref: str
17
+ native_provider_id: str
18
+ required_executables: tuple[str, ...]
19
+ relay_contract: Path
20
+
21
+
22
+ def discover_host_manifests(roots: Iterable[Path]) -> tuple[HostManifest, ...]:
23
+ manifests: list[HostManifest] = []
24
+ for manifest_path in _manifest_paths(roots):
25
+ manifests.append(_read_manifest(manifest_path))
26
+ return tuple(manifests)
27
+
28
+
29
+ def _manifest_paths(roots: Iterable[Path]) -> tuple[Path, ...]:
30
+ manifests: list[Path] = []
31
+ for root in roots:
32
+ if root.is_symlink():
33
+ raise HostAdapterContractError(
34
+ f"host discovery root is symbolic link: {root}"
35
+ )
36
+ if not root.is_dir():
37
+ continue
38
+ resolved_root = root.resolve()
39
+ for manifest_path in sorted(root.glob("*/manifest.json")):
40
+ resolved_manifest = manifest_path.resolve()
41
+ if resolved_root not in resolved_manifest.parents:
42
+ raise HostAdapterContractError(
43
+ f"host adapter escapes discovery root: {manifest_path}"
44
+ )
45
+ manifests.append(resolved_manifest)
46
+ return tuple(manifests)
47
+
48
+
49
+ def _read_manifest(manifest_path: Path) -> HostManifest:
50
+ raw = _read_json_object(manifest_path)
51
+ host_id = _required_id(raw, "id", manifest_path)
52
+ factory_ref = _required_string(raw, "factory", manifest_path)
53
+ native_provider_id = _optional_id(raw, "nativeProviderId", manifest_path)
54
+ required_executables = _string_tuple(raw, "requiredExecutables", manifest_path)
55
+ relay_ref = _required_string(raw, "relayContract", manifest_path)
56
+ relay_contract = _resolve_relay_contract(manifest_path, relay_ref)
57
+ return HostManifest(
58
+ path=manifest_path,
59
+ host_id=host_id,
60
+ factory_ref=factory_ref,
61
+ native_provider_id=native_provider_id,
62
+ required_executables=required_executables,
63
+ relay_contract=relay_contract,
64
+ )
65
+
66
+
67
+ def _read_json_object(manifest_path: Path) -> dict[str, object]:
68
+ try:
69
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
70
+ except (OSError, json.JSONDecodeError) as exc:
71
+ raise HostAdapterContractError(
72
+ f"invalid host manifest: {manifest_path}"
73
+ ) from exc
74
+ if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
75
+ raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
76
+ return raw
77
+
78
+
79
+ def _required_id(raw: dict[str, object], key: str, manifest_path: Path) -> str:
80
+ value = _required_string(raw, key, manifest_path).strip().lower()
81
+ if not value:
82
+ raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
83
+ return value
84
+
85
+
86
+ def _optional_id(raw: dict[str, object], key: str, manifest_path: Path) -> str:
87
+ value = raw.get(key, "")
88
+ if not isinstance(value, str):
89
+ raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
90
+ return value.strip().lower()
91
+
92
+
93
+ def _required_string(
94
+ raw: dict[str, object], key: str, manifest_path: Path
95
+ ) -> str:
96
+ value = raw.get(key)
97
+ if not isinstance(value, str) or not value:
98
+ raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
99
+ return value
100
+
101
+
102
+ def _string_tuple(
103
+ raw: dict[str, object], key: str, manifest_path: Path
104
+ ) -> tuple[str, ...]:
105
+ value = raw.get(key)
106
+ if not isinstance(value, list) or not all(
107
+ isinstance(item, str) and item for item in value
108
+ ):
109
+ raise HostAdapterContractError(f"invalid host manifest: {manifest_path}")
110
+ return tuple(value)
111
+
112
+
113
+ def _resolve_relay_contract(manifest_path: Path, relay_ref: str) -> Path:
114
+ adapter_root = manifest_path.parent.resolve()
115
+ if Path(relay_ref).is_absolute():
116
+ raise HostAdapterContractError(
117
+ f"relay contract escapes adapter root: {relay_ref!r}"
118
+ )
119
+ relay_contract = (adapter_root / relay_ref).resolve()
120
+ if adapter_root not in relay_contract.parents or not relay_contract.is_file():
121
+ raise HostAdapterContractError(
122
+ f"relay contract escapes adapter root: {relay_ref!r}"
123
+ )
124
+ return relay_contract
@@ -0,0 +1,365 @@
1
+ """Register and resolve discovered host adapter strategies."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ from collections.abc import Iterable, Mapping
6
+ from pathlib import Path
7
+
8
+ from okstra_project.dirs import okstra_home
9
+
10
+ from ..domain.host import (
11
+ HostAdapterContractError,
12
+ HostClaim,
13
+ HostDescriptor,
14
+ HostNotRegistered,
15
+ HostResolutionContext,
16
+ HostUnavailable,
17
+ ProviderNotRegistered,
18
+ )
19
+ from ..ports import HostAdapter
20
+ from .factory_loader import FactoryLoadError, load_relative_factory
21
+ from .host_discovery import HostManifest, discover_host_manifests
22
+ from .provider_registry import ProviderRegistry, default_provider_registry
23
+
24
+
25
+ _ADAPTER_METHODS = (
26
+ "detect",
27
+ "probe",
28
+ "interaction",
29
+ "lead_session",
30
+ "worker_dispatch",
31
+ "usage_accounting",
32
+ )
33
+ _PORT_METHODS = {
34
+ "interaction": ("plan",),
35
+ "lead_session": ("build_start", "build_resume"),
36
+ "worker_dispatch": ("build_plan",),
37
+ "usage_accounting": ("collect",),
38
+ }
39
+
40
+
41
+ class _RegisteredHostAdapter:
42
+ def __init__(self, descriptor: HostDescriptor, adapter: object) -> None:
43
+ self.descriptor = descriptor
44
+ self._adapter = adapter
45
+
46
+ def detect(self, context):
47
+ return self._adapter.detect(context)
48
+
49
+ def probe(self, context):
50
+ return self._adapter.probe(context)
51
+
52
+ def interaction(self):
53
+ return self._adapter.interaction()
54
+
55
+ def lead_session(self):
56
+ return self._adapter.lead_session()
57
+
58
+ def worker_dispatch(self):
59
+ return self._adapter.worker_dispatch()
60
+
61
+ def usage_accounting(self):
62
+ return self._adapter.usage_accounting()
63
+
64
+
65
+ class HostAdapterRegistry:
66
+ def __init__(
67
+ self,
68
+ adapters: Mapping[str, HostAdapter],
69
+ aliases: Mapping[str, HostAdapter],
70
+ ) -> None:
71
+ self._adapters = dict(adapters)
72
+ self._aliases = dict(aliases)
73
+
74
+ @classmethod
75
+ def from_roots(
76
+ cls,
77
+ roots: Iterable[Path],
78
+ provider_registry: ProviderRegistry,
79
+ ) -> "HostAdapterRegistry":
80
+ entries = _load_entries(roots, provider_registry)
81
+ adapters = _index_host_ids(entries)
82
+ aliases = _index_aliases(entries, adapters)
83
+ return cls(adapters, aliases)
84
+
85
+ def resolve(self, host_id_or_alias: str) -> HostAdapter:
86
+ normalized = host_id_or_alias.strip().lower()
87
+ adapter = self._aliases.get(normalized)
88
+ if adapter is not None:
89
+ return adapter
90
+ allowed = ", ".join(self._adapters)
91
+ raise HostNotRegistered(
92
+ f"unknown host {host_id_or_alias!r}. Allowed values: {allowed}"
93
+ )
94
+
95
+ def resolve_request(
96
+ self,
97
+ requested_host: str,
98
+ context: HostResolutionContext,
99
+ ) -> HostDescriptor:
100
+ descriptor, _, _ = self.resolve_request_details(requested_host, context)
101
+ return descriptor
102
+
103
+ def resolve_request_details(
104
+ self,
105
+ requested_host: str,
106
+ context: HostResolutionContext,
107
+ ) -> tuple[HostDescriptor, str, str]:
108
+ normalized = requested_host.strip().lower()
109
+ if normalized and normalized != "auto":
110
+ descriptor = self.resolve(normalized).descriptor
111
+ return descriptor, "explicit", (
112
+ f"Explicit runtime {descriptor.id!r} requested."
113
+ )
114
+ env_host = context.explicit_env_host.strip()
115
+ if env_host and env_host.lower() != "auto":
116
+ descriptor = self.resolve(env_host).descriptor
117
+ return descriptor, descriptor.id, (
118
+ f"OKSTRA_RUNTIME_HOST={env_host} selected runtime {descriptor.id!r}."
119
+ )
120
+ adapter, claim = self._resolve_claim(context)
121
+ return adapter.descriptor, adapter.descriptor.id, claim.reason
122
+
123
+ def ids(self) -> tuple[str, ...]:
124
+ return tuple(self._adapters)
125
+
126
+ def catalog(self) -> tuple[HostDescriptor, ...]:
127
+ return tuple(adapter.descriptor for adapter in self._adapters.values())
128
+
129
+ def _resolve_claim(
130
+ self, context: HostResolutionContext
131
+ ) -> tuple[HostAdapter, HostClaim]:
132
+ claims = tuple(
133
+ claim
134
+ for adapter in self._adapters.values()
135
+ if (claim := _detect_claim(adapter, context)) is not None
136
+ )
137
+ if not claims:
138
+ raise HostUnavailable("no host adapter claimed the current session")
139
+ highest_priority = max(claim.priority for claim in claims)
140
+ winners = tuple(
141
+ claim for claim in claims if claim.priority == highest_priority
142
+ )
143
+ if len(winners) != 1:
144
+ host_ids = ", ".join(sorted(claim.host_id for claim in winners))
145
+ raise HostUnavailable(f"ambiguous host adapter claims: {host_ids}")
146
+ winner = winners[0]
147
+ return self.resolve(winner.host_id), winner
148
+
149
+
150
+ def default_host_registry(
151
+ provider_registry: ProviderRegistry | None = None,
152
+ ) -> HostAdapterRegistry:
153
+ bundled_root = Path(__file__).resolve().parents[1] / "adapters" / "hosts"
154
+ user_root = okstra_home() / "adapters" / "hosts"
155
+ providers = provider_registry or default_provider_registry()
156
+ return HostAdapterRegistry.from_roots((bundled_root, user_root), providers)
157
+
158
+
159
+ def _load_entries(
160
+ roots: Iterable[Path], provider_registry: ProviderRegistry
161
+ ) -> tuple[tuple[HostManifest, HostAdapter], ...]:
162
+ entries: list[tuple[HostManifest, HostAdapter]] = []
163
+ for manifest in discover_host_manifests(roots):
164
+ _validate_provider(manifest, provider_registry)
165
+ entries.append((manifest, _load_host_adapter(manifest, provider_registry)))
166
+ return tuple(entries)
167
+
168
+
169
+ def _validate_provider(
170
+ manifest: HostManifest, provider_registry: ProviderRegistry
171
+ ) -> None:
172
+ if not manifest.native_provider_id:
173
+ return
174
+ if manifest.native_provider_id not in provider_registry.providers:
175
+ raise ProviderNotRegistered(
176
+ f"native provider {manifest.native_provider_id!r} is not registered"
177
+ )
178
+
179
+
180
+ def _load_host_adapter(
181
+ manifest: HostManifest,
182
+ provider_registry: ProviderRegistry,
183
+ ) -> HostAdapter:
184
+ try:
185
+ factory = load_relative_factory(manifest.path, manifest.factory_ref)
186
+ parameters = inspect.signature(factory).parameters.values()
187
+ accepts_registry = any(
188
+ parameter.name == "provider_registry"
189
+ or parameter.kind is inspect.Parameter.VAR_KEYWORD
190
+ for parameter in parameters
191
+ )
192
+ adapter = (
193
+ factory(provider_registry=provider_registry)
194
+ if accepts_registry
195
+ else factory()
196
+ )
197
+ except FactoryLoadError as exc:
198
+ raise HostAdapterContractError(str(exc)) from exc
199
+ except Exception as exc:
200
+ raise HostAdapterContractError(
201
+ f"host factory failed for {manifest.host_id!r}"
202
+ ) from exc
203
+ descriptor = _validated_descriptor(adapter, manifest)
204
+ return _RegisteredHostAdapter(descriptor, adapter)
205
+
206
+
207
+ def _validated_descriptor(adapter: object, manifest: HostManifest) -> HostDescriptor:
208
+ for method_name in _ADAPTER_METHODS:
209
+ if not callable(getattr(adapter, method_name, None)):
210
+ raise HostAdapterContractError(
211
+ f"host factory returned invalid adapter {manifest.host_id!r}"
212
+ )
213
+ _validate_ports(adapter, manifest.host_id)
214
+ descriptor = _copy_descriptor(getattr(adapter, "descriptor", None), manifest.host_id)
215
+ if descriptor.id != manifest.host_id:
216
+ raise HostAdapterContractError(f"host id mismatch for {manifest.host_id!r}")
217
+ if descriptor.native_provider_id != manifest.native_provider_id:
218
+ raise HostAdapterContractError(
219
+ f"native provider mismatch for {manifest.host_id!r}"
220
+ )
221
+ if descriptor.required_executables != manifest.required_executables:
222
+ raise HostAdapterContractError(
223
+ f"required executables mismatch for {manifest.host_id!r}"
224
+ )
225
+ if Path(descriptor.relay_contract).resolve() != manifest.relay_contract:
226
+ raise HostAdapterContractError(
227
+ f"relay contract mismatch for {manifest.host_id!r}"
228
+ )
229
+ return descriptor
230
+
231
+
232
+ def _validate_ports(adapter: object, host_id: str) -> None:
233
+ for accessor, required_methods in _PORT_METHODS.items():
234
+ try:
235
+ port = getattr(adapter, accessor)()
236
+ except Exception as exc:
237
+ raise HostAdapterContractError(
238
+ f"host {host_id!r} {accessor} port could not be created"
239
+ ) from exc
240
+ missing = tuple(
241
+ method for method in required_methods
242
+ if not callable(getattr(port, method, None))
243
+ )
244
+ if missing:
245
+ required = ", ".join(missing)
246
+ raise HostAdapterContractError(
247
+ f"host {host_id!r} {accessor} port is missing {required}"
248
+ )
249
+
250
+
251
+ def _copy_descriptor(raw: object, host_id: str) -> HostDescriptor:
252
+ try:
253
+ descriptor = HostDescriptor(
254
+ id=raw.id,
255
+ aliases=raw.aliases,
256
+ native_provider_id=raw.native_provider_id,
257
+ required_executables=raw.required_executables,
258
+ launch_mode=raw.launch_mode,
259
+ install_targets=raw.install_targets,
260
+ agent_id=raw.agent_id,
261
+ agent_label=raw.agent_label,
262
+ role=raw.role,
263
+ dispatch_mode=raw.dispatch_mode,
264
+ session_accounting=raw.session_accounting,
265
+ has_claude_session=raw.has_claude_session,
266
+ relay_contract=raw.relay_contract,
267
+ )
268
+ except (AttributeError, TypeError) as exc:
269
+ raise HostAdapterContractError(
270
+ f"host factory returned invalid descriptor {host_id!r}"
271
+ ) from exc
272
+ if not _descriptor_fields_are_valid(descriptor):
273
+ raise HostAdapterContractError(
274
+ f"host factory returned invalid descriptor {host_id!r}"
275
+ )
276
+ return descriptor
277
+
278
+
279
+ def _descriptor_fields_are_valid(descriptor: HostDescriptor) -> bool:
280
+ normalized_ids = (descriptor.id, descriptor.native_provider_id)
281
+ if not all(isinstance(value, str) for value in normalized_ids):
282
+ return False
283
+ if not descriptor.id or descriptor.id != descriptor.id.strip().lower():
284
+ return False
285
+ if descriptor.native_provider_id != descriptor.native_provider_id.strip().lower():
286
+ return False
287
+ if not isinstance(descriptor.aliases, tuple) or not all(
288
+ isinstance(alias, str) and alias == alias.strip().lower() and alias
289
+ for alias in descriptor.aliases
290
+ ):
291
+ return False
292
+ if not isinstance(descriptor.required_executables, tuple) or not all(
293
+ isinstance(executable, str) and executable
294
+ for executable in descriptor.required_executables
295
+ ):
296
+ return False
297
+ if descriptor.launch_mode not in {"lead", "team"}:
298
+ return False
299
+ if not isinstance(descriptor.install_targets, frozenset) or not all(
300
+ isinstance(target, str) and target for target in descriptor.install_targets
301
+ ):
302
+ return False
303
+ text_fields = (
304
+ descriptor.agent_id,
305
+ descriptor.agent_label,
306
+ descriptor.role,
307
+ descriptor.dispatch_mode,
308
+ descriptor.session_accounting,
309
+ descriptor.relay_contract,
310
+ )
311
+ return all(isinstance(value, str) and value for value in text_fields) and isinstance(
312
+ descriptor.has_claude_session, bool
313
+ )
314
+
315
+
316
+ def _index_host_ids(
317
+ entries: tuple[tuple[HostManifest, HostAdapter], ...]
318
+ ) -> dict[str, HostAdapter]:
319
+ adapters: dict[str, HostAdapter] = {}
320
+ for manifest, adapter in entries:
321
+ if manifest.host_id in adapters:
322
+ raise HostAdapterContractError(
323
+ f"duplicate host id {manifest.host_id!r}"
324
+ )
325
+ adapters[manifest.host_id] = adapter
326
+ return adapters
327
+
328
+
329
+ def _index_aliases(
330
+ entries: tuple[tuple[HostManifest, HostAdapter], ...],
331
+ adapters: Mapping[str, HostAdapter],
332
+ ) -> dict[str, HostAdapter]:
333
+ aliases = dict(adapters)
334
+ for _, adapter in entries:
335
+ for raw_alias in adapter.descriptor.aliases:
336
+ alias = raw_alias.strip().lower()
337
+ existing = aliases.get(alias)
338
+ if not alias or (existing is not None and existing is not adapter):
339
+ raise HostAdapterContractError(f"duplicate host key {alias!r}")
340
+ aliases[alias] = adapter
341
+ return aliases
342
+
343
+
344
+ def _detect_claim(
345
+ adapter: HostAdapter, context: HostResolutionContext
346
+ ) -> HostClaim | None:
347
+ try:
348
+ raw_claim = adapter.detect(context)
349
+ except Exception as exc:
350
+ raise HostAdapterContractError(
351
+ f"host detector failed for {adapter.descriptor.id!r}"
352
+ ) from exc
353
+ if raw_claim is None:
354
+ return None
355
+ try:
356
+ claim = HostClaim(raw_claim.host_id, raw_claim.priority, raw_claim.reason)
357
+ except (AttributeError, TypeError) as exc:
358
+ raise HostAdapterContractError(
359
+ f"host detector returned invalid claim for {adapter.descriptor.id!r}"
360
+ ) from exc
361
+ if claim.host_id != adapter.descriptor.id or not isinstance(claim.priority, int):
362
+ raise HostAdapterContractError(
363
+ f"host detector returned invalid claim for {adapter.descriptor.id!r}"
364
+ )
365
+ return claim
@@ -0,0 +1,149 @@
1
+ """Discover provider adapters from the bundled and Okstra-user locations."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from collections.abc import Iterable, Mapping
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+
9
+ from okstra_project.dirs import okstra_home
10
+
11
+ from ..domain.provider import ProviderSpec, UnknownProviderError
12
+ from .factory_loader import FactoryLoadError, load_relative_factory
13
+
14
+
15
+ class ProviderAdapterContractError(ValueError):
16
+ """Raised when a discovered provider adapter violates its contract."""
17
+
18
+
19
+ class ProviderRegistry:
20
+ """Resolved provider specifications, indexed by their normalized id."""
21
+
22
+ def __init__(self, providers: Mapping[str, ProviderSpec]) -> None:
23
+ self._providers = dict(providers)
24
+
25
+ @classmethod
26
+ def from_roots(cls, roots: Iterable[Path]) -> "ProviderRegistry":
27
+ providers: dict[str, ProviderSpec] = {}
28
+ for manifest_path, provider_id, factory_ref in _manifest_entries(roots):
29
+ spec = _load_provider_spec(manifest_path, provider_id, factory_ref)
30
+ providers[provider_id] = spec
31
+ return cls(providers)
32
+
33
+ def resolve(self, provider_id: str) -> ProviderSpec:
34
+ normalized = provider_id.strip().lower()
35
+ try:
36
+ return self._providers[normalized]
37
+ except KeyError as exc:
38
+ allowed = ", ".join(self._providers)
39
+ raise UnknownProviderError(
40
+ f"unknown provider {provider_id!r}. Allowed values: {allowed}"
41
+ ) from exc
42
+
43
+ def ids(self, role: str | None = None) -> tuple[str, ...]:
44
+ if role is None:
45
+ return tuple(self._providers)
46
+ return tuple(
47
+ provider_id
48
+ for provider_id, spec in self._providers.items()
49
+ if role in spec.supported_roles
50
+ )
51
+
52
+ @property
53
+ def providers(self) -> Mapping[str, ProviderSpec]:
54
+ return dict(self._providers)
55
+
56
+
57
+ def default_provider_registry() -> ProviderRegistry:
58
+ """Load bundled providers plus only adapters owned by the Okstra user home."""
59
+ bundled_root = Path(__file__).resolve().parents[1] / "adapters" / "providers"
60
+ user_root = okstra_home() / "adapters" / "providers"
61
+ if user_root.is_symlink():
62
+ raise ProviderAdapterContractError(
63
+ f"provider discovery root is symbolic link: {user_root}"
64
+ )
65
+ return _registry_for_roots(bundled_root.resolve(), user_root.resolve())
66
+
67
+
68
+ @lru_cache(maxsize=None)
69
+ def _registry_for_roots(
70
+ bundled_root: Path,
71
+ user_root: Path,
72
+ ) -> ProviderRegistry:
73
+ discovered = ProviderRegistry.from_roots((bundled_root, user_root)).providers
74
+ bundled_order = ("claude", "antigravity", "codex", "grok", "kimi")
75
+ ordered = {provider_id: discovered[provider_id] for provider_id in bundled_order}
76
+ ordered.update(
77
+ (provider_id, spec)
78
+ for provider_id, spec in discovered.items()
79
+ if provider_id not in ordered
80
+ )
81
+ return ProviderRegistry(ordered)
82
+
83
+
84
+ def _manifest_entries(roots: Iterable[Path]) -> list[tuple[Path, str, str]]:
85
+ entries: list[tuple[Path, str, str]] = []
86
+ provider_paths: dict[str, Path] = {}
87
+ for manifest_path in _manifest_paths(roots):
88
+ provider_id, factory_ref = _read_manifest(manifest_path)
89
+ if provider_id in provider_paths:
90
+ raise ProviderAdapterContractError(
91
+ f"duplicate provider id {provider_id!r}"
92
+ )
93
+ provider_paths[provider_id] = manifest_path
94
+ entries.append((manifest_path, provider_id, factory_ref))
95
+ return entries
96
+
97
+
98
+ def _manifest_paths(roots: Iterable[Path]) -> tuple[Path, ...]:
99
+ manifests: list[Path] = []
100
+ for root in roots:
101
+ if root.is_symlink():
102
+ raise ProviderAdapterContractError(
103
+ f"provider discovery root is symbolic link: {root}"
104
+ )
105
+ if not root.is_dir():
106
+ continue
107
+ resolved_root = root.resolve()
108
+ for manifest_path in sorted(root.glob("*/manifest.json")):
109
+ resolved_manifest = manifest_path.resolve()
110
+ if resolved_root not in resolved_manifest.parents:
111
+ raise ProviderAdapterContractError(
112
+ f"provider adapter escapes discovery root: {manifest_path}"
113
+ )
114
+ manifests.append(resolved_manifest)
115
+ return tuple(manifests)
116
+
117
+
118
+ def _read_manifest(manifest_path: Path) -> tuple[str, str]:
119
+ try:
120
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
121
+ except (OSError, json.JSONDecodeError) as exc:
122
+ raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}") from exc
123
+ if not isinstance(raw, dict) or raw.get("schemaVersion") != 1:
124
+ raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
125
+ provider_id = raw.get("id")
126
+ factory_ref = raw.get("factory")
127
+ if not isinstance(provider_id, str) or not provider_id.strip():
128
+ raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
129
+ if not isinstance(factory_ref, str) or not factory_ref:
130
+ raise ProviderAdapterContractError(f"invalid provider manifest: {manifest_path}")
131
+ return provider_id.strip().lower(), factory_ref
132
+
133
+
134
+ def _load_provider_spec(
135
+ manifest_path: Path, provider_id: str, factory_ref: str,
136
+ ) -> ProviderSpec:
137
+ try:
138
+ spec = load_relative_factory(manifest_path, factory_ref)()
139
+ except FactoryLoadError as exc:
140
+ raise ProviderAdapterContractError(str(exc)) from exc
141
+ except Exception as exc:
142
+ raise ProviderAdapterContractError(
143
+ f"provider factory failed for {provider_id!r}"
144
+ ) from exc
145
+ if not isinstance(spec, ProviderSpec) or spec.provider != provider_id:
146
+ raise ProviderAdapterContractError(
147
+ f"provider factory returned invalid provider {provider_id!r}"
148
+ )
149
+ return spec