okstra 0.163.2 → 0.165.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 (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -1050,11 +1050,16 @@ class UserResponseEntry:
1050
1050
 
1051
1051
 
1052
1052
  @dataclass(frozen=True)
1053
- class UserResponseApproval:
1054
- """HTML Plan Approval 위젯의 Export 결과. ``implementation_option`` 이 빈
1055
- 문자열이면 라인을 생략한다 (소비 측은 Recommended Option 폴백)."""
1056
- approved: bool
1053
+ class UserPlanDecision:
1054
+ """HTML 계획 결정 위젯의 Export 결과.
1055
+
1056
+ ``status`` 가 승인이 아니면 ``reason`` 이 필수다 — 사유 없는 반려는 다음
1057
+ run 이 무엇을 고쳐야 하는지 알 수 없어 되돌아올 수밖에 없다.
1058
+ ``implementation_option`` 이 빈 문자열이면 라인을 생략한다 (소비 측은
1059
+ Recommended Option 폴백)."""
1060
+ status: str
1057
1061
  implementation_option: str = ""
1062
+ reason: str = ""
1058
1063
 
1059
1064
 
1060
1065
  @dataclass(frozen=True)
@@ -1072,6 +1077,13 @@ _ANALYSIS_REVIEW_STATUSES = frozenset({
1072
1077
  "rejected",
1073
1078
  })
1074
1079
 
1080
+ PLAN_DECISION_APPROVED = "approved"
1081
+ _PLAN_DECISION_STATUSES = frozenset({
1082
+ PLAN_DECISION_APPROVED,
1083
+ "revision-requested",
1084
+ "rejected",
1085
+ })
1086
+
1075
1087
 
1076
1088
  def _quoted_sidecar_field(label: str, value: str) -> str:
1077
1089
  cleaned = value.strip()
@@ -1100,12 +1112,25 @@ def _serialize_analysis_review(review: UserResponseAnalysisReview) -> str:
1100
1112
  )
1101
1113
 
1102
1114
 
1115
+ def _serialize_plan_decision(decision: UserPlanDecision) -> str:
1116
+ if decision.status not in _PLAN_DECISION_STATUSES:
1117
+ raise ValueError(f"invalid PLAN DECISION status: {decision.status}")
1118
+ if decision.status != PLAN_DECISION_APPROVED and not decision.reason.strip():
1119
+ raise ValueError(f"PLAN DECISION {decision.status} requires a Reason")
1120
+ chunk = f"\n## PLAN DECISION\n- Status: {decision.status}\n"
1121
+ if decision.implementation_option:
1122
+ chunk += f"- Implementation-Option: {decision.implementation_option.strip()}\n"
1123
+ if decision.reason.strip():
1124
+ chunk += _quoted_sidecar_field("Reason", decision.reason)
1125
+ return chunk
1126
+
1127
+
1103
1128
  def serialize_user_response(
1104
1129
  *,
1105
1130
  run_meta: RunMeta,
1106
1131
  entries: list[UserResponseEntry],
1107
1132
  created_at: str,
1108
- approval: UserResponseApproval | None = None,
1133
+ plan_decision: UserPlanDecision | None = None,
1109
1134
  analysis_review: UserResponseAnalysisReview | None = None,
1110
1135
  ) -> str:
