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,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]:
@@ -29,6 +29,23 @@ from datetime import datetime
29
29
  from pathlib import Path
30
30
  from typing import Any, Callable, Optional
31
31
 
32
+ from okstra_ctl.application.advance_wizard import plan_prompt
33
+ from okstra_ctl.application.resolve_assignment import resolve_lead_provider
34
+ from okstra_ctl.domain.host import (
35
+ HostCapabilityMismatch,
36
+ HostNotRegistered,
37
+ HostSessionContext,
38
+ ProviderUnavailable,
39
+ )
40
+ from okstra_ctl.domain.wizard.interaction import (
41
+ InteractionPlan,
42
+ WizardAnswerError,
43
+ WizardOption,
44
+ WizardPrompt,
45
+ normalize_planned_answer,
46
+ )
47
+ from okstra_ctl.registry.host_registry import default_host_registry
48
+ from okstra_ctl.registry.provider_registry import default_provider_registry
32
49
  from okstra_ctl.ids import slugify_task_segment
33
50
  from okstra_ctl.brief_frontmatter import read_brief_frontmatter
34
51
  from okstra_ctl.analysis_inputs import (
@@ -44,13 +61,12 @@ from okstra_ctl.analysis_inputs import (
44
61
  from okstra_ctl.models import (
45
62
  PROVIDER_MAPPINGS,
46
63
  UnknownModelError,
64
+ UnknownProviderError,
47
65
  picker_options,
48
66
  provider_ids,
49
67
  provider_supports_role,
50
68
  resolve_model_metadata,
51
69
  )
52
- from okstra_ctl.lead_runtime import lead_runtime_info
53
- from okstra_ctl.runner_resolution import native_provider_for_host
54
70
  from okstra_ctl.clarification_items import (
55
71
  scan_approval_gate,
56
72
  sidecar_answers,
@@ -176,7 +192,6 @@ _BRIEF_ENTRY_TASK_TYPES = (
176
192
 
177
193
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
178
194
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
179
- HOST_PICK_OPTION_CAP = 4
180
195
 
181
196
  CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
182
197
  CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
@@ -389,6 +404,8 @@ class WizardState:
389
404
  project_root: str = ""
390
405
  project_id: str = ""
391
406
  host_runtime: str = "claude-code"
407
+ host_entry_mode: str = "current-session"
408
+ available_functions: list[str] = field(default_factory=list)
392
409
 
393
410
  # task identity
394
411
  is_new_task: Optional[bool] = None
@@ -535,8 +552,6 @@ class Prompt:
535
552
  "echoTemplate": self.echo_template,
536
553
  "multi": self.multi,
537
554
  }
538
- if self.kind == "pick" and len(self.options) > HOST_PICK_OPTION_CAP:
539
- out["presentation"] = "numbered-text"
540
555
  if self.kind == "pick_group":
541
556
  out["questions"] = [
542
557
  {"step": q.step, "label": q.label,
@@ -551,35 +566,6 @@ class WizardError(Exception):
551
566
  """validation failure surfaced to user."""
552
567
 
553
568
 
554
- def _normalize_numbered_pick_item(prompt: Prompt, answer: str) -> str:
555
- candidate = (answer or "").strip()
556
- exact_values = [option.value for option in prompt.options if option.value == candidate]
557
- if exact_values:
558
- return exact_values[0]
559
-
560
- if candidate.isdecimal():
561
- number = int(candidate)
562
- if 1 <= number <= len(prompt.options):
563
- return prompt.options[number - 1].value
564
- raise WizardError(f"numbered-text answer is out of range: {candidate}")
565
-
566
- label_values = [option.value for option in prompt.options if option.label == candidate]
567
- if len(label_values) == 1:
568
- return label_values[0]
569
- if len(label_values) > 1:
570
- raise WizardError(f"numbered-text answer label is ambiguous: {candidate}")
571
- raise WizardError(f"numbered-text answer is not an option: {candidate}")
572
-
573
-
574
- def _normalize_numbered_pick_answer(prompt: Prompt, answer: str) -> str:
575
- if prompt.multi:
576
- return ",".join(
577
- _normalize_numbered_pick_item(prompt, item)
578
- for item in (answer or "").split(",")
579
- )
580
- return _normalize_numbered_pick_item(prompt, answer)
581
-
582
-
583
569
  # ---- Validation helpers --------------------------------------------------
584
570
 
585
571
  _SLUG_OK = re.compile(r"[a-z0-9]")
@@ -3369,6 +3355,28 @@ def _build_reuse_previous(state: WizardState) -> Prompt:
3369
3355
  )
3370
3356
 
3371
3357
 
3358
+ def _restore_reused_lead(state: WizardState, inputs: dict) -> None:
3359
+ requested_provider = _safe_provider(inputs.get("leadProvider"), "lead")
3360
+ try:
3361
+ assignment = resolve_lead_provider(
3362
+ host_id=state.host_runtime,
3363
+ requested_provider=requested_provider,
3364
+ host_registry=default_host_registry(),
3365
+ provider_registry=default_provider_registry(),
3366
+ )
3367
+ except (
3368
+ HostCapabilityMismatch,
3369
+ HostNotRegistered,
3370
+ ProviderUnavailable,
3371
+ UnknownProviderError,
3372
+ ) as exc:
3373
+ raise WizardError(
3374
+ f"previous run cannot be reused: {exc}; choose step-by-step re-entry"
3375
+ ) from exc
3376
+ state.lead_provider = assignment.provider
3377
+ state.lead_model = _safe_model(assignment.provider, inputs.get("leadModel"))
3378
+
3379
+
3372
3380
  def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
3373
3381
  if value not in ("yes", "no"):
3374
3382
  raise WizardError(f"expected 'yes' or 'no', got: {value!r}")
@@ -3383,16 +3391,7 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
3383
3391
  state.workers_override = (
3384
3392
  ",".join(w for w in workers if isinstance(w, str))
3385
3393
  if isinstance(workers, list) else "")
3386
- state.lead_provider = _safe_provider(inputs.get("leadProvider"), "lead")
3387
- lead_provider = state.lead_provider or "claude"
3388
- native_provider = native_provider_for_host(state.host_runtime)
3389
- if native_provider and lead_provider != native_provider:
3390
- raise WizardError(
3391
- f"previous run used provider {lead_provider!r}, but "
3392
- f"{state.host_runtime} requires provider {native_provider!r}; "
3393
- "choose step-by-step re-entry"
3394
- )
3395
- state.lead_model = _safe_model(lead_provider, inputs.get("leadModel"))
3394
+ _restore_reused_lead(state, inputs)
3396
3395
  state.claude_model = _safe_model("claude", inputs.get("claudeModel"))
3397
3396
  state.codex_model = _safe_model("codex", inputs.get("codexModel"))
3398
3397
  state.antigravity_model = _safe_model("antigravity", inputs.get("antigravityModel"))
@@ -3601,16 +3600,27 @@ def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
3601
3600
 
3602
3601
 
3603
3602
  def _qualified_role_model_options(role: str) -> list[str]:
3604
- options = list(CLAUDE_MODEL_OPTIONS)
3605
- for provider in provider_ids(role):
3603
+ registry = default_provider_registry()
3604
+ options = ["default", *_provider_picker_options("claude", registry)]
3605
+ for provider in registry.ids(role):
3606
3606
  if provider == "claude":
3607
3607
  continue
3608
3608
  options.extend(
3609
- f"{provider}:{model}" for model in picker_options(provider)
3609
+ f"{provider}:{model}"
3610
+ for model in _provider_picker_options(provider, registry)
3610
3611
  )
3611
3612
  return options
3612
3613
 
3613
3614
 
3615
+ def _provider_picker_options(provider: str, registry=None) -> list[str]:
3616
+ provider_registry = registry or default_provider_registry()
3617
+ return [
3618
+ alias
3619
+ for alias, model in provider_registry.resolve(provider).models.items()
3620
+ if model.in_picker
3621
+ ]
3622
+
3623
+
3614
3624
  def _submit_qualified_role_model(
3615
3625
  state: WizardState, value: str, role: str, model_field: str, provider_field: str,
3616
3626
  ) -> str:
@@ -3629,32 +3639,44 @@ def _submit_qualified_role_model(
3629
3639
 
3630
3640
  def _build_lead_model(state: WizardState) -> Prompt:
3631
3641
  t = _p(state.workspace_root, "lead_model")
3632
- provider = native_provider_for_host(state.host_runtime)
3642
+ provider = default_host_registry().resolve(
3643
+ state.host_runtime
3644
+ ).descriptor.native_provider_id
3633
3645
  options = (
3634
- ["default", *picker_options(provider)]
3646
+ ["default", *_provider_picker_options(provider)]
3635
3647
  if provider else _qualified_role_model_options("lead")
3636
3648
  )
3637
3649
  return _model_pick(S_LEAD_MODEL, t["label"], options, t["echo_template"])
3638
3650
 
3639
3651
 
3640
3652
  def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
3641
- native_provider = native_provider_for_host(state.host_runtime)
3653
+ native_provider = default_host_registry().resolve(
3654
+ state.host_runtime
3655
+ ).descriptor.native_provider_id
3642
3656
  if native_provider:
3643
3657
  raw = (value or "").strip()
3658
+ requested_provider = ""
3644
3659
  if ":" in raw:
3645
- selected_provider, raw = (
3660
+ requested_provider, raw = (
3646
3661
  part.strip().lower() for part in raw.split(":", 1)
3647
3662
  )
3648
- if selected_provider != native_provider:
3649
- host_label = lead_runtime_info(state.host_runtime).agent_label
3650
- raise WizardError(
3651
- f"{host_label.removesuffix(' CLI')} host requires provider "
3652
- f"{native_provider!r} "
3653
- "for the in-session lead"
3654
- )
3655
- state.lead_provider = native_provider
3656
- state.lead_model = _validate_model(native_provider, raw)
3657
- return f"lead-model: {native_provider}:{state.lead_model or 'default'}"
3663
+ try:
3664
+ assignment = resolve_lead_provider(
3665
+ host_id=state.host_runtime,
3666
+ requested_provider=requested_provider,
3667
+ host_registry=default_host_registry(),
3668
+ provider_registry=default_provider_registry(),
3669
+ )
3670
+ except (
3671
+ HostCapabilityMismatch,
3672
+ HostNotRegistered,
3673
+ ProviderUnavailable,
3674
+ UnknownProviderError,
3675
+ ) as exc:
3676
+ raise WizardError(str(exc)) from exc
3677
+ state.lead_provider = assignment.provider
3678
+ state.lead_model = _validate_model(assignment.provider, raw)
3679
+ return f"lead-model: {assignment.provider}:{state.lead_model or 'default'}"
3658
3680
  selected = _submit_qualified_role_model(
3659
3681
  state, value, "lead", "lead_model", "lead_provider",
3660
3682
  )
@@ -4529,6 +4551,8 @@ def _reset_field(state: WizardState, fname: str) -> None:
4529
4551
  def init_state(
4530
4552
  *, workspace_root: str, project_root: str, project_id: str,
4531
4553
  host_runtime: str = "claude-code",
4554
+ host_entry_mode: str = "current-session",
4555
+ available_functions: Optional[list[str]] = None,
4532
4556
  ) -> WizardState:
4533
4557
  """Bootstrap a new wizard state."""
4534
4558
  return WizardState(
@@ -4536,6 +4560,8 @@ def init_state(
4536
4560
  project_root=project_root,
4537
4561
  project_id=project_id,
4538
4562
  host_runtime=host_runtime,
4563
+ host_entry_mode=host_entry_mode,
4564
+ available_functions=list(available_functions or []),
4539
4565
  )
4540
4566
 
4541
4567
 
@@ -4668,9 +4694,73 @@ def _screen_progress(state: WizardState) -> dict[str, Any]:
4668
4694
  }
4669
4695
 
4670
4696
 
4697
+ def _domain_prompt(prompt: Prompt) -> WizardPrompt:
4698
+ return WizardPrompt(
4699
+ step=prompt.step,
4700
+ kind=prompt.kind,
4701
+ label=prompt.label,
4702
+ options=tuple(
4703
+ WizardOption(option.value, option.label, option.description)
4704
+ for option in prompt.options
4705
+ ),
4706
+ help=prompt.help,
4707
+ echo_template=prompt.echo_template,
4708
+ multi=prompt.multi,
4709
+ questions=tuple(_domain_prompt(question) for question in prompt.questions),
4710
+ )
4711
+
4712
+
4713
+ def _interaction_plan(state: WizardState, prompt: Prompt) -> InteractionPlan:
4714
+ context = HostSessionContext(
4715
+ host_id=state.host_runtime,
4716
+ entry_mode=state.host_entry_mode,
4717
+ available_functions=frozenset(state.available_functions),
4718
+ interaction_surface="terminal",
4719
+ )
4720
+ adapter = default_host_registry().resolve(state.host_runtime)
4721
+ return plan_prompt(_domain_prompt(prompt), context, adapter.interaction()).interaction
4722
+
4723
+
4724
+ def _interaction_payload(plan: InteractionPlan) -> dict[str, Any]:
4725
+ return {
4726
+ "kind": plan.kind,
4727
+ "answerProtocol": {
4728
+ "kind": plan.answer_protocol.kind,
4729
+ "multi": plan.answer_protocol.multi,
4730
+ },
4731
+ }
4732
+
4733
+
4734
+ def _normalize_interaction_answer(
4735
+ state: WizardState,
4736
+ prompt: Prompt,
4737
+ plan: InteractionPlan,
4738
+ value: str,
4739
+ ) -> str:
4740
+ try:
4741
+ return normalize_planned_answer(
4742
+ _domain_prompt(prompt),
4743
+ plan.answer_protocol,
4744
+ value,
4745
+ )
4746
+ except WizardAnswerError as exc:
4747
+ is_legacy_raw_answer = (
4748
+ not state.available_functions
4749
+ and "not an option" in str(exc)
4750
+ and not (value or "").strip().isdecimal()
4751
+ )
4752
+ if is_legacy_raw_answer:
4753
+ return value
4754
+ raise WizardError(str(exc)) from exc
4755
+
4756
+
4671
4757
  def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
4672
4758
  """Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
4673
4759
  out = prompt.to_json()
4760
+ plan = _interaction_plan(state, prompt)
4761
+ out["interaction"] = _interaction_payload(plan)
4762
+ if plan.kind in {"numbered-single", "numbered-multi"}:
4763
+ out["presentation"] = "numbered-text"
4674
4764
  if prompt.kind not in ("done", "aborted"):
4675
4765
  out["progress"] = _screen_progress(state)
4676
4766
  return out
@@ -4711,10 +4801,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
4711
4801
  prompt = next_prompt(state)
4712
4802
  if prompt.kind in ("done", "aborted"):
4713
4803
  return {"echo": "", "next": prompt_payload(state, prompt)}
4804
+ plan = _interaction_plan(state, prompt)
4805
+ value = _normalize_interaction_answer(state, prompt, plan, value)
4714
4806
  if prompt.kind == "pick_group":
4715
4807
  return _submit_group(state, prompt, value)
4716
- if prompt.kind == "pick" and len(prompt.options) > HOST_PICK_OPTION_CAP:
4717
- value = _normalize_numbered_pick_answer(prompt, value)
4718
4808
  step = STEP_BY_ID[prompt.step]
4719
4809
  echo = step.submit(state, value or "")
4720
4810
  if prompt.step not in state.answered:
@@ -5048,8 +5138,14 @@ def _cli(argv: list[str]) -> int:
5048
5138
  p_init.add_argument(
5049
5139
  "--host-runtime",
5050
5140
  default="claude-code",
5051
- choices=("claude-code", "codex", "antigravity", "external"),
5141
+ choices=default_host_registry().ids(),
5142
+ )
5143
+ p_init.add_argument(
5144
+ "--entry-mode",
5145
+ default="current-session",
5146
+ choices=("spawn-process", "current-session"),
5052
5147
  )
5148
+ p_init.add_argument("--available-function", action="append", default=[])
5053
5149
  p_init.add_argument("--critic", default="")
5054
5150
 
5055
5151
  p_step = sub.add_parser("step")
@@ -5079,6 +5175,8 @@ def _cli(argv: list[str]) -> int:
5079
5175
  project_root=args.project_root,
5080
5176
  project_id=args.project_id,
5081
5177
  host_runtime=args.host_runtime,
5178
+ host_entry_mode=args.entry_mode,
5179
+ available_functions=args.available_function,
5082
5180
  )
5083
5181
  if args.critic:
5084
5182
  state.critic = args.critic
@@ -29,8 +29,13 @@ READING_CONFIRMATION_HEADING_RE = re.compile(
29
29
  r"^##[ \t]+0\.[ \t]+Reading Confirmation\b", re.MULTILINE
30
30
  )
31
31
 
32
+ # The row's prefix opens it and the end of the line closes it, so the backticks
33
+ # the template prescribes are decoration and are accepted either way. Requiring
34
+ # them once cost a run: a worker wrote six correct paths bare, none of the rows
35
+ # parsed, and every citation it made read as unbacked — a file it demonstrably
36
+ # opened counted as unread over markdown punctuation.
32
37
  _EVIDENCE_READ_RE = re.compile(
33
- r"^- Evidence read: `(?P<path>[^`\n]+)`\s*$",
38
+ r"^- Evidence read: `?(?P<path>[^`\n]+?)`?\s*$",
34
39
  re.MULTILINE,
35
40
  )
36
41
  _FILE_LINE_CITATION_RE = re.compile(
@@ -106,17 +111,37 @@ def _evidence_read_ledger_failures(
106
111
  except OSError as exc:
107
112
  return [f"worker audit sidecar unreadable: {audit_path.name} ({exc})"]
108
113
 
109
- missing_paths = sorted(
110
- _cited_file_paths(result_content) - _audit_evidence_read_paths(audit_content)
111
- )
114
+ read_paths = _audit_evidence_read_paths(audit_content)
115
+ missing_paths = sorted(_cited_file_paths(result_content) - read_paths)
112
116
  return [
113
117
  f"worker `{worker_role}` result `{result_name}` cites "
114
118
  f"`{missing_path}:line` without an Evidence read row for "
115
119
  f"`{missing_path}` in `{audit_path.name}`"
120
+ + _ledger_path_hint(missing_path, read_paths)
116
121
  for missing_path in missing_paths
117
122
  ]
118
123
 
119
124
 
125
+ def _ledger_path_hint(cited_path: str, read_paths: set[str]) -> str:
126
+ """What to cite instead, when the ledger plainly holds the same file.
127
+
128
+ A worker that cites the full path once and the bare filename afterwards
129
+ reads to this check as never having opened the file, and the bare failure
130
+ text sends the reader looking for a missing read that is not missing. One
131
+ unambiguous ledger row makes the remedy a rewritten citation; two rows
132
+ ending the same way make it a guess, so those say nothing.
133
+ """
134
+ if "/" in cited_path:
135
+ return ""
136
+ same_name = [path for path in read_paths if path.rsplit("/", 1)[-1] == cited_path]
137
+ if len(same_name) != 1:
138
+ return ""
139
+ return (
140
+ f" — the ledger records it as `{same_name[0]}`; cite that "
141
+ f"project-relative path, not the bare filename"
142
+ )
143
+
144
+
120
145
  def _result_files(run_dir: Path, task_type: str, seq: str | None, worker: str | None):
121
146
  """Every worker-results file in *run_dir* this check owns, in name order."""
122
147
  for path in sorted((run_dir / "worker-results").glob("*.md")):
@@ -22,7 +22,6 @@ from .antigravity import (
22
22
  from .paths import claude_project_dir, utc_now
23
23
  from .pricing import antigravity_cost_usd, provider_cost_usd
24
24
  from okstra_ctl.models import provider_wrappers
25
- from okstra_ctl.lead_runtime import ARTIFACT_ONLY_LEAD_RUNTIMES
26
25
  from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
27
26
 
28
27
 
@@ -758,19 +757,20 @@ def _populate_usage_summary(
758
757
  }
759
758
 
760
759
 
761
- def collect(team_state_path: Path, project_root: Path | None = None, *,
762
- incremental: bool = True) -> dict:
760
+ def collect_claude_runtime_usage(
761
+ team_state_path: Path,
762
+ project_root: Path | None = None,
763
+ *,
764
+ incremental: bool = True,
765
+ ) -> dict:
763
766
  # incremental: 세션 jsonl 스캔에 byte cursor 캐시 사용 (P6). 캐시는 윈도우
764
767
  # 적용 전 이벤트를 저장하므로 결과는 전체 스캔과 동일 — False 는 캐시 경로를
765
768
  # 완전히 우회하는 정확성 폴백(CLI --no-cache).
766
769
  state = json.loads(team_state_path.read_text())
767
770
  cwd = project_root or _infer_project_root(team_state_path, state)
768
771
  run_since, run_until = resolve_run_window(team_state_path, state)
769
- task_key = state.get("taskKey", "")
770
772
  team_name = resolve_team_name(state)
771
773
  lead_sid = (state.get("lead") or {}).get("sessionId")
772
- if state.get("leadRuntime") in ARTIFACT_ONLY_LEAD_RUNTIMES:
773
- return _collect_cli_runtime_usage(state, cwd)
774
774
 
775
775
  # 1) Claude sessions (lead + claude-side workers). Cache totals at scan
776
776
  # time so we don't re-read the jsonl when a worker matches multiple
@@ -936,6 +936,34 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
936
936
  return state
937
937
 
938
938
 
939
+ def collect_cli_runtime_usage(
940
+ team_state_path: Path,
941
+ project_root: Path | None = None,
942
+ *,
943
+ incremental: bool = True,
944
+ ) -> dict:
945
+ state = json.loads(team_state_path.read_text())
946
+ cwd = project_root or _infer_project_root(team_state_path, state)
947
+ return _collect_cli_runtime_usage(state, cwd)
948
+
949
+
950
+ def collect(
951
+ team_state_path: Path,
952
+ project_root: Path | None = None,
953
+ *,
954
+ incremental: bool = True,
955
+ ) -> dict:
956
+ from okstra_ctl.application.collect_usage import collect_usage
957
+ from okstra_ctl.ports.usage_accounting import UsageRequest
958
+ from okstra_ctl.registry.host_registry import default_host_registry
959
+
960
+ state = json.loads(team_state_path.read_text())
961
+ cwd = project_root or _infer_project_root(team_state_path, state)
962
+ adapter = default_host_registry().resolve(str(state.get("leadRuntime") or ""))
963
+ request = UsageRequest(team_state_path, cwd, incremental)
964
+ return collect_usage(request, adapter.usage_accounting()).payload
965
+
966
+
939
967
  def _infer_project_root(team_state_path: Path, state: dict) -> Path:
940
968
  rel = state.get("runDirectoryPath") or ""
941
969
  p = team_state_path.resolve().parent