okstra 0.160.0 → 0.162.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.
@@ -2,7 +2,6 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
- import os
6
5
  import subprocess
7
6
  import time
8
7
  from dataclasses import dataclass
@@ -10,9 +9,11 @@ from datetime import datetime, timezone
10
9
  from pathlib import Path
11
10
  from typing import Any, Mapping, Sequence
12
11
 
12
+ from . import cmux
13
13
  from . import tmux
14
14
  from .dispatch_state import (
15
15
  BACKEND_CLI_WRAPPER,
16
+ BACKEND_CMUX_PANE,
16
17
  BACKEND_MIXED,
17
18
  BACKEND_TMUX_PANE,
18
19
  DispatchError,
@@ -185,11 +186,19 @@ def build_dispatch_plan(
185
186
 
186
187
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
187
188
  if wait:
188
- if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
189
+ pane_backends = sorted(
190
+ {
191
+ job.backend
192
+ for job in plan.jobs
193
+ if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
194
+ }
195
+ )
196
+ if pane_backends:
189
197
  raise DispatchError(
190
- "wait=True dispatch does not support tmux-pane workers: "
191
- "the per-job blocking loop would serialize panes instead of "
192
- "running them concurrently; dispatch tmux panes with wait=False"
198
+ f"wait=True dispatch does not support {'/'.join(pane_backends)} "
199
+ "workers: the per-job blocking loop would serialize panes "
200
+ "instead of running them concurrently; dispatch panes with "
201
+ "wait=False"
193
202
  )
194
203
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
195
204
  for job in plan.jobs:
@@ -427,9 +436,30 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
427
436
  return _run_cli_wrapper(plan, job, "")
428
437
  if job.backend == BACKEND_TMUX_PANE:
429
438
  return _start_tmux_or_degrade(plan, job)
439
+ if job.backend == BACKEND_CMUX_PANE:
440
+ return _start_cmux_or_degrade(plan, job)
430
441
  raise DispatchError(f"unsupported worker backend: {job.backend}")
431
442
 
432
443
 
444
+ def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
445
+ workspace = cmux.resolve_lead_workspace()
446
+ if not workspace:
447
+ _refuse_when_cmux_is_walled_off()
448
+ return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
449
+ try:
450
+ surface_id = cmux.spawn_worker_surface(
451
+ workspace=workspace,
452
+ cwd=plan.project_root,
453
+ command=job.command,
454
+ title=f"{job.worker_id}-worker",
455
+ )
456
+ except (RuntimeError, OSError, subprocess.SubprocessError):
457
+ return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
458
+ return WorkerHandle(
459
+ job, surface_id, None, status_path_for_prompt(job.prompt_path), ""
460
+ )
461
+
462
+
433
463
  def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
434
464
  lead_pane = tmux.resolve_caller_pane()
435
465
  if not lead_pane:
@@ -447,17 +477,35 @@ def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
447
477
  return WorkerHandle(job, pane_id, None, status_path_for_prompt(job.prompt_path), "")
448
478
 
449
479
 
480
+ def _refuse_when_cmux_is_walled_off() -> None:
481
+ """Stop rather than degrade when a sandbox stands between okstra and cmux.
482
+
483
+ prepare records `cmux-pane` only after reaching cmux, so losing it by
484
+ dispatch time means something came between. When the socket is present but
485
+ connecting is denied, that something is a sandbox around the lead — and the
486
+ same sandbox hides the worker CLIs' own config, so the fallback this would
487
+ otherwise take is already dead. Degrading here spends every worker's retry
488
+ budget on the same wall and leaves the user with three failures and no
489
+ explanation, which is exactly what it looks like when nothing happens.
490
+ """
491
+ reason = cmux.unreachable_reason()
492
+ if reason not in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
493
+ return
494
+ observed = (
495
+ "this process has no cmux environment left — a sandbox sanitized it"
496
+ if reason == cmux.LOST_ENVIRONMENT
497
+ else "cmux is running but this process may not connect to its socket"
498
+ )
499
+ raise DispatchError(
500
+ f"this run was prepared for cmux panes, but {observed}. The same "
501
+ "sandbox blocks worker CLIs from their own config, so falling back to "
502
+ "blocking workers would fail too. Relaunch the lead outside a sandbox "
503
+ "(codex: `codex -s danger-full-access`)."
504
+ )
505
+
506
+
450
507
  def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
451
- env = {
452
- **os.environ,
453
- "OKSTRA_WORKER_ID": job.worker_id,
454
- "OKSTRA_WORKER_RESULT_PATH": str(job.result_path),
455
- "OKSTRA_WORKER_AUDIT_PATH": str(job.worker_result_path),
456
- "OKSTRA_RUN_MANIFEST_PATH": str(plan.manifest_path),
457
- }
458
- if job.worker_id == REPORT_WRITER_WORKER_ID:
459
- env["OKSTRA_REPORT_WRITER_MARKDOWN_PATH"] = str(_final_report_markdown_path(job.result_path))
460
- completed = subprocess.run(job.command, cwd=plan.project_root, env=env, text=True)
508
+ completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
461
509
  return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
462
510
 
463
511
 
@@ -561,7 +609,7 @@ def _dispatch_record(
561
609
 
562
610
 
563
611
  def _liveness_mode(backend: str) -> str:
564
- if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
612
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
565
613
  return LIVENESS_WRAPPER_STATUS
566
614
  return LIVENESS_AUDIT_HEARTBEAT
567
615
 
@@ -615,7 +663,44 @@ def _skip_reasons(
615
663
  return {w: "skipped by worker dispatch default: worker is not supported by this dispatcher" for w in _string_list(manifest.get("recommendedWorkers")) if w not in set(selected) and w not in supported}
616
664
 
617
665
 
666
+ _SIDEBAR_LEVELS = {
667
+ "worker-dispatched": "progress",
668
+ "worker-result-collected": "success",
669
+ "worker-retry-scheduled": "warning",
670
+ "worker-failed": "error",
671
+ }
672
+
673
+
674
+ def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
675
+ """Mirror a dispatch event onto the cmux sidebar.
676
+
677
+ A long run is mostly silence, and the sidebar is the one surface still
678
+ visible after the user scrolls away or switches workspaces. A failed worker
679
+ additionally raises a notification, because that is the event whose cost
680
+ grows the longer it goes unnoticed.
681
+ """
682
+ # The plan's backend, not the manifest field it was resolved from: a job
683
+ # that degraded to the blocking wrapper is still part of a cmux run, and
684
+ # that degradation is exactly what the sidebar should keep showing.
685
+ if plan.default_backend != BACKEND_CMUX_PANE:
686
+ return
687
+ workspace = cmux.resolve_lead_workspace()
688
+ worker_id = str(details.get("workerId", "") or "worker")
689
+ cmux.sidebar_log(
690
+ workspace,
691
+ f"{worker_id}: {event_type.removeprefix('worker-')}",
692
+ level=_SIDEBAR_LEVELS.get(event_type, "info"),
693
+ )
694
+ if event_type == "worker-failed":
695
+ cmux.sidebar_notify(
696
+ workspace,
697
+ title=f"okstra — {_require_string(plan.manifest, 'taskType')}",
698
+ body=f"{worker_id} failed: {details.get('reason', 'no reason recorded')}",
699
+ )
700
+
701
+
618
702
  def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
703
+ _relay_to_sidebar(plan, event_type, details)
619
704
  append_lead_event(
620
705
  plan.lead_events_path,
621
706
  LeadEvent(
@@ -24,6 +24,7 @@ from datetime import datetime, timezone
24
24
  from pathlib import Path
25
25
  from typing import Any, Callable, Mapping, Sequence
26
26
 
27
+ from . import cmux
27
28
  from .worker_prompt_contract import (
28
29
  PromptRecord,
29
30
  validate_initial_prompt_records,
@@ -32,8 +33,23 @@ from .worker_prompt_contract import (
32
33
 
33
34
  BACKEND_CLI_WRAPPER = "cli-wrapper"
34
35
  BACKEND_TMUX_PANE = "tmux-pane"
36
+ BACKEND_CMUX_PANE = "cmux-pane"
35
37
  BACKEND_MIXED = "mixed"
36
38
 
39
+
40
+ def detect_terminal_backend() -> str:
41
+ """Which pane backend this run gets. Called once, by prepare.
42
+
43
+ cmux wins wherever it is usable: the only thing a tmux session buys a lead
44
+ is Claude Code's AgentTeam, and the cmux path deliberately does not use it.
45
+ Everywhere else this answers `tmux-pane`, which is what every run got before
46
+ cmux existed. The answer is written to the run manifest and read back from
47
+ there — consumers must not re-detect, or two phases of one run can disagree.
48
+ """
49
+ if cmux.cmux_available():
50
+ return BACKEND_CMUX_PANE
51
+ return BACKEND_TMUX_PANE
52
+
37
53
  # `livenessMode` picks which artifact answers "is this worker still alive": the
38
54
  # in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
39
55
  # sidecar. Both dispatchers write it and `worker_liveness` reads it, so the
@@ -42,6 +42,43 @@ class IncrementalDecision:
42
42
  reason: str
43
43
 
44
44
 
45
+ @dataclass(frozen=True)
46
+ class UserReverifyScope:
47
+ """What the user answered at the wizard's re-verification-scope step.
48
+
49
+ The lead still runs `okstra incremental-scope`; this only says which of the
50
+ CLI's inputs the user pinned. `auto` pins nothing.
51
+ """
52
+ mode: str # "auto" | "full" | "stages"
53
+ stages: list[int]
54
+
55
+
56
+ class ReverifyScopeError(ValueError):
57
+ """The `--reverify-scope` value is not one of the three accepted forms."""
58
+
59
+
60
+ def parse_user_reverify_scope(raw: str) -> UserReverifyScope:
61
+ """`--reverify-scope` → the user's pinned scope.
62
+
63
+ Accepts exactly `""` / `auto`, `full`, or a comma-separated stage list.
64
+ Anything else raises rather than degrading to `auto`: a typo silently read
65
+ as "let the lead decide" would drop a full-re-verification request the user
66
+ made on purpose, and the run would look like it honoured it.
67
+ """
68
+ value = (raw or "").strip()
69
+ if not value or value == "auto":
70
+ return UserReverifyScope("auto", [])
71
+ if value == "full":
72
+ return UserReverifyScope("full", [])
73
+ tokens = [token.strip() for token in value.split(",") if token.strip()]
74
+ if not tokens or not all(token.isdigit() for token in tokens):
75
+ raise ReverifyScopeError(
76
+ f"--reverify-scope must be 'auto', 'full', or a stage-number list "
77
+ f"(e.g. '2,3'); got {raw!r}"
78
+ )
79
+ return UserReverifyScope("stages", sorted({int(token) for token in tokens}))
80
+
81
+
45
82
  def _parse_depends_on(cell: str) -> list[int]:
46
83
  text = (cell or "").strip()
47
84
  if not text or text == "(none)":
@@ -1,7 +1,7 @@
1
1
  """Lead runtime metadata shared by render and prepare paths."""
2
2
  from __future__ import annotations
3
3
 
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, replace
5
5
 
6
6
 
7
7
  @dataclass(frozen=True)
@@ -24,9 +24,14 @@ class LeadRuntimeInfo:
24
24
  }
25
25
 
26
26
 
27
- ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex", "antigravity", "external")
28
27
  ARTIFACT_ONLY_LEAD_RUNTIMES = frozenset({"codex", "antigravity", "external"})
29
28
 
29
+ # Not a lead runtime — an adapter selected by the environment, so it is absent
30
+ # from ALLOWED_LEAD_RUNTIMES and from _LEAD_RUNTIMES on purpose. `--lead-runtime
31
+ # cmux` is not a thing a user can ask for; see `with_cmux_dispatch`.
32
+ CMUX_ADAPTER_NAME = "cmux"
33
+ CMUX_ADAPTER_RELATIVE_PATH = "lead/adapters/cmux.md"
34
+
30
35
  _LEAD_RUNTIMES = {
31
36
  "claude-code": LeadRuntimeInfo(
32
37
  runtime="claude-code",
@@ -75,6 +80,11 @@ _LEAD_RUNTIMES = {
75
80
  }
76
81
 
77
82
 
83
+ # Derived rather than restated: a hand-written copy of the registry's own keys
84
+ # is a list that can disagree with the thing it describes.
85
+ ALLOWED_LEAD_RUNTIMES = tuple(_LEAD_RUNTIMES)
86
+
87
+
78
88
  def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
79
89
  try:
80
90
  return _LEAD_RUNTIMES[runtime]
@@ -85,3 +95,21 @@ def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
85
95
 
86
96
  def is_artifact_only_runtime(runtime: str) -> bool:
87
97
  return runtime in ARTIFACT_ONLY_LEAD_RUNTIMES
98
+
99
+
100
+ def with_cmux_dispatch(info: LeadRuntimeInfo) -> LeadRuntimeInfo:
101
+ """The same lead, minus the two fields cmux takes over.
102
+
103
+ On the cmux path okstra owns the worker panes rather than the host, so
104
+ "which adapter" and "does this lead dispatch or only render" stop depending
105
+ on who the lead is — they are the same for every runtime. Overriding the two
106
+ fields keeps that answer in one adapter file rather than repeating it in
107
+ four. Everything else (agent, label, role, session accounting) still
108
+ describes the lead itself and is left untouched.
109
+ """
110
+ return replace(
111
+ info,
112
+ adapter_name=CMUX_ADAPTER_NAME,
113
+ adapter_dispatch_mode="team",
114
+ adapter_contract_relative_path=CMUX_ADAPTER_RELATIVE_PATH,
115
+ )
@@ -94,6 +94,26 @@ ANALYSIS_ROLES = frozenset({"analyser", "critic"})
94
94
  IMPLEMENTATION_ROLES = frozenset({"executor", "verifier"})
95
95
 
96
96
 
97
+ @dataclass(frozen=True)
98
+ class LeadLaunchSpec:
99
+ """How to start this provider's CLI as an interactive lead session.
100
+
101
+ The worker wrappers cannot serve here: they run one non-interactive turn and
102
+ exit, while a lead owns the session the user talks to. Empty `prompt_flag`
103
+ means the CLI takes the prompt positionally; empty `session_id_flag` means it
104
+ has no resumable session id and resumes from artifacts instead.
105
+ """
106
+
107
+ executable: str
108
+ model_flag: str
109
+ prompt_flag: str = ""
110
+ session_id_flag: str = ""
111
+ # Flags that lift the CLI's own sandbox, and what the user is agreeing to by
112
+ # accepting them. Empty when the CLI does not sandbox itself by default.
113
+ sandbox_waiver: tuple[str, ...] = ()
114
+ sandbox_waiver_note: str = ""
115
+
116
+
97
117
  @dataclass(frozen=True)
98
118
  class ProviderSpec:
99
119
  provider: str
@@ -102,6 +122,7 @@ class ProviderSpec:
102
122
  default_models: Mapping[str, str]
103
123
  wrapper: str
104
124
  supported_roles: frozenset[str]
125
+ lead_launch: Optional[LeadLaunchSpec] = None
105
126
 
106
127
 
107
128
  PROVIDERS = {
@@ -121,6 +142,11 @@ PROVIDERS = {
121
142
  supported_roles=frozenset(
122
143
  {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
123
144
  ),
145
+ lead_launch=LeadLaunchSpec(
146
+ executable="claude",
147
+ model_flag="--model",
148
+ session_id_flag="--session-id",
149
+ ),
124
150
  ),
125
151
  "antigravity": ProviderSpec(
126
152
  provider="antigravity",
@@ -133,6 +159,13 @@ PROVIDERS = {
133
159
  supported_roles=frozenset(
134
160
  {"lead", "analyser", "critic", "executor", "verifier"}
135
161
  ),
162
+ lead_launch=LeadLaunchSpec(
163
+ executable="agy",
164
+ model_flag="--model",
165
+ # Without this the prompt runs once and the session ends; okstra
166
+ # needs the lead to stay and drive the remaining phases.
167
+ prompt_flag="--prompt-interactive",
168
+ ),
136
169
  ),
137
170
  "codex": ProviderSpec(
138
171
  provider="codex",
@@ -145,6 +178,20 @@ PROVIDERS = {
145
178
  supported_roles=frozenset(
146
179
  {"lead", "analyser", "critic", "executor", "verifier", "report-writer"}
147
180
  ),
181
+ lead_launch=LeadLaunchSpec(
182
+ executable="codex",
183
+ model_flag="-m",
184
+ # A codex session sandboxes itself, and everything it spawns inherits
185
+ # that — okstra's dispatch and every worker CLI with it. Measured:
186
+ # the cmux socket returns EPERM and each worker dies unable to write
187
+ # its own config. A sandboxed lead cannot run okstra at all.
188
+ sandbox_waiver=("-s", "danger-full-access"),
189
+ sandbox_waiver_note=(
190
+ "codex will start without its filesystem and network sandbox, "
191
+ "which okstra needs so the lead can reach cmux and start worker "
192
+ "CLIs."
193
+ ),
194
+ ),
148
195
  ),
149
196
  "grok": ProviderSpec(
150
197
  provider="grok",
@@ -190,6 +237,46 @@ class UnknownProviderError(ValueError):
190
237
  """Raised when a requested provider is absent from the registry."""
191
238
 
192
239
 
240
+ def lead_launch_spec(provider: str) -> LeadLaunchSpec:
241
+ """The launch spec for a provider that can lead, or an error naming why not."""
242
+ spec = provider_spec(provider)
243
+ if spec.lead_launch is None:
244
+ raise UnknownProviderError(
245
+ f"provider {provider!r} cannot act as a lead: no launch spec"
246
+ )
247
+ return spec.lead_launch
248
+
249
+
250
+ def lead_launch_argv(
251
+ provider: str,
252
+ *,
253
+ model: str,
254
+ prompt: str,
255
+ session_id: str = "",
256
+ waive_sandbox: bool = True,
257
+ ) -> list[str]:
258
+ """The argv that starts this provider's CLI as the lead for one run.
259
+
260
+ The sandbox waiver is applied by default because a sandboxed lead cannot run
261
+ okstra at all — it reaches neither cmux nor the worker CLIs' own config. The
262
+ caller still owns telling the user and collecting their answer; passing
263
+ False produces the un-waived argv so a declined confirmation can show what
264
+ would otherwise have run.
265
+ """
266
+ launch = lead_launch_spec(provider)
267
+ argv = [launch.executable]
268
+ if waive_sandbox:
269
+ argv.extend(launch.sandbox_waiver)
270
+ if model:
271
+ argv.extend([launch.model_flag, model])
272
+ if session_id and launch.session_id_flag:
273
+ argv.extend([launch.session_id_flag, session_id])
274
+ if launch.prompt_flag:
275
+ argv.append(launch.prompt_flag)
276
+ argv.append(prompt)
277
+ return argv
278
+
279
+
193
280
  def provider_spec(provider: str) -> ProviderSpec:
194
281
  """Return one registered provider or fail before assignment/dispatch."""
195
282
  normalized = (provider or "").strip().lower()
@@ -31,7 +31,8 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
31
31
  from . import fix_cycles
32
32
  from .analysis_inputs import ANALYSIS_TASK_TYPES
33
33
  from .paths import okstra_home
34
- from .lead_runtime import lead_runtime_info
34
+ from .dispatch_state import BACKEND_CMUX_PANE
35
+ from .lead_runtime import lead_runtime_info, with_cmux_dispatch
35
36
  from .models import UnknownProviderError, provider_ids, provider_spec
36
37
  from .runner_resolution import native_provider_for_host
37
38
  from .path_hints import compact_active_run_context, hydrate_run_context
@@ -80,7 +81,10 @@ def _lead_runtime(ctx: dict) -> str:
80
81
 
81
82
 
82
83
  def _lead_info(ctx: dict):
83
- return lead_runtime_info(_lead_runtime(ctx))
84
+ info = lead_runtime_info(_lead_runtime(ctx))
85
+ if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
86
+ return with_cmux_dispatch(info)
87
+ return info
84
88
 
85
89
 
86
90
  def _lead_agent(ctx: dict) -> str:
@@ -1444,6 +1448,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1444
1448
  "hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
1445
1449
  "leadRuntime": _lead_runtime(ctx),
1446
1450
  "leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
1451
+ "terminalBackend": ctx.get("TERMINAL_BACKEND", ""),
1447
1452
  "runtimeResolution": _runtime_resolution(ctx),
1448
1453
  "leadAssignment": _lead_assignment(ctx),
1449
1454
  "workerAssignments": _worker_assignments(ctx),
@@ -46,6 +46,7 @@ from .clarification_items import (
46
46
  scan_approval_gate,
47
47
  )
48
48
  from .error_report import prior_run_error_digest
49
+ from .incremental_scope import ReverifyScopeError, parse_user_reverify_scope
49
50
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
50
51
  from .material import (
51
52
  build_analysis_material,
@@ -65,6 +66,8 @@ from .model_discovery import normalize_execution_for_dispatch
65
66
  from .models import (
66
67
  ModelAssignment,
67
68
  default_model,
69
+ lead_launch_argv,
70
+ lead_launch_spec,
68
71
  provider_default_model,
69
72
  provider_ids,
70
73
  provider_spec,
@@ -96,6 +99,7 @@ from .render import (
96
99
  )
97
100
  from okstra_project.dirs import okstra_home
98
101
 
102
+ from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
99
103
  from .run_context import (
100
104
  compute_and_write_run_context,
101
105
  refresh_run_context_snapshot,
@@ -372,6 +376,10 @@ class PrepareInputs:
372
376
  # 별개 채널이다.
373
377
  stages: str = ""
374
378
  clarification_response_path: str = "" # absolute or empty
379
+ # implementation-planning 전용: 사용자가 고른 이번 재실행의 재검증 범위.
380
+ # "" / "auto" = 리드의 `okstra incremental-scope` 판정에 맡김, "full" =
381
+ # 전체 재검증 강제, "<stage csv>" = 그 stage 들을 impacted 로 지정.
382
+ reverify_scope: str = ""
375
383
  # release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
376
384
  # project.json → global config → 스킬 디폴트 순으로 해석된다.
377
385
  pr_template_path: str = ""
@@ -1015,9 +1023,35 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1015
1023
  raise PrepareError(
1016
1024
  f"clarification response file not found: {inp.clarification_response_path}"
1017
1025
  )
1026
+ _validate_reverify_scope(inp)
1018
1027
  return ctx_stage_map
1019
1028
 
1020
1029
 
1030
+ def _validate_reverify_scope(inp: PrepareInputs) -> None:
1031
+ """A pinned re-verification scope is only actionable on a planning re-run.
1032
+
1033
+ Every other phase renders the tokens too (the template always reads them),
1034
+ but nothing consumes them there — so a value outside the one phase that
1035
+ acts on it is a caller mistake, not a preference to honour silently.
1036
+ """
1037
+ if not (inp.reverify_scope or "").strip():
1038
+ return
1039
+ if inp.task_type != "implementation-planning":
1040
+ raise PrepareError(
1041
+ "--reverify-scope is only meaningful with --task-type "
1042
+ f"implementation-planning; got {inp.task_type}"
1043
+ )
1044
+ if not inp.clarification_response_path:
1045
+ raise PrepareError(
1046
+ "--reverify-scope needs --clarification-response: there is no prior "
1047
+ "report to narrow re-verification against"
1048
+ )
1049
+ try:
1050
+ parse_user_reverify_scope(inp.reverify_scope)
1051
+ except ReverifyScopeError as exc:
1052
+ raise PrepareError(str(exc)) from exc
1053
+
1054
+
1021
1055
  def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1022
1056
  """Apply approved-plan inputs only after canonical brief preflight succeeds."""
1023
1057
  if inp.approve_plan_ack or inp.implementation_option:
@@ -2275,6 +2309,21 @@ def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
2275
2309
  }
2276
2310
 
2277
2311
 
2312
+ def _reverify_scope_ctx(raw: str) -> dict[str, str]:
2313
+ """Render tokens for the re-verification scope the user pinned.
2314
+
2315
+ Always emitted: the lead prompt reads both tokens unconditionally, and an
2316
+ absent one is a render failure rather than a silent `auto`.
2317
+ """
2318
+ scope = parse_user_reverify_scope(raw)
2319
+ return {
2320
+ "REVERIFY_SCOPE_MODE": scope.mode,
2321
+ "REVERIFY_SCOPE_STAGES": (
2322
+ ",".join(str(num) for num in scope.stages) or "(none)"
2323
+ ),
2324
+ }
2325
+
2326
+
2278
2327
  def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
2279
2328
  """Render tokens for every model binding this run resolved."""
2280
2329
  return {
@@ -2394,7 +2443,17 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2394
2443
  json.loads(runtime_resolution_json or "{}")
2395
2444
  except json.JSONDecodeError as exc:
2396
2445
  raise PrepareError(f"invalid --runtime-resolution-json: {exc}") from exc
2397
- if lead_runtime != "claude-code" and not inp.render_only:
2446
+ # Probed once here and reused below, so the gate and the manifest cannot
2447
+ # disagree about which backend this run is on.
2448
+ terminal_backend = detect_terminal_backend()
2449
+ # Outside cmux only claude-code has a dispatch backend of its own. Under
2450
+ # cmux okstra owns the panes for every lead, so the render-only restriction
2451
+ # no longer applies to any runtime.
2452
+ if (
2453
+ lead_runtime != "claude-code"
2454
+ and terminal_backend != BACKEND_CMUX_PANE
2455
+ and not inp.render_only
2456
+ ):
2398
2457
  raise PrepareError(
2399
2458
  f"lead runtime `{lead_runtime}` is currently render-only; "
2400
2459
  "use --render-only until a dispatch backend is enabled for this lead runtime."
@@ -2495,6 +2554,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2495
2554
  )
2496
2555
 
2497
2556
  ctx.update({
2557
+ "TERMINAL_BACKEND": terminal_backend,
2498
2558
  "EXECUTOR_WORKTREE_PATH": worktree.path,
2499
2559
  "EXECUTOR_WORKTREE_BRANCH": worktree.branch,
2500
2560
  "EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
@@ -2565,6 +2625,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2565
2625
  "CLAUDE_SESSION_ID": claude_session_id,
2566
2626
  "CLARIFICATION_RESPONSE_PATH": inp.clarification_response_path,
2567
2627
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
2628
+ **_reverify_scope_ctx(inp.reverify_scope),
2568
2629
  "BRIEF_FILE_PATH": str(inp.brief_path),
2569
2630
  "BRIEF_RELATIVE_PATH": brief_relative,
2570
2631
  **_model_ctx(models),
@@ -2749,6 +2810,17 @@ def main(argv: list[str]) -> int:
2749
2810
  ),
2750
2811
  )
2751
2812
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
2813
+ p.add_argument(
2814
+ "--reverify-scope",
2815
+ default="",
2816
+ dest="reverify_scope",
2817
+ help=(
2818
+ "implementation-planning 재실행 전용. 사용자가 고른 재검증 범위. "
2819
+ "'' / 'auto' = 리드의 incremental-scope 판정에 맡김(기본), "
2820
+ "'full' = 전체 재검증 강제, '<stage csv>' (예: '2,3') = 그 stage 를 "
2821
+ "impacted 로 지정."
2822
+ ),
2823
+ )
2752
2824
  p.add_argument(
2753
2825
  "--pr-template-path",
2754
2826
  default="",
@@ -2858,6 +2930,7 @@ def main(argv: list[str]) -> int:
2858
2930
  stage=args.stage,
2859
2931
  stages=args.stages,
2860
2932
  clarification_response_path=clarification_abs,
2933
+ reverify_scope=args.reverify_scope,
2861
2934
  pr_template_path=args.pr_template_path,
2862
2935
  render_only=args.render_only,
2863
2936
  approve_plan_ack=args.approve_plan_ack,
@@ -2891,19 +2964,38 @@ def main(argv: list[str]) -> int:
2891
2964
  else:
2892
2965
  print(f"okstra current run dir: {ctx['RUN_DIR']}")
2893
2966
  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
2969
+ launch = lead_launch_spec(lead_provider)
2894
2970
  print(f"lead model: {ctx['LEAD_MODEL']}")
2895
2971
  print(f"claude session id: {ctx['CLAUDE_SESSION_ID']}")
2896
2972
  print(f"resume command file: {ctx['CLAUDE_RESUME_COMMAND_PATH']}")
2897
- print("launch mode: interactive Claude handoff")
2898
- print(f"claude working directory: {ctx['PROJECT_ROOT']}")
2973
+ print(f"launch mode: interactive {launch.executable} handoff")
2974
+ print(f"lead working directory: {ctx['PROJECT_ROOT']}")
2899
2975
  print()
2900
- # In non-render-only mode emit a small JSON the bash wrapper can parse
2901
- # to build the `claude` exec command. Wrapper exec's; we don't.
2976
+ # In non-render-only mode emit the JSON a front end needs to exec the
2977
+ # lead. The argv is assembled here rather than in the caller so provider
2978
+ # launch knowledge stays in the catalog and every front end — the bash
2979
+ # wrapper and the Node CLI — starts the lead identically.
2980
+ prompt_file = Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"
2902
2981
  machine = {
2903
- "claudeSessionId": ctx["CLAUDE_SESSION_ID"],
2982
+ "leadRuntime": lead_runtime_name,
2983
+ "leadProvider": lead_provider,
2984
+ "leadExecutable": launch.executable,
2985
+ "leadSessionId": ctx["CLAUDE_SESSION_ID"],
2904
2986
  "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
2905
2987
  "projectRoot": ctx["PROJECT_ROOT"],
2906
- "promptFile": str(Path(ctx["INSTRUCTION_SET_PATH"]) / "lead-execution-prompt.md"),
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
+ ),
2907
2999
  }
2908
3000
  print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
2909
3001
  return 0