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
@@ -30,11 +30,18 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
30
30
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
31
31
  from . import fix_cycles
32
32
  from .analysis_inputs import ANALYSIS_TASK_TYPES
33
+ from .application.resolve_assignment import (
34
+ resolve_assignment_runner,
35
+ resolve_lead_provider,
36
+ )
37
+ from .application.dispatch_assignments import dispatch_assignments
38
+ from .adapters.dispatch import default_worker_dispatch_port, with_worker_dispatch
39
+ from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
33
40
  from .paths import okstra_home
34
- from .dispatch_state import BACKEND_CMUX_PANE
35
- from .lead_runtime import lead_runtime_info, with_cmux_dispatch
36
41
  from .models import UnknownProviderError, provider_ids, provider_spec
37
- from .runner_resolution import native_provider_for_host
42
+ from .ports.worker_dispatch import WorkerDispatchRequest
43
+ from .registry.host_registry import default_host_registry
44
+ from .registry.provider_registry import default_provider_registry
38
45
  from .path_hints import compact_active_run_context, hydrate_run_context
39
46
  from .paths import task_timeline_file
40
47
  from .worker_artifacts import artifacts_from_context
@@ -80,27 +87,49 @@ def _lead_runtime(ctx: dict) -> str:
80
87
  return ctx.get("LEAD_RUNTIME", "") or "claude-code"
81
88
 
82
89
 
83
- def _lead_info(ctx: dict):
84
- info = lead_runtime_info(_lead_runtime(ctx))
85
- if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
86
- return with_cmux_dispatch(info)
87
- return info
90
+ def _lead_descriptor(ctx: dict):
91
+ return default_host_registry().resolve(_lead_runtime(ctx)).descriptor
92
+
93
+
94
+ def _worker_dispatch_plan(ctx: dict):
95
+ host_adapter = default_host_registry().resolve(_lead_runtime(ctx))
96
+ fallback_port = default_worker_dispatch_port(host_adapter.descriptor)
97
+ port = dispatch_port_for_terminal_backend(
98
+ ctx.get("TERMINAL_BACKEND", ""),
99
+ fallback_port,
100
+ )
101
+ decorated = with_worker_dispatch(host_adapter, port)
102
+ request = WorkerDispatchRequest(
103
+ project_root=Path(ctx.get("PROJECT_ROOT", "")),
104
+ run_manifest=Path(ctx.get("RUN_MANIFEST_PATH", "")),
105
+ )
106
+ return dispatch_assignments(request, decorated.worker_dispatch())
88
107
 
89
108
 
90
109
  def _lead_agent(ctx: dict) -> str:
91
- return _lead_info(ctx).agent
110
+ return _lead_descriptor(ctx).agent_id
92
111
 
93
112
 
94
113
  def _lead_agent_label(ctx: dict) -> str:
95
- return _lead_info(ctx).agent_label
114
+ return _lead_descriptor(ctx).agent_label
96
115
 
97
116
 
98
117
  def _lead_role(ctx: dict) -> str:
99
- return _lead_info(ctx).role
118
+ return _lead_descriptor(ctx).role
119
+
120
+
121
+ def _lead_adapter(ctx: dict, plan) -> dict:
122
+ descriptor = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
123
+ return {
124
+ "name": plan.adapter_name or descriptor.id,
125
+ "dispatchMode": plan.dispatch_mode or descriptor.dispatch_mode,
126
+ "sessionAccounting": descriptor.session_accounting,
127
+ }
100
128
 
101
129
 
102
- def _lead_adapter(ctx: dict) -> dict:
103
- return _lead_info(ctx).adapter_payload()
130
+ def _lead_adapter_contract(ctx: dict, plan) -> str:
131
+ descriptor = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
132
+ return plan.relay_contract or descriptor.relay_contract
104
133
 
105
134
 
106
135
  def _runtime_resolution(ctx: dict) -> dict:
