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
@@ -1,220 +1,33 @@
1
- """Model catalog: display / execution / pricing / picker for okstra workers.
1
+ """Compatibility exports for the provider model catalog.
2
2
 
3
- Single reference point. Adding a model = one ModelSpec entry here. Changing a
4
- role default = one ROLE_DEFAULTS line here.
3
+ Provider value objects live in ``domain`` and catalog entries are discovered
4
+ from provider adapters. This module preserves the established public helpers.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
- from dataclasses import dataclass
9
- from typing import Mapping, Optional
10
-
11
-
12
- @dataclass(frozen=True)
13
- class ModelSpec:
14
- display: str
15
- execution: str
16
- pricing: Optional[tuple[float, float, float]] = None
17
- in_picker: bool = False # exposed in the wizard 3-option picker
18
-
19
-
20
- CLAUDE = {
21
- "fable": ModelSpec("fable", "fable", in_picker=True),
22
- "fable-5": ModelSpec("fable-5", "claude-fable-5"),
23
- "claude-fable-5": ModelSpec("fable-5", "claude-fable-5"),
24
- "opus": ModelSpec("opus", "opus", in_picker=True),
25
- "opus-5": ModelSpec("opus-5", "claude-opus-5"),
26
- "claude-opus-5": ModelSpec("opus-5", "claude-opus-5"),
27
- "opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
28
- "claude-opus-4-8": ModelSpec("opus-4-8", "claude-opus-4-8"),
29
- "opus-4-7": ModelSpec("opus-4-7", "claude-opus-4-7"),
30
- "claude-opus-4-7": ModelSpec("opus-4-7", "claude-opus-4-7"),
31
- "opus-4-6": ModelSpec("opus-4-6", "claude-opus-4-6"),
32
- "claude-opus-4-6": ModelSpec("opus-4-6", "claude-opus-4-6"),
33
- "sonnet": ModelSpec("sonnet", "sonnet", in_picker=True),
34
- "sonnet-5": ModelSpec("sonnet-5", "claude-sonnet-5", in_picker=True),
35
- "claude-sonnet-5": ModelSpec("sonnet-5", "claude-sonnet-5"),
36
- "sonnet-4-6": ModelSpec("sonnet-4-6", "claude-sonnet-4-6"),
37
- "claude-sonnet-4-6": ModelSpec("sonnet-4-6", "claude-sonnet-4-6"),
38
- "haiku": ModelSpec("haiku", "haiku", in_picker=True),
39
- "haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
40
- "claude-haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
41
- "claude-haiku-4-5-20251001": ModelSpec("haiku-4-5", "claude-haiku-4-5-20251001"),
42
- }
43
- # `agy` rejects a bare execution slug — it demands a tier, spelled as a suffix
44
- # on the slug itself ("gemini-3.1-pro-high"), and its list is host/version-
45
- # specific. model_discovery.normalize_execution_for_dispatch appends the role's
46
- # effort to ModelSpec.execution at dispatch time. `display` is okstra-facing
47
- # only and never reaches agy. agy also serves Claude/GPT-OSS models on some
48
- # installs; those carry their tier inline and take no suffix.
49
- ANTIGRAVITY = {
50
- "gemini-3.1-pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro", in_picker=True),
51
- "gemini 3.1 pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro"),
52
- "gemini-3.6-flash": ModelSpec("Gemini 3.6 Flash", "gemini-3.6-flash", in_picker=True),
53
- "gemini 3.6 flash": ModelSpec("Gemini 3.6 Flash", "gemini-3.6-flash"),
54
- "gemini-3.5-flash": ModelSpec("Gemini 3.5 Flash", "gemini-3.5-flash", in_picker=True),
55
- "gemini 3.5 flash": ModelSpec("Gemini 3.5 Flash", "gemini-3.5-flash"),
56
- }
57
- CODEX = {
58
- # ChatGPT-account host (agy/codex) serves 5.6 without per-token billing → pricing=None.
59
- "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", None, in_picker=True),
60
- "gpt-5.6": ModelSpec("gpt-5.6", "gpt-5.6", (5.00, 0.50, 30.0), in_picker=True),
61
- "gpt-5.5": ModelSpec("gpt-5.5", "gpt-5.5", (5.00, 0.50, 30.0), in_picker=True),
62
- "gpt-5.4": ModelSpec("gpt-5.4", "gpt-5.4", (2.50, 0.25, 15.0), in_picker=True),
63
- "gpt-5.4-mini": ModelSpec("gpt-5.4-mini", "gpt-5.4-mini", (0.75, 0.075, 4.50), in_picker=True),
64
- "gpt-5.3-codex": ModelSpec("gpt-5.4", "gpt-5.4"),
65
- "gpt-5.2": ModelSpec("gpt-5.2", "gpt-5.2", (1.75, 0.175, 14.0)),
66
- "codex-auto-review": ModelSpec("codex-auto-review", "codex-auto-review"),
67
- }
68
- GROK = {
69
- "grok-build-0.1": ModelSpec(
70
- "grok-build-0.1", "grok-build-0.1", (1.00, 0.20, 2.00), in_picker=True,
71
- ),
72
- "grok-4.5": ModelSpec(
73
- "grok-4.5", "grok-4.5", (2.00, 0.30, 6.00), in_picker=True,
74
- ),
75
- }
76
- KIMI = {
77
- "kimi-k2.7-code": ModelSpec(
78
- "Kimi K2.7 Code", "kimi-k2.7-code", (0.95, 0.19, 4.00), in_picker=True,
79
- ),
80
- "kimi k2.7 code": ModelSpec("Kimi K2.7 Code", "kimi-k2.7-code"),
81
- "kimi-for-coding": ModelSpec(
82
- "Kimi K2.7 Code", "kimi-for-coding", in_picker=True,
83
- ),
84
- "kimi-k3": ModelSpec(
85
- "Kimi K3", "kimi-k3", (3.00, 0.30, 15.00), in_picker=True,
86
- ),
87
- "kimi k3": ModelSpec("Kimi K3", "kimi-k3"),
88
- "k3": ModelSpec("Kimi K3", "k3", in_picker=True),
89
- "k3-256k": ModelSpec("Kimi K3 256K", "k3-256k", in_picker=True),
90
- "kimi k3 256k": ModelSpec("Kimi K3 256K", "k3-256k"),
91
- }
92
-
8
+ from typing import Optional
9
+
10
+ from .domain.provider import (
11
+ LeadLaunchSpec,
12
+ ModelAssignment,
13
+ ModelSpec,
14
+ ProviderSpec,
15
+ UnknownModelError,
16
+ UnknownProviderError,
17
+ )
18
+ from .registry.provider_registry import default_provider_registry
19
+
20
+
21
+ PROVIDERS = default_provider_registry().providers
22
+ CLAUDE = PROVIDERS["claude"].models
23
+ ANTIGRAVITY = PROVIDERS["antigravity"].models
24
+ CODEX = PROVIDERS["codex"].models
25
+ GROK = PROVIDERS["grok"].models
26
+ KIMI = PROVIDERS["kimi"].models
27
+ PROVIDER_MAPPINGS = {provider: spec.models for provider, spec in PROVIDERS.items()}
93
28
  ANALYSIS_ROLES = frozenset({"analyser", "critic"})