1111
1136
  """Return the canonical markdown text the HTML 'Export user
@@ -1125,7 +1150,7 @@ def serialize_user_response(
1125
1150
  "\n"
1126
1151
  "# User Response\n"
1127
1152
  )
1128
- has_approval = approval is not None and approval.approved
1153
+ has_plan_decision = plan_decision is not None
1129
1154
  has_analysis_review = analysis_review is not None
1130
1155
  body_chunks: list[str] = []
1131
1156
  for e in entries:
@@ -1137,13 +1162,10 @@ def serialize_user_response(
1137
1162
  if e.rationale:
1138
1163
  chunk += f"- Rationale: {e.rationale.strip()}\n"
1139
1164
  body_chunks.append(chunk)
1140
- if not entries and not has_approval and not has_analysis_review:
1165
+ if not entries and not has_plan_decision and not has_analysis_review:
1141
1166
  body_chunks.append("\n_(No user responses recorded.)_\n")
1142
- if has_approval:
1143
- chunk = "\n## APPROVAL\n- Approved: true\n"
1144
- if approval.implementation_option:
1145
- chunk += f"- Implementation-Option: {approval.implementation_option.strip()}\n"
1146
- body_chunks.append(chunk)
1167
+ if plan_decision is not None:
1168
+ body_chunks.append(_serialize_plan_decision(plan_decision))
1147
1169
  if analysis_review is not None:
1148
1170
  body_chunks.append(_serialize_analysis_review(analysis_review))
1149
1171
  return head + "".join(body_chunks)
@@ -1369,11 +1391,17 @@ def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
1369
1391
  )
1370
1392
  return (
1371
1393
  '<section id="plan-approval">\n'
1372
- " <h2>Plan Approval</h2>\n"
1394
+ " <h2>Plan Decision</h2>\n"
1373
1395
  f' <label>구현 옵션: <select id="approval-option"{disabled}>{"".join(opts)}</select></label>\n'
1374
- f' <label><input type="checkbox" id="approval-checkbox"{disabled}> '
1375
- "이 plan 을 승인합니다 — Export 시 sidecar 에 APPROVAL 블록으로 기록되고, "
1376
- "implementation 시작 마법사가 확인 후 적용합니다.</label>"
1396
+ ' <fieldset id="plan-decision"><legend>판정</legend>'
1397
+ f'<label><input type="radio" name="plan-decision-status" value="approved"{disabled}> '
1398
+ "이 plan 을 승인합니다</label>"
1399
+ '<label><input type="radio" name="plan-decision-status" '
1400
+ 'value="revision-requested"> 고쳐서 다시 가져오게 합니다</label>'
1401
+ '<label><input type="radio" name="plan-decision-status" value="rejected"> '
1402
+ "이 plan 을 반려합니다</label></fieldset>\n"
1403
+ ' <label>사유 — 반려하거나 다시 고치게 할 때는 반드시 적어야 합니다'
1404
+ '<textarea id="plan-decision-reason" rows="4"></textarea></label>'
1377
1405
  f"{reason_html}\n"
1378
1406
  "</section>\n"
1379
1407
  )
@@ -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)
@@ -9,6 +9,7 @@ which decides whether a re-run narrows or stays full.
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
+ from collections.abc import Iterator
12
13
 
13
14
  _RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
14
15
  _ITEM = rf"\d+(?:\s*{_RANGE}\s*\d+)?"
@@ -23,25 +24,61 @@ _ITEM_RE = re.compile(rf"(\d+)(?:\s*{_RANGE}\s*(\d+))?", re.IGNORECASE)
23
24
  RANGE_MAX_SPAN = 64
24
25
 
25
26
 
27
+ def _stage_citation_items(text: str) -> Iterator[tuple[int, int | None]]:
28
+ """Each `stage`-anchored citation in *text* as `(start, end)`.
29
+
30
+ *end* is None for a bare number and the far endpoint for a range. Numbers
31
+ stay anchored to a word-initial `stage`/`stages` token on the same line;
32
+ harvesting bare numbers — or letting the anchor reach across a line break,
33
+ or match the tail of `Substage`/`Backstage` — would let any prose, including
34
+ a row disclaiming every stage, justify any stage.
35
+ """
36
+ for span in _LIST_RE.finditer(text):
37
+ for item in _ITEM_RE.finditer(span.group(1)):
38
+ end = item.group(2)
39
+ yield int(item.group(1)), int(end) if end is not None else None
40
+
41
+
26
42
  def cited_stage_numbers(text: str) -> set[int]:
27
43
  """Stage numbers *text* cites, in every prose form a planner writes.
28
44
 