@@ -257,31 +286,71 @@ def _resolve_workers(ctx: dict) -> list[str]:
257
286
  ]
258
287
 
259
288
 
260
- def _assignment_runner_for_ctx(ctx: dict, provider: str) -> str:
261
- native_provider = native_provider_for_host(_lead_runtime(ctx))
262
- return "native-session" if provider == native_provider else "cli-wrapper"
289
+ def _assignment_runner_for_ctx(
290
+ ctx: dict,
291
+ provider: str,
292
+ role: str,
293
+ requested_runner: str = "",
294
+ ) -> str:
295
+ host = default_host_registry().resolve(_lead_runtime(ctx)).descriptor
296
+ spec = default_provider_registry().resolve(provider)
297
+ return resolve_assignment_runner(
298
+ host=host,
299
+ provider=spec,
300
+ role=role,
301
+ requested_runner=requested_runner,
302
+ ).runner
303
+
304
+
305
+ def _resolved_lead_assignment(
306
+ ctx: dict,
307
+ requested_provider: str,
308
+ requested_runner: str = "",
309
+ ):
310
+ host_registry = default_host_registry()
311
+ provider_registry = default_provider_registry()
312
+ assignment = resolve_lead_provider(
313
+ host_id=_lead_runtime(ctx),
314
+ requested_provider=requested_provider,
315
+ host_registry=host_registry,
316
+ provider_registry=provider_registry,
317
+ )
318
+ if requested_runner:
319
+ resolution = resolve_assignment_runner(
320
+ host=host_registry.resolve(_lead_runtime(ctx)).descriptor,
321
+ provider=provider_registry.resolve(assignment.provider),
322
+ role="lead",
323
+ requested_runner=requested_runner,
324
+ )
325
+ return assignment.provider, resolution.runner
326
+ return assignment.provider, assignment.runner
263
327
 
264
328
 
265
329
  def _lead_assignment(ctx: dict) -> dict:
330
+ parsed = None
266
331
  raw = ctx.get("LEAD_ASSIGNMENT_JSON", "")
267
332
  if raw:
268
333
  try:
269
- parsed = json.loads(raw)
270
- if isinstance(parsed, dict):
271
- return parsed
334
+ candidate = json.loads(raw)
335
+ if isinstance(candidate, dict):
336
+ parsed = candidate
272
337
  except json.JSONDecodeError:
273
338
  pass
274
- provider = (
275
- ctx.get("LEAD_PROVIDER", "")
276
- or native_provider_for_host(_lead_runtime(ctx))
277
- or "claude"
339
+ source = parsed or {}
340
+ provider, runner = _resolved_lead_assignment(
341
+ ctx,
342
+ source.get("provider", "") or ctx.get("LEAD_PROVIDER", ""),
343
+ source.get("runner", ""),
278
344
  )
279
345
  return {
346
+ **source,
280
347
  "role": "lead",
281
348
  "provider": provider,
282
- "model": ctx.get("LEAD_MODEL", ""),
283
- "modelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
284
- "runner": _assignment_runner_for_ctx(ctx, provider),
349
+ "model": source.get("model", ctx.get("LEAD_MODEL", "")),
350
+ "modelExecutionValue": source.get(
351
+ "modelExecutionValue", ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
352
+ ),
353
+ "runner": runner,
285
354
  }
286
355
 
287
356
 
@@ -291,7 +360,7 @@ def _worker_assignments(ctx: dict) -> list[dict]:
291
360
  try:
292
361
  parsed = json.loads(raw)
293
362
  if isinstance(parsed, list) and all(isinstance(row, dict) for row in parsed):
294
- return parsed
363
+ return [_resolved_worker_assignment(ctx, row) for row in parsed]
295
364
  except json.JSONDecodeError:
296
365
  pass
297
366
  catalog = _worker_catalog(ctx, include_assignments=False)
@@ -302,12 +371,39 @@ def _worker_assignments(ctx: dict) -> list[dict]:
302
371
  "provider": catalog[worker_id]["agent"],
303
372
  "model": catalog[worker_id]["model"],
304
373
  "modelExecutionValue": catalog[worker_id]["modelExecutionValue"],
305
- "runner": _assignment_runner_for_ctx(ctx, catalog[worker_id]["agent"]),
374
+ "runner": _assignment_runner_for_ctx(
375
+ ctx,
376
+ catalog[worker_id]["agent"],
377
+ "report-writer" if worker_id == "report-writer" else "analyser",
378
+ ),
306
379
  }