94
29
  IMPLEMENTATION_ROLES = frozenset({"executor", "verifier"})
95
30
 
96
-
97
- @dataclass(frozen=True)
98
- class LeadLaunchSpec:
99
- """How to start this provider's CLI as an interactive lead session.
100
-
101
- The worker wrappers cannot serve here: they run one non-interactive turn and
102
- exit, while a lead owns the session the user talks to. Empty `prompt_flag`
103
- means the CLI takes the prompt positionally; empty `session_id_flag` means it
104
- has no resumable session id and resumes from artifacts instead.
105
- """
106
-
107
- executable: str
108
- model_flag: str
109
- prompt_flag: str = ""
110
- session_id_flag: str = ""
111
- # Flags that lift the CLI's own sandbox, and what the user is agreeing to by
112
- # accepting them. Empty when the CLI does not sandbox itself by default.
113
- sandbox_waiver: tuple[str, ...] = ()
114
- sandbox_waiver_note: str = ""
115
-
116
-
117
- @dataclass(frozen=True)
118
- class ProviderSpec:
119
- provider: str
120
- display_label: str
121
- models: Mapping[str, ModelSpec]
122
- default_models: Mapping[str, str]
123
- wrapper: str
124
- supported_roles: frozenset[str]
125
- lead_launch: Optional[LeadLaunchSpec] = None
126
-
127
-
128
- PROVIDERS = {
129
- "claude": ProviderSpec(
130
- provider="claude",
131
- display_label="Claude",
132
- models=CLAUDE,
133
- default_models={
134
- "lead": "opus",
135
- "analyser": "opus",
136
- "critic": "opus",
137
- "executor": "opus",
138
- "verifier": "opus",
139
- "report-writer": "sonnet",
140
- },
141
- wrapper="okstra-claude-exec.sh",
142
- supported_roles=frozenset(
143
- {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
144
- ),
145
- lead_launch=LeadLaunchSpec(
146
- executable="claude",
147
- model_flag="--model",
148
- session_id_flag="--session-id",
149
- ),
150
- ),
151
- "antigravity": ProviderSpec(
152
- provider="antigravity",
153
- display_label="Antigravity",
154
- models=ANTIGRAVITY,
155
- default_models={role: "gemini-3.1-pro" for role in (
156
- "lead", "analyser", "critic", "executor", "verifier",
157
- )},
158
- wrapper="okstra-antigravity-exec.sh",
159
- supported_roles=frozenset(
160
- {"lead", "analyser", "critic", "executor", "verifier"}
161
- ),
162
- lead_launch=LeadLaunchSpec(
163
- executable="agy",
164
- model_flag="--model",
165
- # Without this the prompt runs once and the session ends; okstra
166
- # needs the lead to stay and drive the remaining phases.
167
- prompt_flag="--prompt-interactive",
168
- ),
169
- ),
170
- "codex": ProviderSpec(
171
- provider="codex",
172
- display_label="Codex",
173
- models=CODEX,
174
- default_models={role: "gpt-5.6-sol" for role in (
175
- "lead", "analyser", "critic", "executor", "verifier", "report-writer",
176
- )},
177
- wrapper="okstra-codex-exec.sh",
178
- supported_roles=frozenset(
179
- {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
180
- ),
181
- lead_launch=LeadLaunchSpec(
182
- executable="codex",
183
- model_flag="-m",
184
- # A codex session sandboxes itself, and everything it spawns inherits
185
- # that — okstra's dispatch and every worker CLI with it. Measured:
186
- # the cmux socket returns EPERM and each worker dies unable to write
187
- # its own config. A sandboxed lead cannot run okstra at all.
188
- sandbox_waiver=("-s", "danger-full-access"),
189
- sandbox_waiver_note=(
190
- "codex will start without its filesystem and network sandbox, "
191
- "which okstra needs so the lead can reach cmux and start worker "
192
- "CLIs."
193
- ),
194
- ),
195
- ),
196
- "grok": ProviderSpec(
197
- provider="grok",
198
- display_label="Grok",
199
- models=GROK,
200
- default_models={"analyser": "grok-build-0.1", "critic": "grok-4.5"},
201
- wrapper="okstra-grok-exec.sh",
202
- supported_roles=ANALYSIS_ROLES,
203
- ),
204
- "kimi": ProviderSpec(
205
- provider="kimi",
206
- display_label="Kimi",
207
- models=KIMI,
208
- default_models={"analyser": "kimi-k2.7-code", "critic": "kimi-k3"},
209
- wrapper="okstra-kimi-exec.sh",
210
- supported_roles=ANALYSIS_ROLES,
211
- ),
212
- }
213
-
214
- PROVIDER_MAPPINGS = {
215
- provider: spec.models for provider, spec in PROVIDERS.items()
216
- }
217
-
218
31
  # Legacy role-keyed compatibility defaults. Canonical assignments resolve a
219
32
  # provider first and then use ProviderSpec.default_models for that role.
220
33
  ROLE_DEFAULTS = {
@@ -223,20 +36,6 @@ ROLE_DEFAULTS = {
223
36
  }
224
37
 
225
38
 
226
- @dataclass
227
- class ModelAssignment:
228
- display: str
229
- execution: str
230
-
231
-
232
- class UnknownModelError(ValueError):
233
- """raw_value 가 provider mapping 에 등록되어 있지 않을 때 발생."""
234
-
235
-
236
- class UnknownProviderError(ValueError):
237
- """Raised when a requested provider is absent from the registry."""
238
-
239
-
240
39
  def lead_launch_spec(provider: str) -> LeadLaunchSpec:
241
40
  """The launch spec for a provider that can lead, or an error naming why not."""
242
41
  spec = provider_spec(provider)
@@ -248,13 +47,7 @@ def lead_launch_spec(provider: str) -> LeadLaunchSpec:
248
47
 
249
48
 
250
49
  def bare_lead_launch_argv(provider: str) -> list[str]:
251
- """Start this provider's CLI with nothing to do yet.
252
-
253
- `okstra run <lead>` opens the host; the okstra-run skill inside it collects
254
- task inputs, exactly as the Claude Code path already works. So there is no
255
- prompt and no model to pin here — only the flags that make the session
256
- usable to okstra at all.
257
- """
50
+ """Start this provider CLI with only the flags required by Okstra."""
258
51
  launch = lead_launch_spec(provider)
259
52
  return [launch.executable, *launch.sandbox_waiver]
260
53
 
@@ -267,22 +60,15 @@ def lead_launch_argv(
267
60
  session_id: str = "",
268
61
  waive_sandbox: bool = True,
269
62
  ) -> list[str]:
270
- """The argv that starts this provider's CLI as the lead for one run.
271
-
272
- The sandbox waiver is applied by default because a sandboxed lead cannot run
273
- okstra at all — it reaches neither cmux nor the worker CLIs' own config. The
274
- caller still owns telling the user and collecting their answer; passing
275
- False produces the un-waived argv so a declined confirmation can show what
276
- would otherwise have run.
277
- """
63
+ """Return the argv that starts one provider CLI as the lead for a run."""
278
64
  launch = lead_launch_spec(provider)
279
65
  argv = [launch.executable]
280
66
  if waive_sandbox:
281
67
  argv.extend(launch.sandbox_waiver)
282
68
  if model:
283
69
  argv.extend([launch.model_flag, model])
284
- if session_id and launch.session_id_flag:
285
- argv.extend([launch.session_id_flag, session_id])
70
+ if session_id and launch.start_session_id_flag:
71
+ argv.extend([launch.start_session_id_flag, session_id])
286
72
  if launch.prompt_flag:
287
73
  argv.append(launch.prompt_flag)
288
74
  argv.append(prompt)
@@ -290,12 +76,12 @@ def lead_launch_argv(
290
76
 
291
77
 
292
78
  def provider_spec(provider: str) -> ProviderSpec:
293
- """Return one registered provider or fail before assignment/dispatch."""
79
+ """Return one registered provider or fail before assignment or dispatch."""
294
80
  normalized = (provider or "").strip().lower()
295
81
  try:
296
- return PROVIDERS[normalized]
297
- except KeyError as exc:
298
- allowed = ", ".join(PROVIDERS)
82
+ return default_provider_registry().resolve(normalized)
83
+ except UnknownProviderError as exc:
84
+ allowed = ", ".join(default_provider_registry().ids())
299
85
  raise UnknownProviderError(
300
86
  f"unknown provider {provider!r}. Allowed values: {allowed}"
301
87
  ) from exc
@@ -303,12 +89,8 @@ def provider_spec(provider: str) -> ProviderSpec:
303
89
 
304
90
  def provider_ids(role: str | None = None) -> list[str]:
305
91
  """Registered provider ids, optionally filtered by functional role."""
306
- if not role:
307
- return list(PROVIDERS)
308
- return [
309
- provider for provider, spec in PROVIDERS.items()
310
- if role in spec.supported_roles
311
- ]
92
+ registry = default_provider_registry()
93
+ return list(registry.ids(role if role else None))
312
94
 
313
95
 
314
96
  def provider_supports_role(provider: str, role: str) -> bool:
@@ -318,29 +100,30 @@ def provider_supports_role(provider: str, role: str) -> bool:
318
100
 
319
101
  def provider_wrappers(role: str | None = None) -> dict[str, str]:
320
102
  """Provider-to-wrapper mapping derived from capability entries."""
103
+ registry = default_provider_registry()
321
104
  return {
322
- provider: PROVIDERS[provider].wrapper
323
- for provider in provider_ids(role)
324
- if PROVIDERS[provider].wrapper
105
+ provider: registry.resolve(provider).wrapper
106
+ for provider in registry.ids(role if role else None)
107
+ if registry.resolve(provider).wrapper
325
108
  }
326
109
 
327
110
 
328
111
  def provider_default_model(provider: str, role: str) -> Optional[str]:
329
- """Resolve a provider's explicit default for one functional role."""
112
+ """Resolve a provider explicit default for one functional role."""
330
113
  return provider_spec(provider).default_models.get(role)
331
114
 
332
115
 
333
116
  def resolve_model_metadata(
334
117
  *, provider: str, raw_value: str, default_display: str, default_execution: str,
335
118
  ) -> ModelAssignment:
336
- """alias → (display, execution). Unchanged contract; mapping values are now
337
- ModelSpec instead of tuples."""
119
+ """Resolve a model alias to its display and execution values."""
338
120
  raw_value = (raw_value or "").strip()
339
- mapping = PROVIDER_MAPPINGS.get(provider)
340
- if raw_value and mapping is not None:
121
+ mapping = default_provider_registry().providers.get(provider)
122
+ models = mapping.models if mapping else None
123
+ if raw_value and models is not None:
341
124
  normalized = raw_value.lower()
342
- if normalized not in mapping:
343
- allowed = ", ".join(sorted(mapping.keys()))
125
+ if normalized not in models:
126
+ allowed = ", ".join(sorted(models))
344
127
  raise UnknownModelError(
345
128
  f"{provider} model {raw_value!r} is not a supported alias. "
346
129
  f"Allowed values: {allowed}"
@@ -349,24 +132,26 @@ def resolve_model_metadata(
349
132
  display = value
350
133
  execution = raw_value or default_execution
351
134
  normalized = value.strip().lower()
352
- if mapping and normalized in mapping:
353
- spec = mapping[normalized]
135
+ if models and normalized in models:
136
+ spec = models[normalized]
354
137
  display, execution = spec.display, spec.execution
355
138
  return ModelAssignment(display=display, execution=execution)
356
139
 
357
140
 
358
141
  def picker_options(provider: str) -> list[str]:
359
142
  """Picker-exposed aliases for a provider, in catalog insertion order."""
360
- mapping = PROVIDER_MAPPINGS.get(provider, {})
361
- return [alias for alias, spec in mapping.items() if spec.in_picker]
143
+ spec = default_provider_registry().providers.get(provider)
144
+ models = spec.models if spec else {}
145
+ return [alias for alias, model in models.items() if model.in_picker]
362
146
 
363
147
 
364
148
  def default_model(role: str) -> Optional[str]:
365
- """Role → default model alias (env override is the caller's concern)."""
149
+ """Role-to-default model alias; environment overrides belong to the caller."""
366
150
  return ROLE_DEFAULTS.get(role)
367
151
 
368
152
 
369
153
  def catalog_pricing(provider: str) -> dict:
370
- """Selectable-model pricing as (input, cached input, output) USD/1M."""
371
- mapping = PROVIDER_MAPPINGS.get(provider, {})
372
- return {alias: spec.pricing for alias, spec in mapping.items() if spec.pricing is not None}
154
+ """Selectable-model pricing as input, cached-input, output USD per million."""
155
+ spec = default_provider_registry().providers.get(provider)
156
+ models = spec.models if spec else {}
157
+ return {alias: model.pricing for alias, model in models.items() if model.pricing is not None}
@@ -0,0 +1,15 @@
1
+ """Host-facing ports consumed by application use cases."""
2
+
3
+ from .host import HostAdapter
4
+ from .interaction import InteractionPort
5
+ from .lead_session import LeadSessionPort
6
+ from .usage_accounting import UsageAccountingPort
7
+ from .worker_dispatch import WorkerDispatchPort
8
+
9
+ __all__ = (
10
+ "HostAdapter",
11
+ "InteractionPort",
12
+ "LeadSessionPort",
13
+ "UsageAccountingPort",
14
+ "WorkerDispatchPort",
15
+ )
@@ -0,0 +1,32 @@
1
+ """Composite interface implemented by each host strategy."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from ..domain.host import (
7
+ HostClaim,
8
+ HostDescriptor,
9
+ HostReadiness,
10
+ HostResolutionContext,
11
+ HostSessionContext,
12
+ )
13
+ from .interaction import InteractionPort
14
+ from .lead_session import LeadSessionPort
15
+ from .usage_accounting import UsageAccountingPort
16
+ from .worker_dispatch import WorkerDispatchPort
17
+
18
+
19
+ class HostAdapter(Protocol):
20
+ descriptor: HostDescriptor
21
+
22
+ def detect(self, context: HostResolutionContext) -> HostClaim | None: ...
23
+
24
+ def probe(self, context: HostSessionContext) -> HostReadiness: ...
25
+
26
+ def interaction(self) -> InteractionPort: ...
27
+
28
+ def lead_session(self) -> LeadSessionPort: ...
29
+
30
+ def worker_dispatch(self) -> WorkerDispatchPort: ...
31
+
32
+ def usage_accounting(self) -> UsageAccountingPort: ...
@@ -0,0 +1,15 @@
1
+ """Interaction boundary exposed by a host adapter."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from ..domain.host import HostSessionContext
7
+ from ..domain.wizard.interaction import InteractionPlan, WizardPrompt
8
+
9
+
10
+ class InteractionPort(Protocol):
11
+ def plan(
12
+ self,
13
+ prompt: WizardPrompt,
14
+ context: HostSessionContext,
15
+ ) -> InteractionPlan: ...
@@ -0,0 +1,25 @@
1
+ """Lead-session boundary exposed by a host adapter."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from ..domain.host import (
7
+ HostSessionContext,
8
+ LeadResumeRequest,
9
+ LeadSessionPlan,
10
+ LeadStartRequest,
11
+ )
12
+
13
+
14
+ class LeadSessionPort(Protocol):
15
+ def build_start(
16
+ self,
17
+ request: LeadStartRequest,
18
+ context: HostSessionContext,
19
+ ) -> LeadSessionPlan: ...
20
+
21
+ def build_resume(
22
+ self,
23
+ request: LeadResumeRequest,
24
+ context: HostSessionContext,
25
+ ) -> LeadSessionPlan: ...
@@ -0,0 +1,23 @@
1
+ """Usage-accounting boundary exposed by a host adapter."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class UsageRequest:
11
+ team_state_path: Path
12
+ project_root: Path
13
+ incremental: bool = True
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class UsageSnapshot:
18
+ source: str
19
+ payload: dict
20
+
21
+
22
+ class UsageAccountingPort(Protocol):
23
+ def collect(self, request: UsageRequest) -> UsageSnapshot: ...
@@ -0,0 +1,32 @@
1
+ """Worker-dispatch boundary exposed by a host adapter."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal, Protocol
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WorkerDispatchRequest:
11
+ project_root: Path
12
+ run_manifest: Path
13
+ workspace_root: Path | None = None
14
+ okstra_bin: Path | None = None
15
+ requested_workers: tuple[str, ...] = ()
16
+ idle_timeout_seconds: int = 600
17
+ dispatch_kind: str = "initial"
18
+ jobs_file: Path | None = None
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WorkerDispatchPlan:
23
+ mode: Literal["native-team", "cli-wrapper", "cmux-pane"]
24
+ request: WorkerDispatchRequest
25
+ backend_plan: object | None = None
26
+ adapter_name: str | None = None
27
+ dispatch_mode: str | None = None
28
+ relay_contract: str | None = None
29
+
30
+
31
+ class WorkerDispatchPort(Protocol):
32
+ def build_plan(self, request: WorkerDispatchRequest) -> WorkerDispatchPlan: ...
@@ -0,0 +1,13 @@
1
+ """Registries that discover and resolve installed Okstra adapters."""
2
+
3
+ from .provider_registry import (
4
+ ProviderAdapterContractError,
5
+ ProviderRegistry,
6
+ default_provider_registry,
7
+ )
8
+
9
+ __all__ = (
10
+ "ProviderAdapterContractError",
11
+ "ProviderRegistry",
12
+ "default_provider_registry",
13
+ )
@@ -0,0 +1,32 @@
1
+ """Safe loader for a provider adapter factory declared in a manifest."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import importlib.util
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+
10
+ class FactoryLoadError(ValueError):
11
+ """Raised when an adapter factory reference cannot be safely loaded."""
12
+
13
+
14
+ def load_relative_factory(manifest_path: Path, factory_ref: str) -> Callable[..., object]:
15
+ """Load a callable factory without allowing its file to leave the adapter."""
16
+ file_name, separator, symbol = factory_ref.partition(":")
17
+ if not separator or not file_name or not symbol:
18
+ raise FactoryLoadError(f"invalid factory reference: {factory_ref!r}")
19
+ adapter_root = manifest_path.parent.resolve()
20
+ module_path = (adapter_root / file_name).resolve()
21
+ if adapter_root not in module_path.parents or not module_path.is_file():
22
+ raise FactoryLoadError(f"factory escapes adapter root: {factory_ref!r}")
23
+ module_name = f"okstra_adapter_{hashlib.sha256(str(module_path).encode()).hexdigest()}"
24
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
25
+ if spec is None or spec.loader is None:
26
+ raise FactoryLoadError(f"cannot load factory module: {module_path}")
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ factory = getattr(module, symbol, None)
30
+ if not callable(factory):
31
+ raise FactoryLoadError(f"factory symbol is not callable: {factory_ref!r}")
32
+ return factory