29
- Numbers stay anchored to a word-initial `stage`/`stages` token on the same
30
- line; harvesting bare numbers — or letting the anchor reach across a line
31
- break, or match the tail of `Substage`/`Backstage` — would let any prose,
32
- including a row disclaiming every stage, justify any stage.
45
+ A range reaches every number between its endpoints here, which is what a
46
+ reader asking "could this touch stage 5" needs.
33
47
  """
34
48
  cited: set[int] = set()
49
+ for start, end in _stage_citation_items(text):
50
+ cited.add(start)
51
+ if end is None:
52
+ continue
53
+ cited.add(end)
54
+ # A reversed or absurdly wide range is a typo, not a citation of
55
+ # everything between its endpoints.
56
+ if 0 <= end - start <= RANGE_MAX_SPAN:
57
+ cited.update(range(start, end))
58
+ return cited
59
+
60
+
61
+ def enumerated_stage_numbers(text: str) -> set[int]:
62
+ """Stage numbers *text* names one by one — a range's interior excluded.
63
+
64
+ The two readers of this grammar ask opposite questions, and a range answers
65
+ only one of them. The incremental-scope back-trace asks "could this answer
66
+ reach stage 5", so it must read `Stages 1-8` as reaching it — that is
67
+ `cited_stage_numbers`, and widening there is the safe direction. Coverage
68
+ provenance asks "did the planner confirm stage 5 satisfies this
69
+ requirement", and a range answers that for free: one `Stages 1-64` cell
70
+ stamps every stage in the map without the planner looking at any of them.
71
+
72
+ So the interior is dropped here and only hand-written numbers count. A
73
+ range's endpoints ARE hand-written and stay, which keeps `Stages 7-8`
74
+ honest while forcing the wide case to be spelled out. The cost of a
75
+ legitimately broad requirement is typing each number, and that typing is
76
+ the confirmation this check is asking for.
77
+ """
78
+ enumerated: set[int] = set()
35
79
  for span in _LIST_RE.finditer(text):
36
80
  for item in _ITEM_RE.finditer(span.group(1)):
37
- start = int(item.group(1))
38
- cited.add(start)
39
- if item.group(2) is None:
40
- continue
41
- end = int(item.group(2))
42
- cited.add(end)
43
- # A reversed or absurdly wide range is a typo, not a citation of
44
- # everything between its endpoints.
45
- if 0 <= end - start <= RANGE_MAX_SPAN:
46
- cited.update(range(start, end))
47
- return cited
81
+ enumerated.add(int(item.group(1)))
82
+ if item.group(2) is not None:
83
+ enumerated.add(int(item.group(2)))
84
+ return enumerated
@@ -1,8 +1,9 @@
1
1
  """Neutral okstra team CLI for pane-backed worker dispatch.
2
2
 
