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.
- package/README.md +7 -5
- package/docs/architecture.md +12 -7
- package/docs/cli.md +8 -5
- package/docs/project-structure-overview.md +7 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -0
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/usage.sh +5 -5
- package/runtime/bin/okstra.sh +87 -91
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
- package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/application/__init__.py +1 -0
- package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
- package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
- package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
- package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
- package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
- package/runtime/python/okstra_ctl/application/start_run.py +21 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
- package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
- package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
- package/runtime/python/okstra_ctl/domain/host.py +100 -0
- package/runtime/python/okstra_ctl/domain/provider.py +66 -0
- package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
- package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
- package/runtime/python/okstra_ctl/models.py +54 -269
- package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
- package/runtime/python/okstra_ctl/ports/host.py +32 -0
- package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
- package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
- package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
- package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
- package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
- package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
- package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
- package/runtime/python/okstra_ctl/render.py +145 -47
- package/runtime/python/okstra_ctl/run.py +80 -58
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team.py +44 -32
- package/runtime/python/okstra_ctl/wizard.py +162 -64
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
- package/runtime/python/okstra_token_usage/collect.py +34 -6
- package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
- package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
- package/runtime/skills/okstra-run/SKILL.md +73 -28
- package/runtime/templates/implementation-worker-preamble.md +1 -1
- package/runtime/templates/reports/final-report.template.md +3 -3
- package/runtime/templates/reports/html/i18n/en.json +3 -1
- package/runtime/templates/reports/html/i18n/ko.json +3 -1
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +17 -22
- package/runtime/validators/validate-report-views.py +0 -39
- package/runtime/validators/validate-run.py +8 -7
- package/runtime/validators/validate_session_conformance.py +7 -2
- package/src/commands/execute/render-bundle.mjs +4 -4
- package/src/commands/execute/run.mjs +8 -25
- package/src/commands/execute/wizard.mjs +33 -13
- package/src/commands/lifecycle/doctor.mjs +10 -10
- package/src/commands/lifecycle/install.mjs +53 -30
- package/src/commands/lifecycle/preflight.mjs +14 -4
- package/src/lib/host-registry-client.mjs +176 -0
- package/src/lib/runtime-manifest.mjs +6 -8
- package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
- package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
- package/src/lib/runtime-readiness.mjs +0 -90
- 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
|
|
4
|
-
is every lead's door onto cmux surfaces,
|
|
5
|
-
rather than the host. Which backend a run
|
|
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 .
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
128
|
+
_print_json(backend_plan.to_payload(dry_run=True))
|
|
113
129
|
return 0
|
|
114
|
-
result = dispatch_plan(
|
|
115
|
-
_print_json(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
if
|
|
257
|
-
|
|
258
|
-
raise DispatchError(
|
|
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
|
|
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
|
-
|
|
3605
|
-
|
|
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}"
|
|
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 =
|
|
3642
|
+
provider = default_host_registry().resolve(
|
|
3643
|
+
state.host_runtime
|
|
3644
|
+
).descriptor.native_provider_id
|
|
3633
3645
|
options = (
|
|
3634
|
-
["default", *
|
|
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 =
|
|
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
|
-
|
|
3660
|
+
requested_provider, raw = (
|
|
3646
3661
|
part.strip().lower() for part in raw.split(":", 1)
|
|
3647
3662
|
)
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
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=(
|
|
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:
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
762
|
-
|
|
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
|