307
380
  for worker_id in _resolve_workers(ctx)
308
381
  ]
309
382
 
310
383
 
384
+ def _resolved_worker_assignment(ctx: dict, source: dict) -> dict:
385
+ worker_id = source.get("workerId", "")
386
+ role = source.get("role", "") or (
387
+ "report-writer" if worker_id == "report-writer" else "analyser"
388
+ )
389
+ provider = source.get("provider", "") or _fallback_worker_provider(
390
+ ctx, worker_id
391
+ )
392
+ runner = _assignment_runner_for_ctx(
393
+ ctx,
394
+ provider,
395
+ role,
396
+ source.get("runner", ""),
397
+ )
398
+ return {
399
+ **source,
400
+ "workerId": worker_id,
401
+ "role": role,
402
+ "provider": provider,
403
+ "runner": runner,
404
+ }
405
+
406
+
311
407
  def _worker_catalog(ctx: dict, *, include_assignments: bool = True) -> dict:
312
408
  assignments = {} if not include_assignments else {
313
409
  row.get("workerId"): row for row in _worker_assignments(ctx)
@@ -329,7 +425,11 @@ def _worker_catalog(ctx: dict, *, include_assignments: bool = True) -> dict:
329
425
  "agentLabel": provider_info.display_label,
330
426
  "provider": provider,
331
427
  "runner": assignment.get(
332
- "runner", _assignment_runner_for_ctx(ctx, provider),
428
+ "runner", _assignment_runner_for_ctx(
429
+ ctx,
430
+ provider,
431
+ "report-writer" if worker_id == "report-writer" else "analyser",
432
+ ),
333
433
  ),
334
434
  "model": assignment.get("model", model),
335
435
  "modelExecutionValue": assignment.get("modelExecutionValue", execution),
@@ -551,6 +651,7 @@ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
551
651
  def render_team_state(team_state_path: str, ctx: dict) -> None:
552
652
  selected = _resolve_workers(ctx)
553
653
  catalog = _worker_catalog(ctx)
654
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
554
655
  workers = []
555
656
  for w in selected:
556
657
  m = catalog[w]
@@ -577,7 +678,7 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
577
678
  "leadRuntime": _lead_runtime(ctx),
578
679
  "leadAssignment": _lead_assignment(ctx),
579
680
  "workerAssignments": _worker_assignments(ctx),
580
- "leadAdapter": _lead_adapter(ctx),
681
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
581
682
  "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
582
683
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
583
684
  "workflowState": ctx.get("CURRENT_RUN_STATUS", ""),
@@ -1112,6 +1213,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1112
1213
  except Exception:
1113
1214
  existing = {}
1114
1215
  reviewers = _resolve_workers(ctx)
1216
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1115
1217
  catalog = _worker_catalog(ctx)
1116
1218
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1117
1219
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
@@ -1170,7 +1272,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1170
1272
  "leadRuntime": _lead_runtime(ctx),
1171
1273
  "leadAssignment": _lead_assignment(ctx),
1172
1274
  "workerAssignments": _worker_assignments(ctx),
1173
- "leadAdapter": _lead_adapter(ctx),
1275
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
1174
1276
  "workCategory": work_category,
1175
1277
  # user-managed status (set-work-status CLI) — carried across re-renders
1176
1278
  "workStatus": existing.get("workStatus", ""),
@@ -1308,7 +1410,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1308
1410
  "leadSession": {
1309
1411
  "sessionId": ctx.get("CLAUDE_SESSION_ID", ""),
1310
1412
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
1311
- "accounting": _lead_info(ctx).session_accounting,
1413
+ "accounting": _lead_descriptor(ctx).session_accounting,
1312
1414
  },
1313
1415
  "convergence": convergence_block,
1314
1416
  "createdAt": existing.get("createdAt") or ctx.get("RUN_TIMESTAMP_ISO", ""),
@@ -1418,6 +1520,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1418
1520
  except Exception:
1419
1521
  task_manifest = {}
1420
1522
  reviewers = _resolve_workers(ctx)
1523
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1421
1524
  catalog = _worker_catalog(ctx)
1422
1525
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1423
1526
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
@@ -1452,7 +1555,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1452
1555
  "runtimeResolution": _runtime_resolution(ctx),
1453
1556
  "leadAssignment": _lead_assignment(ctx),
1454
1557
  "workerAssignments": _worker_assignments(ctx),
1455
- "leadAdapter": _lead_adapter(ctx),
1558
+ "leadAdapter": _lead_adapter(ctx, worker_dispatch_plan),
1456
1559
  "workCategory": task_manifest.get(
1457
1560
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1458
1561
  ),
@@ -1957,6 +2060,7 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1957
2060
  selected = _resolve_workers(ctx)
1958
2061
  catalog = _worker_catalog(ctx)
1959
2062
  lead_runtime = _lead_runtime(ctx)
2063
+ worker_dispatch_plan = _worker_dispatch_plan(ctx)
1960
2064
  lead_role = _lead_role(ctx)
1961
2065
  lead_agent_label = _lead_agent_label(ctx)
1962
2066
  lead_model = ctx.get("LEAD_MODEL", "")
@@ -1966,7 +2070,7 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1966
2070
  home_prompts / "lead" / "okstra-lead-contract.md"
1967
2071
  )
1968
2072
  adapter_contract_path = ctx.get("OKSTRA_LEAD_ADAPTER_CONTRACT_PATH") or str(
1969
- home_prompts / _lead_info(ctx).adapter_contract_relative_path
2073
+ home_prompts / _lead_adapter_contract(ctx, worker_dispatch_plan)
1970
2074
  )
1971
2075
  context_loader_path = ctx.get("OKSTRA_CONTEXT_LOADER_PATH") or str(
1972
2076
  home_prompts / "lead" / "context-loader.md"
@@ -2163,27 +2267,21 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
2163
2267
  "schema and write rules. Do not re-inline either contract."
2164
2268
  )
2165
2269
 
2166
- if lead_runtime == "external":
2270
+ lead_descriptor = _lead_descriptor(ctx)
2271
+ if lead_descriptor.has_claude_session:
2167
2272
  lead_session_block = (
2168
2273
  "## Session\n"
2169
2274
  "\n"
2170
- "- Lead runtime: `external`\n"
2171
- "- Lead session accounting: artifact-only; this harness does not expose Claude Code jsonl session IDs."
2172
- )
2173
- elif lead_runtime == "codex":
2174
- lead_session_block = (
2175
- "## Session\n"
2176
- "\n"
2177
- "- Lead runtime: `codex`\n"
2178
- "- Codex run resume is artifact/checkpoint based; `okstra codex-run` "
2179
- "does not create a Claude session id."
2275
+ f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
2276
+ f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
2180
2277
  )
2181
2278
  else:
2182
2279
  lead_session_block = (
2183
2280
  "## Session\n"
2184
2281
  "\n"
2185
- f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
2186
- f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
2282
+ f"- Lead runtime: `{lead_descriptor.id}`\n"
2283
+ f"- Lead session accounting: {lead_descriptor.session_accounting}\n"
2284
+ "- Resume behavior is provided by the selected host lead-session port."
2187
2285
  )
2188
2286
 
2189
2287
  # Compute results (deterministic from ctx, 덮어쓰기)
@@ -57,14 +57,19 @@ from .material import (
57
57
  from .final_report_schema import load_schema_version
58
58
  from .final_report_paths import final_report_data_path as _final_report_data_path
59
59
  from .lead_events import LeadEvent, append_lead_event
60
- from .lead_runtime import (
61
- ALLOWED_LEAD_RUNTIMES,
62
- ARTIFACT_ONLY_LEAD_RUNTIMES,
63
- lead_runtime_info,
60
+ from .application.resolve_assignment import (
61
+ resolve_assignment_runner,
62
+ resolve_lead_provider,
63
+ )
64
+ from .domain.host import (
65
+ HostCapabilityMismatch,
66
+ HostNotRegistered,
67
+ ProviderUnavailable,
64
68
  )
65
69
  from .model_discovery import normalize_execution_for_dispatch
66
70
  from .models import (
67
71
  ModelAssignment,
72
+ UnknownProviderError,
68
73
  default_model,
69
74
  lead_launch_argv,
70
75
  lead_launch_spec,
@@ -75,11 +80,8 @@ from .models import (
75
80
  resolve_model_metadata,
76
81
  )
77
82
  from .schema_excerpt import build_schema_excerpt
78
- from .runner_resolution import (
79
- RunnerResolutionError,
80
- native_provider_for_host,
81
- resolve_runner,
82
- )
83
+ from .registry.host_registry import default_host_registry
84
+ from .registry.provider_registry import default_provider_registry
83
85
  from .report_contract import CURRENT_REPORT_SCHEMA_VERSION
84
86
  from .path_resolve import relative_to_project_root, resolve_user_file
85
87
  from .render import (
@@ -326,15 +328,16 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
326
328
  PrepareError = _stage_targets.PrepareError
327
329
 
328
330
 
329
- _ALLOWED_LEAD_RUNTIMES = ALLOWED_LEAD_RUNTIMES
330
-
331
-
332
331
  def _normalize_lead_runtime(value: str) -> str:
333
332
  runtime = (value or "claude-code").strip()
334
- if runtime not in _ALLOWED_LEAD_RUNTIMES:
335
- allowed = ", ".join(_ALLOWED_LEAD_RUNTIMES)
336
- raise PrepareError(f"unsupported lead runtime: {runtime} (allowed: {allowed})")
337
- return runtime
333
+ registry = default_host_registry()
334
+ try:
335
+ return registry.resolve(runtime).descriptor.id
336
+ except HostNotRegistered as exc:
337
+ allowed = ", ".join(registry.ids())
338
+ raise PrepareError(
339
+ f"unsupported lead runtime: {runtime} (allowed: {allowed})"
340
+ ) from exc
338
341
 
339
342
 
340
343
  @dataclass
@@ -726,7 +729,8 @@ def _brief_sha256(path: Path) -> str:
726
729
 
727
730
 
728
731
  def _record_artifact_runtime_render_only_event(inp: PrepareInputs, ctx: dict) -> None:
729
- if inp.lead_runtime not in ARTIFACT_ONLY_LEAD_RUNTIMES or not inp.render_only:
732
+ descriptor = default_host_registry().resolve(inp.lead_runtime).descriptor
733
+ if descriptor.session_accounting != "artifact-only" or not inp.render_only:
730
734
  return
731
735
  append_lead_event(
732
736
  Path(ctx["LEAD_EVENTS_PATH"]),
@@ -1446,9 +1450,20 @@ def _model_for_role(
1446
1450
 
1447
1451
 
1448
1452
  def _assignment_runner(lead_runtime: str, provider: str, role: str) -> str:
1453
+ host_registry = default_host_registry()
1454
+ provider_registry = default_provider_registry()
1449
1455
  try:
1450
- return resolve_runner(lead_runtime, provider, role).runner
1451
- except RunnerResolutionError as exc:
1456
+ return resolve_assignment_runner(
1457
+ host=host_registry.resolve(lead_runtime).descriptor,
1458
+ provider=provider_registry.resolve(provider),
1459
+ role=role,
1460
+ ).runner
1461
+ except (
1462
+ HostCapabilityMismatch,
1463
+ HostNotRegistered,
1464
+ ProviderUnavailable,
1465
+ UnknownProviderError,
1466
+ ) as exc:
1452
1467
  raise PrepareError(str(exc)) from exc
1453
1468
 
1454
1469
 
@@ -1468,23 +1483,22 @@ def _resolve_worker_models(inp: PrepareInputs) -> dict:
1468
1483
  provider, overrides.get(provider, legacy.get(provider, "")),
1469
1484
  "analyser", default_override,
1470
1485
  )
1471
- native_lead_provider = native_provider_for_host(inp.lead_runtime)
1472
- requested_lead_provider = inp.lead_provider.strip().lower()
1473
- if (
1474
- native_lead_provider
1475
- and requested_lead_provider
1476
- and requested_lead_provider != native_lead_provider
1477
- ):
1478
- host_label = lead_runtime_info(inp.lead_runtime).agent_label
1479
- raise PrepareError(
1480
- f"{host_label.removesuffix(' CLI')} host requires provider "
1481
- f"{native_lead_provider!r} for the in-session lead; provider "
1482
- f"{requested_lead_provider!r} must be selected as a worker instead"
1483
- )
1484
- lead_provider = native_lead_provider or requested_lead_provider or "claude"
1486
+ try:
1487
+ lead_assignment = resolve_lead_provider(
1488
+ host_id=inp.lead_runtime,
1489
+ requested_provider=inp.lead_provider,
1490
+ host_registry=default_host_registry(),
1491
+ provider_registry=default_provider_registry(),
1492
+ )
1493
+ except (
1494
+ HostCapabilityMismatch,
1495
+ HostNotRegistered,
1496
+ ProviderUnavailable,
1497
+ UnknownProviderError,
1498
+ ) as exc:
1499
+ raise PrepareError(str(exc)) from exc
1500
+ lead_provider = lead_assignment.provider
1485
1501
  report_writer_provider = inp.report_writer_provider.strip().lower() or "claude"
1486
- if not provider_supports_role(lead_provider, "lead"):
1487
- raise PrepareError(f"provider {lead_provider!r} does not support the lead role")
1488
1502
  if not provider_supports_role(report_writer_provider, "report-writer"):
1489
1503
  raise PrepareError(
1490
1504
  f"provider {report_writer_provider!r} does not support the report-writer role"
@@ -2736,7 +2750,7 @@ def main(argv: list[str]) -> int:
2736
2750
  p.add_argument(
2737
2751
  "--lead-runtime",
2738
2752
  default="claude-code",
2739
- choices=list(_ALLOWED_LEAD_RUNTIMES),
2753
+ choices=list(default_host_registry().ids()),
2740
2754
  dest="lead_runtime",
2741
2755
  help=(
2742
2756
  "Lead runtime adapter. Default: claude-code. `codex` renders "
@@ -2964,8 +2978,7 @@ def main(argv: list[str]) -> int:
2964
2978
  else:
2965
2979
  print(f"okstra current run dir: {ctx['RUN_DIR']}")
2966
2980
  print(f"final report path: {ctx['FINAL_REPORT_PATH']}")
2967
- lead_runtime_name = ctx.get("LEAD_RUNTIME", "claude-code")
2968
- lead_provider = lead_runtime_info(lead_runtime_name).agent
2981
+ lead_provider = ctx.get("LEAD_PROVIDER", "")
2969
2982
  launch = lead_launch_spec(lead_provider)
2970
2983
  print(f"lead model: {ctx['LEAD_MODEL']}")
2971
2984
  print(f"claude session id: {ctx['CLAUDE_SESSION_ID']}")
@@ -2977,30 +2990,39 @@ def main(argv: list[str]) -> int:
2977
2990
  # lead. The argv is assembled here rather than in the caller so provider
2978
2991
  # launch knowledge stays in the catalog and every front end — the bash
2979
2992
  # wrapper and the Node CLI — starts the lead identically.
2980
- prompt_file = Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"
2981
- machine = {
2982
- "leadRuntime": lead_runtime_name,
2983
- "leadProvider": lead_provider,
2984
- "leadExecutable": launch.executable,
2985
- "leadSessionId": ctx["CLAUDE_SESSION_ID"],
2986
- "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
2987
- "projectRoot": ctx["PROJECT_ROOT"],
2988
- "promptFile": str(prompt_file),
2989
- # Non-empty only when starting this lead lowers a protection the
2990
- # user should agree to first. The front end asks; okstra does not
2991
- # waive it silently.
2992
- "sandboxWaiverNote": launch.sandbox_waiver_note,
2993
- "launchArgv": lead_launch_argv(
2994
- lead_provider,
2995
- model=ctx["LEAD_MODEL_EXECUTION_VALUE"],
2996
- session_id=ctx["CLAUDE_SESSION_ID"],
2997
- prompt=prompt_file.read_text(encoding="utf-8"),
2998
- ),
2999
- }
2993
+ machine = _lead_launch_payload(ctx)
3000
2994
  print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
3001
2995
  return 0
3002
2996
 
3003
2997
 
2998
+ def _lead_launch_payload(ctx: dict[str, object]) -> dict[str, object]:
2999
+ lead_runtime = str(ctx.get("LEAD_RUNTIME", "claude-code"))
3000
+ lead_provider = str(ctx.get("LEAD_PROVIDER", ""))
3001
+ launch = lead_launch_spec(lead_provider)
3002
+ prompt_file = (
3003
+ Path(str(ctx["INSTRUCTION_SET_PATH"])) / "lead-execution-prompt.md"
3004
+ )
3005
+ launch_argv = lead_launch_argv(
3006
+ lead_provider,
3007
+ model=str(ctx["LEAD_MODEL_EXECUTION_VALUE"]),
3008
+ session_id=str(ctx["CLAUDE_SESSION_ID"]),
3009
+ prompt=prompt_file.read_text(encoding="utf-8"),
3010
+ )
3011
+ launch_prefix_size = 1 + len(launch.sandbox_waiver)
3012
+ return {
3013
+ "leadRuntime": lead_runtime,
3014
+ "leadProvider": lead_provider,
3015
+ "leadExecutable": launch.executable,
3016
+ "leadSessionId": ctx["CLAUDE_SESSION_ID"],
3017
+ "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
3018
+ "projectRoot": ctx["PROJECT_ROOT"],
3019
+ "promptFile": str(prompt_file),
3020
+ "sandboxWaiverNote": launch.sandbox_waiver_note,
3021
+ "launchArgv": launch_argv,
3022
+ "launchRequestArgv": launch_argv[launch_prefix_size:],
3023
+ }
3024
+
3025
+
3004
3026
  if __name__ == "__main__":
3005
3027
  import sys
3006
3028
  raise SystemExit(main(sys.argv[1:]))
@@ -186,7 +186,7 @@ cat >&2 <<EOF
186
186
  EOF
187
187
 
188
188
  cd "$PROJECT_ROOT"
189
- exec claude --resume "$SESSION_ID"
189
+ exec "$HOME/.okstra/bin/okstra.sh" --resume-session "$SESSION_ID" --lead-runtime claude-code --project-root "$PROJECT_ROOT"
190
190
  """
191
191
  resume_command_path.write_text(body, encoding="utf-8")
192
192
  os.chmod(resume_command_path, 0o755)