3
- Outside cmux this is the external lead's door onto tmux panes. Under cmux it
4
- is every lead's door onto cmux surfaces, because okstra owns the panes there
5
- rather than the host. Which backend a run uses is read from its run manifest.
3
+ Outside cmux this is the door onto tmux panes for hosts whose descriptor owns
4
+ the team lifecycle. Under cmux it is every lead's door onto cmux surfaces,
5
+ because okstra owns the panes there rather than the host. Which backend a run
6
+ uses is read from its run manifest.
6
7
  """
7
8
  from __future__ import annotations
8
9
 
@@ -14,18 +15,23 @@ from typing import Any, Mapping, Sequence
14
15
 
15
16
  from . import cmux
16
17
  from . import tmux
18
+ from .adapters.dispatch import provider_worker_wrappers
19
+ from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
20
+ from .application.dispatch_assignments import dispatch_assignments
17
21
  from .dispatch_core import (
18
22
  BACKEND_CMUX_PANE,
19
23
  BACKEND_TMUX_PANE,
20
24
  DispatchError,
21
25
  DispatchPlan,
22
26
  await_dispatches,
23
- build_dispatch_plan,
24
27
  dispatch_plan,
25
28
  )
26
- from .runner_resolution import worker_wrapper_registry
29
+ from .ports.worker_dispatch import WorkerDispatchRequest
30
+ from .registry.host_registry import default_host_registry
31
+ from .registry.provider_registry import default_provider_registry
27
32
 
28
- _SUPPORTED_WRAPPERS = worker_wrapper_registry()
33
+
34
+ _SUPPORTED_WRAPPERS = provider_worker_wrappers(default_provider_registry())
29
35
  _TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
30
36
 
31
37
 
@@ -92,33 +98,43 @@ def _dispatch(args) -> int:
92
98
  if args.jobs_file and args.workers:
93
99
  raise DispatchError("--jobs-file and --workers cannot be combined")
94
100
  manifest = _load_manifest(args.project_root, args.run_manifest)
95
- _validate_external_manifest(manifest)
96
- requested = _parse_workers(args.workers)
97
- plan = build_dispatch_plan(
101
+ terminal_backend = _manifest_backend(manifest)
102
+ lead_runtime = _require_string(manifest, "leadRuntime")
103
+ fallback_port = default_host_registry().resolve(
104
+ lead_runtime
105
+ ).worker_dispatch()
106
+ port = dispatch_port_for_terminal_backend(
107
+ terminal_backend,
108
+ fallback_port,
109
+ supported_worker_wrappers=_SUPPORTED_WRAPPERS,
110
+ unsupported_worker_label=f"{lead_runtime} lead",
111
+ required_lead_runtime=None if _is_cmux_run(manifest) else lead_runtime,
112
+ )
113
+ request = WorkerDispatchRequest(
98
114
  project_root=Path(args.project_root),
99
- run_manifest_path=Path(args.run_manifest),
115
+ run_manifest=Path(args.run_manifest),
100
116
  workspace_root=Path(args.workspace_root),
101
117
  okstra_bin=Path(args.okstra_bin),
102
- requested_workers=requested,
118
+ requested_workers=tuple(_parse_workers(args.workers)),
103
119
  idle_timeout_seconds=args.idle_timeout_seconds,
104
- required_lead_runtime=None if _is_cmux_run(manifest) else "external",
105
- default_backend=_manifest_backend(manifest),
106
- supported_worker_wrappers=_SUPPORTED_WRAPPERS,
107
- unsupported_worker_label="external lead",
108
120
  dispatch_kind=args.dispatch_kind,
109
121
  jobs_file=Path(args.jobs_file) if args.jobs_file else None,
110
122
  )
123
+ plan = dispatch_assignments(request, port)
124
+ backend_plan = plan.backend_plan
125
+ if backend_plan is None:
126
+ raise DispatchError("team dispatch did not produce a backend plan")
111
127
  if args.dry_run:
112
- _print_json(plan.to_payload(dry_run=True))
128
+ _print_json(backend_plan.to_payload(dry_run=True))
113
129
  return 0
114
- result = dispatch_plan(plan, wait=False)
115
- _print_json(plan.to_payload(dry_run=False))
130
+ result = dispatch_plan(backend_plan, wait=False)
131
+ _print_json(backend_plan.to_payload(dry_run=False))
116
132
  return result
117
133
 
118
134
 
119
135
  def _await(args) -> int:
120
136
  manifest = _load_manifest(args.project_root, args.run_manifest)
121
- _validate_external_manifest(manifest)
137
+ _validate_team_manifest(manifest)
122
138
  plan = _plan_for_existing(Path(args.project_root), Path(args.workspace_root), Path(args.run_manifest), manifest)
123
139
  code = await_dispatches(
124
140
  plan,
@@ -135,7 +151,7 @@ def _await(args) -> int:
135
151
 
136
152
  def _teardown(args) -> int:
137
153
  manifest = _load_manifest(args.project_root, args.run_manifest)
138
- _validate_external_manifest(manifest)
154
+ _validate_team_manifest(manifest)
139
155
  project_root = Path(args.project_root).resolve()
140
156
  team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
141
157
  team_state = _load_json(team_state_path, "team-state")
@@ -242,20 +258,16 @@ def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
242
258
  return _manifest_backend(manifest) == BACKEND_CMUX_PANE
243
259
 
244
260
 
245
- def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
246
- runtime = manifest.get("leadRuntime")
247
- if runtime == "external":
248
- return
261
+ def _validate_team_manifest(manifest: Mapping[str, Any]) -> None:
249
262
  if _is_cmux_run(manifest):
250
- # Under cmux okstra owns the panes for every lead, so this command is no
251
- # longer the external lead's private door and the advice below no longer
252
- # applies — there is nowhere else for a codex or Claude Code lead to go.
253
263
  return
254
- if runtime == "codex":
255
- raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
256
- if runtime == "claude-code":
257
- raise DispatchError("use Claude Code TeamCreate/Agent flow for leadRuntime=claude-code")
258
- raise DispatchError(f"unsupported leadRuntime for okstra team: {runtime}")
264
+ runtime = _require_string(manifest, "leadRuntime")
265
+ descriptor = default_host_registry().resolve(runtime).descriptor
266
+ if descriptor.launch_mode == "team":
267
+ return
268
+ raise DispatchError(
269
+ f"leadRuntime={descriptor.id} does not use the okstra team lifecycle"
270
+ )
259
271
 
260
272
 
261
273
  def _load_manifest(project_root: str, run_manifest: str) -> dict[str, Any]: