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.
@@ -1,4 +1,9 @@
1
- """Neutral okstra team CLI for external tmux-pane worker dispatch."""
1
+ """Neutral okstra team CLI for pane-backed worker dispatch.
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.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
9
  import argparse
@@ -7,8 +12,10 @@ import sys
7
12
  from pathlib import Path
8
13
  from typing import Any, Mapping, Sequence
9
14
 
15
+ from . import cmux
10
16
  from . import tmux
11
17
  from .dispatch_core import (
18
+ BACKEND_CMUX_PANE,
12
19
  BACKEND_TMUX_PANE,
13
20
  DispatchError,
14
21
  DispatchPlan,
@@ -51,7 +58,7 @@ def _parser() -> argparse.ArgumentParser:
51
58
 
52
59
 
53
60
  def _add_dispatch_parser(sub) -> None:
54
- parser = sub.add_parser("dispatch", help="dispatch tmux-pane workers")
61
+ parser = sub.add_parser("dispatch", help="dispatch pane-backed workers")
55
62
  _add_run_args(parser)
56
63
  parser.add_argument("--workers", default="")
57
64
  parser.add_argument("--jobs-file", default="")
@@ -61,7 +68,7 @@ def _add_dispatch_parser(sub) -> None:
61
68
 
62
69
 
63
70
  def _add_await_parser(sub) -> None:
64
- parser = sub.add_parser("await", help="wait for tmux-pane workers")
71
+ parser = sub.add_parser("await", help="wait for pane-backed workers")
65
72
  _add_run_args(parser)
66
73
  parser.add_argument("--poll-interval-seconds", type=int, default=5)
67
74
  parser.add_argument("--timeout-seconds", type=int, default=None)
@@ -70,7 +77,7 @@ def _add_await_parser(sub) -> None:
70
77
 
71
78
 
72
79
  def _add_teardown_parser(sub) -> None:
73
- parser = sub.add_parser("teardown", help="kill tmux-pane workers")
80
+ parser = sub.add_parser("teardown", help="reclaim this run's worker panes")
74
81
  _add_run_args(parser)
75
82
  parser.add_argument("--dry-run", action="store_true")
76
83
  parser.add_argument("--json", action="store_true")
@@ -94,8 +101,8 @@ def _dispatch(args) -> int:
94
101
  okstra_bin=Path(args.okstra_bin),
95
102
  requested_workers=requested,
96
103
  idle_timeout_seconds=args.idle_timeout_seconds,
97
- required_lead_runtime="external",
98
- default_backend=BACKEND_TMUX_PANE,
104
+ required_lead_runtime=None if _is_cmux_run(manifest) else "external",
105
+ default_backend=_manifest_backend(manifest),
99
106
  supported_worker_wrappers=_SUPPORTED_WRAPPERS,
100
107
  unsupported_worker_label="external lead",
101
108
  dispatch_kind=args.dispatch_kind,
@@ -133,13 +140,13 @@ def _teardown(args) -> int:
133
140
  team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
134
141
  team_state = _load_json(team_state_path, "team-state")
135
142
  run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
136
- lead_pane = tmux.resolve_caller_pane()
137
- panes = _teardown_panes(team_state, run_dir, lead_pane)
143
+ panes = _reclaimable_panes(manifest, team_state, run_dir)
138
144
  if args.dry_run:
139
145
  _emit_teardown(args.json, panes)
140
146
  return 0
147
+ reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
141
148
  for pane in panes:
142
- tmux.kill_pane(pane["paneId"])
149
+ reclaim(pane["paneId"])
143
150
  _mark_teardown_errors(team_state_path)
144
151
  _emit_teardown(args.json, panes)
145
152
  return 0
@@ -158,7 +165,7 @@ def _plan_for_existing(
158
165
  lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
159
166
  manifest=manifest,
160
167
  jobs=(),
161
- default_backend=BACKEND_TMUX_PANE,
168
+ default_backend=_manifest_backend(manifest),
162
169
  )
163
170
 
164
171
 
@@ -174,12 +181,25 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
174
181
  }
175
182
 
176
183
 
177
- def _teardown_panes(team_state: Mapping[str, Any], run_dir: Path, lead_pane: str) -> list[dict[str, str]]:
184
+ def _reclaimable_panes(
185
+ manifest: Mapping[str, Any], team_state: Mapping[str, Any], run_dir: Path
186
+ ) -> list[dict[str, str]]:
187
+ """Everything this run owns and may close.
188
+
189
+ Under cmux the recorded ids are the whole list. There is no per-pane tag API
190
+ to sweep with, and scanning by title would be worse than nothing: cmux labels
191
+ its own agent surfaces with the same glyph okstra's tmux cleanup treats as a
192
+ teammate marker, so a sweep could close the lead. Only surfaces okstra
193
+ created are recorded, so only those can be closed.
194
+ """
178
195
  seen: set[str] = set()
179
196
  panes: list[dict[str, str]] = []
180
197
  for record in team_state.get("workerDispatches", []):
181
198
  if isinstance(record, dict):
182
199
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
200
+ if _is_cmux_run(manifest):
201
+ return panes
202
+ lead_pane = tmux.resolve_caller_pane()
183
203
  for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
184
204
  _append_pane(panes, seen, pane.pane_id, pane.kind)
185
205
  return panes
@@ -208,10 +228,29 @@ def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
208
228
  print(f"{pane['paneId']}\t{pane['kind']}")
209
229
 
210
230
 
231
+ def _manifest_backend(manifest: Mapping[str, Any]) -> str:
232
+ """The backend prepare recorded for this run.
233
+
234
+ Deliberately not a flag on this command: the manifest already answers it,
235
+ and a flag would be a second answer free to disagree. A manifest written
236
+ before the field existed reads as tmux, which is what those runs used.
237
+ """
238
+ return str(manifest.get("terminalBackend") or "") or BACKEND_TMUX_PANE
239
+
240
+
241
+ def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
242
+ return _manifest_backend(manifest) == BACKEND_CMUX_PANE
243
+
244
+
211
245
  def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
212
246
  runtime = manifest.get("leadRuntime")
213
247
  if runtime == "external":
214
248
  return
249
+ 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
+ return
215
254
  if runtime == "codex":
216
255
  raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
217
256
  if runtime == "claude-code":
@@ -56,7 +56,10 @@ from okstra_ctl.clarification_items import (
56
56
  sidecar_answers,
57
57
  user_response_sidecars,
58
58
  )
59
- from okstra_ctl.incremental_scope import preview_link_availability_for_report
59
+ from okstra_ctl.incremental_scope import (
60
+ parse_stage_graph,
61
+ preview_link_availability_for_report,
62
+ )
60
63
  from okstra_ctl.design_prep import (
61
64
  DesignPrepError,
62
65
  load_design_prep_items,
@@ -342,6 +345,8 @@ S_RELATED_TASKS_PICK = "related_tasks_pick"
342
345
  S_RELATED_TASKS = "related_tasks"
343
346
  S_CLARIFICATION_PICK = "clarification_pick"
344
347
  S_CLARIFICATION = "clarification"
348
+ S_REVERIFY_SCOPE_PICK = "reverify_scope_pick"
349
+ S_REVERIFY_SCOPE_STAGES = "reverify_scope_stages"
345
350
  S_PR_TEMPLATE_PICK = "pr_template_pick"
346
351
  S_PR_TEMPLATE = "pr_template"
347
352
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
@@ -473,6 +478,10 @@ class WizardState:
473
478
  clarification_response_path: str = ""
474
479
  clarification_pending_text: bool = False
475
480
  last_final_report_cached: str = ""
481
+ # "" | "auto" | "full" | "<stage csv>" — 사용자가 고른 이번 재실행의 재검증
482
+ # 범위. implementation-planning 재실행에서 좁힐 여지가 있을 때만 채워진다.
483
+ reverify_scope: str = ""
484
+ reverify_scope_pending_text: bool = False
476
485
  pr_template_path: str = ""
477
486
  pr_template_pending_text: bool = False
478
487
  pr_template_scope: str = "" # "once" | "project" | "global"
@@ -3002,6 +3011,128 @@ def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
3002
3011
  return _submit_optional_cached_pick(state, value, _CLARIFICATION_PICK_SPEC)
3003
3012
 
3004
3013
 
3014
+ def _carried_planning_report(state: WizardState) -> Optional[Path]:
3015
+ """이번 재실행이 이어받는 직전 implementation-planning 리포트 (없으면 None)."""
3016
+ if state.task_type != "implementation-planning":
3017
+ return None
3018
+ if not state.clarification_response_path or not state.project_root:
3019
+ return None
3020
+ report = _resolve_path(
3021
+ state.clarification_response_path, Path(state.project_root)
3022
+ )
3023
+ return report if report.is_file() else None
3024
+
3025
+
3026
+ def _reverify_scope_preview(state: WizardState) -> Optional[dict]:
3027
+ """답변된 id 가 직전 리포트의 stage 로 되짚어지는지 — 범위 판정의 앞 절반."""
3028
+ report = _carried_planning_report(state)
3029
+ if report is None:
3030
+ return None
3031
+ return preview_link_availability_for_report(
3032
+ report, set(sidecar_answers(report))
3033
+ )
3034
+
3035
+
3036
+ def _reverify_scope_pick_required(state: WizardState) -> bool:
3037
+ """좁힐 여지가 있는 재실행에서만 범위를 묻는다.
3038
+
3039
+ 링크가 끊겨 full 이 이미 확정된 재실행에서 물으면 어떤 답도 판정을 바꾸지
3040
+ 못한다 — 고를 수 없는 선택지를 내미는 화면이 하나 느는 것뿐이다. 그 경우
3041
+ 확인 블록의 `reverify-scope: full 예상` 줄이 이유까지 같이 알려준다.
3042
+ """
3043
+ preview = _reverify_scope_preview(state)
3044
+ return preview is not None and not preview["wouldForceFull"]
3045
+
3046
+
3047
+ def _reverify_scope_step_pending(state: WizardState) -> bool:
3048
+ """범위 질문이 아직 안 끝났는가 — confirm 진입을 막는 게이트."""
3049
+ if state.reverify_scope_pending_text:
3050
+ return True
3051
+ return _reverify_scope_pick_required(state) and not state.reverify_scope
3052
+
3053
+
3054
+ def _prior_stage_numbers(state: WizardState) -> set[int]:
3055
+ """직전 리포트 Stage Map 의 stage 번호. 읽을 수 없으면 WizardError."""
3056
+ t = _p(state.workspace_root, "reverify_scope_stages")
3057
+ report = _carried_planning_report(state)
3058
+ if report is None:
3059
+ raise WizardError(
3060
+ t["errors"]["no_stage_map"].format(reason="carried report not found")
3061
+ )
3062
+ data_path = final_report_data_path(report)
3063
+ try:
3064
+ data = json.loads(data_path.read_text(encoding="utf-8"))
3065
+ stages = {num for num, _ in parse_stage_graph(data)}
3066
+ except (OSError, ValueError, KeyError, TypeError) as exc:
3067
+ raise WizardError(
3068
+ t["errors"]["no_stage_map"].format(reason=str(exc))
3069
+ ) from exc
3070
+ if not stages:
3071
+ raise WizardError(
3072
+ t["errors"]["no_stage_map"].format(reason="stage map is empty")
3073
+ )
3074
+ return stages
3075
+
3076
+
3077
+ def _build_reverify_scope_pick(state: WizardState) -> Prompt:
3078
+ t = _p(state.workspace_root, "reverify_scope_pick")
3079
+ opts = t["options"]
3080
+ return Prompt(
3081
+ step=S_REVERIFY_SCOPE_PICK, kind="pick", label=t["label"],
3082
+ options=[
3083
+ _opt("auto", opts["auto"]),
3084
+ _opt("full", opts["full"]),
3085
+ _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
3086
+ ],
3087
+ echo_template=t["echo_template"])
3088
+
3089
+
3090
+ def _submit_reverify_scope_pick(state: WizardState, value: str) -> Optional[str]:
3091
+ t = _p(state.workspace_root, "reverify_scope_pick")
3092
+ picked = value.strip().lower()
3093
+ if picked == PICK_TYPE_CUSTOM:
3094
+ state.reverify_scope = ""
3095
+ state.reverify_scope_pending_text = True
3096
+ return "reverify-scope: 직접 입력"
3097
+ if picked not in ("auto", "full"):
3098
+ raise WizardError(
3099
+ f"expected 'auto' / 'full' / {PICK_TYPE_CUSTOM!r}, got: {value!r}"
3100
+ )
3101
+ state.reverify_scope = picked
3102
+ state.reverify_scope_pending_text = False
3103
+ return t["echo_suffixes"][picked]
3104
+
3105
+
3106
+ def _build_reverify_scope_stages(state: WizardState) -> Prompt:
3107
+ t = _p(state.workspace_root, "reverify_scope_stages")
3108
+ return Prompt(
3109
+ step=S_REVERIFY_SCOPE_STAGES, kind="text", label=t["label"],
3110
+ echo_template=t["echo_template"])
3111
+
3112
+
3113
+ def _submit_reverify_scope_stages(state: WizardState, value: str) -> Optional[str]:
3114
+ t = _p(state.workspace_root, "reverify_scope_stages")
3115
+ tokens = [token.strip() for token in value.split(",") if token.strip()]
3116
+ if not tokens:
3117
+ state.reverify_scope = "auto"
3118
+ state.reverify_scope_pending_text = False
3119
+ return t["echo_suffixes"]["auto"]
3120
+ for token in tokens:
3121
+ if not token.isdigit():
3122
+ raise WizardError(t["errors"]["not_a_number"].format(token=token))
3123
+ known = _prior_stage_numbers(state)
3124
+ picked = sorted({int(token) for token in tokens})
3125
+ unknown = [num for num in picked if num not in known]
3126
+ if unknown:
3127
+ raise WizardError(t["errors"]["unknown_stage"].format(
3128
+ stages=", ".join(str(num) for num in unknown),
3129
+ known=", ".join(str(num) for num in sorted(known)),
3130
+ ))
3131
+ state.reverify_scope = ",".join(str(num) for num in picked)
3132
+ state.reverify_scope_pending_text = False
3133
+ return t["echo_template"].format(value=state.reverify_scope)
3134
+
3135
+
3005
3136
  def _suggest_project_pr_template(state: WizardState) -> str:
3006
3137
  """project.json 의 prTemplatePath 필드를 읽어 경로 문자열로 반환.
3007
3138
 
@@ -4232,14 +4363,32 @@ STEPS: list[Step] = [
4232
4363
  and S_PR_TEMPLATE_SCOPE not in s.answered),
4233
4364
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
4234
4365
  owns=("pr_template_scope",)),
4366
+ # 재검증 범위는 clarification-response 가 정해진 뒤에야 판정할 수 있고,
4367
+ # 확인 블록은 full 재검증 비용을 물기 전 마지막 되돌림 지점이다. 그래서
4368
+ # 이 질문은 confirm 바로 앞에 선다.
4369
+ Step(S_REVERIFY_SCOPE_PICK,
4370
+ applies=lambda s: (_ready_for_confirm(s)
4371
+ and not s.reverify_scope_pending_text
4372
+ and not s.reverify_scope
4373
+ and _reverify_scope_pick_required(s)),
4374
+ build=_build_reverify_scope_pick, submit=_submit_reverify_scope_pick,
4375
+ owns=("reverify_scope", "reverify_scope_pending_text")),
4376
+ Step(S_REVERIFY_SCOPE_STAGES,
4377
+ applies=lambda s: (s.reverify_scope_pending_text
4378
+ and S_REVERIFY_SCOPE_STAGES not in s.answered),
4379
+ build=_build_reverify_scope_stages,
4380
+ submit=_submit_reverify_scope_stages,
4381
+ owns=("reverify_scope", "reverify_scope_pending_text")),
4235
4382
  Step(S_FIX_CYCLE_CONFIRM,
4236
4383
  applies=lambda s: (_ready_for_confirm(s)
4384
+ and not _reverify_scope_step_pending(s)
4237
4385
  and _fix_cycle_confirm_required(s)
4238
4386
  and not s.fix_cycle),
4239
4387
  build=_build_fix_cycle_confirm, submit=_submit_fix_cycle_confirm,
4240
4388
  owns=("fix_cycle",)),
4241
4389
  Step(S_CONFIRM,
4242
4390
  applies=lambda s: (_ready_for_confirm(s)
4391
+ and not _reverify_scope_step_pending(s)
4243
4392
  and (not _fix_cycle_confirm_required(s)
4244
4393
  or bool(s.fix_cycle))
4245
4394
  and s.confirmed is None),
@@ -4357,6 +4506,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
4357
4506
  "directive_pending_text": False,
4358
4507
  "related_tasks_raw": "", "related_tasks_pending_text": False,
4359
4508
  "clarification_response_path": "", "clarification_pending_text": False,
4509
+ "reverify_scope": "", "reverify_scope_pending_text": False,
4360
4510
  "pr_template_path": "", "pr_template_pending_text": False,
4361
4511
  "pr_template_scope": "",
4362
4512
  "fix_cycle": "",
@@ -4650,31 +4800,34 @@ def render_args(state: WizardState) -> dict[str, str]:
4650
4800
  "report-writer-model": state.report_writer_model,
4651
4801
  "related-tasks": state.related_tasks_raw,
4652
4802
  "clarification-response": state.clarification_response_path,
4803
+ "reverify-scope": (
4804
+ state.reverify_scope
4805
+ if state.task_type == "implementation-planning" else ""
4806
+ ),
4653
4807
  "pr-template-path": pr_template,
4654
4808
  "fix-cycle": state.fix_cycle,
4655
4809
  }
4656
4810
 
4657
4811
 
4658
4812
  def _reverify_scope_line(state: WizardState) -> Optional[str]:
4659
- """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 미리 보여주는 줄.
4660
-
4661
- 이 판정의 절반(답변된 id 가 stage 로 되짚어지는지)은 base SHA 없이 결정되고
4662
- 직전 리포트만 있으면 이미 정해져 있다. 그런데 지금까지는 `okstra recap
4663
- assemble` 을 따로 돌려야만 보였고, run 이 시작된 뒤에 full 로 밝혀지면 두
4664
- 시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는 마지막 지점이다.
4813
+ """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 보여주는 줄.
4814
+
4815
+ 사용자가 범위를 직접 골랐으면 그 선택을 찍는다. 고르지 않았으면(또는 좁힐
4816
+ 수 없어 질문 자체가 안 뜬 경우) 예측을 찍는다. 예측의 절반(답변된 id 가
4817
+ stage 로 되짚어지는지)은 base SHA 없이 직전 리포트만으로 이미 정해져 있는데,
4818
+ 지금까지는 `okstra recap assemble` 을 따로 돌려야만 보였고 run 이 시작된 뒤
4819
+ full 로 밝혀지면 두 시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는
4820
+ 마지막 지점이다.
4665
4821
  """
4666
- if state.task_type != "implementation-planning":
4667
- return None
4668
- if not state.clarification_response_path or not state.project_root:
4822
+ preview = _reverify_scope_preview(state)
4823
+ if preview is None:
4669
4824
  return None
4670
- report = _resolve_path(
4671
- state.clarification_response_path, Path(state.project_root)
4672
- )
4673
- if not report.is_file():
4674
- return None
4675
- preview = preview_link_availability_for_report(
4676
- report, set(sidecar_answers(report))
4677
- )
4825
+ if state.reverify_scope == "full":
4826
+ return _msg(state.workspace_root, "confirmation",
4827
+ "reverify_scope_user_full")
4828
+ if state.reverify_scope and state.reverify_scope != "auto":
4829
+ return _msg(state.workspace_root, "confirmation",
4830
+ "reverify_scope_user_stages", stages=state.reverify_scope)
4678
4831
  if not preview["wouldForceFull"]:
4679
4832
  return _msg(state.workspace_root, "confirmation",
4680
4833
  "reverify_scope_incremental")
@@ -142,6 +142,7 @@ That is the entire interactive flow. The wizard handles:
142
142
  - `release-handoff`-only sub-flow: after the approved plan auto-resolves, a `handoff_stage_pick` multi-select — choose an eligible stage bundle (stage-group) or the whole task (when an accepted whole-task verification report exists); the result goes out as render-args' `stages` key (csv, empty when whole-task),
143
143
  - `Use defaults / Customize` branch with profile-aware worker/model questions,
144
144
  - **resume-clarification (in-session equivalent)** — there is no separate mode or flag matching the shell's `okstra.sh --resume-clarification`; two steps of the standard flow carry out its substance. (1) `reuse_previous` (yes/no to reuse the previous run's settings — in `requirements-discovery` / `error-analysis` / `implementation-planning`, only when prior run-inputs exist): YES prefills workers·model·directive·related-tasks at once. (2) `clarification_pick`: if the **task-type's own** previous `final-report` exists it is auto-recommended as the carry-in input (falling back to the newest by mtime across all phases when absent), and the same run's `user-responses/` sidecar (answers the user filled in) is attached alongside. The chosen path is passed to prepare as `--clarification-response` — the user makes the sidecar via the report's `Export user response`, places it in `runs/<task-type>/user-responses/`, and re-runs the same phase,
145
+ - **re-verification scope (`reverify_scope_pick`, `implementation-planning` clarification re-runs only)** — asked right before `confirm`, and **only when the re-run is narrowable** (every answered `C-NNN` traces back to a stage in the prior report). 3 options: `auto` (recommended — leave it to the lead's `okstra incremental-scope` decision) / `full` (re-verify every stage) / Enter directly (a stage-number CSV, validated against the prior report's Stage Map). The answer goes out as `--reverify-scope` and reaches the lead prompt as the `REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` tokens; it shapes that CLI's inputs rather than replacing the decision. When the re-run is not narrowable the step does not appear — full is already fixed, and the confirmation block's `reverify-scope` line says which answered id broke the link,
145
146
  - `release-handoff` PR template override + persist scope,
146
147
  - final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
147
148
 
@@ -39,6 +39,12 @@ SECONDARY_BRIEF_FILENAME="validation-brief-secondary.md"
39
39
  export OKSTRA_SKIP_INSTALL_CHECK="${OKSTRA_SKIP_INSTALL_CHECK:-1}"
40
40
  export OKSTRA_CTL_SKIP_RECONCILE="${OKSTRA_CTL_SKIP_RECONCILE:-1}"
41
41
  export OKSTRA_CTL_SKIP_BACKFILL="${OKSTRA_CTL_SKIP_BACKFILL:-1}"
42
+ # Same reason as the three above: the synthetic run must render the same way on
43
+ # every machine. cmux outranks tmux when present, so a maintainer running this
44
+ # from inside cmux would otherwise get the cmux adapter and a lead-session
45
+ # requirement this fixture never simulates. The cmux path has its own coverage
46
+ # in tests/run/test_cmux*.py and tests/contract/test_validate_session_conformance.py.
47
+ export CMUX_WORKSPACE_ID=""
42
48
 
43
49
  # shellcheck source=lib/common.sh
44
50
  source "$SCRIPT_DIR/lib/common.sh"
@@ -112,9 +112,19 @@ _ENTRY_GUARD_READS = (
112
112
  ),
113
113
  )
114
114
 
115
+ # 환경으로 선택되는 어댑터라 lead 의 런타임이 이 파일을 가르쳐 주지 않는다.
116
+ # 읽지 않은 lead 는 자기 런타임이 아는 방식 — cmux 경로에서는 okstra 가 소유한
117
+ # 디스패치를 host 네이티브로 가로채는 방식 — 으로 되돌아간다.
118
+ CMUX_ADAPTER_BASENAME = "cmux.md"
119
+ CMUX_ADAPTER_NAME = "cmux"
120
+ _CMUX_ADAPTER_CITE = "prompts/lead/adapters/cmux.md"
121
+
115
122
  # Read 증거는 basename 으로 거른다 — 절대 경로는 레이어(repo / runtime / 설치본)
116
123
  # 마다 다르지만 basename 은 동일하다. 목록이 갈리지 않도록 기대치에서 파생한다.
117
- _ENTRY_GUARD_BASENAMES = tuple(row.basename for row in _ENTRY_GUARD_READS)
124
+ _TRACKED_READ_BASENAMES = (
125
+ *(row.basename for row in _ENTRY_GUARD_READS),
126
+ CMUX_ADAPTER_BASENAME,
127
+ )
118
128
 
119
129
 
120
130
  @dataclass
@@ -208,7 +218,7 @@ def _scan_one_jsonl(
208
218
  progress.append((ts, m.group("phase"), line))
209
219
  elif block.get("type") == "tool_use" and block.get("name") == "Read":
210
220
  base = Path(str((block.get("input") or {}).get("file_path") or "")).name
211
- if base in _ENTRY_GUARD_BASENAMES:
221
+ if base in _TRACKED_READ_BASENAMES:
212
222
  reads.setdefault(base, []).append(ts)
213
223
  return progress, reads, agent_name
214
224
 
@@ -334,7 +344,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
334
344
  if not isinstance(basename, str) or not basename:
335
345
  raw_path = details.get("path") or details.get("filePath")
336
346
  basename = Path(str(raw_path or "")).name
337
- if basename not in _ENTRY_GUARD_BASENAMES:
347
+ if basename not in _TRACKED_READ_BASENAMES:
338
348
  return None
339
349
  return (basename, event.timestamp)
340
350
 
@@ -752,6 +762,29 @@ def _instruction_set_dir(run_dir: Path, suffix: str | None, project_root: Path)
752
762
  return path if path.is_dir() else None
753
763
 
754
764
 
765
+ def _check_cmux_adapter_read(
766
+ evidence: _LeadEvidence, team_state: dict, errors: list[str]
767
+ ) -> None:
768
+ """검사 4 — cmux 어댑터 읽음. 모든 task-type 에 적용된다.
769
+
770
+ 다른 어댑터는 lead 의 런타임이 고르지만 이것은 환경이 고른다. 그래서 읽지
771
+ 않은 lead 에게는 이 경로가 존재한다는 사실 자체가 닿지 않고, 자기 런타임이
772
+ 아는 host 네이티브 디스패치로 되돌아간다 — cmux 경로에서 okstra 가 소유한
773
+ 바로 그 일이다."""
774
+ adapter = team_state.get("leadAdapter")
775
+ name = str(adapter.get("name", "")).strip() if isinstance(adapter, dict) else ""
776
+ if name != CMUX_ADAPTER_NAME:
777
+ return
778
+ if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
779
+ return
780
+ errors.append(
781
+ f"cmux adapter: no `Read` of `{CMUX_ADAPTER_BASENAME}` found in the "
782
+ "selected adapter evidence source within this run's window — the cmux "
783
+ "adapter is selected by environment, not by lead runtime, so it MUST be "
784
+ f"read before dispatch ({_CMUX_ADAPTER_CITE})."
785
+ )
786
+
787
+
755
788
  def _check_implementation_entry_guard(
756
789
  evidence: _LeadEvidence, errors: list[str], instruction_set: Path | None
757
790
  ) -> None:
@@ -837,6 +870,7 @@ def validate_session_conformance(
837
870
  result.errors.append(error)
838
871
  return result
839
872
  _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
873
+ _check_cmux_adapter_read(evidence, team_state, result.errors)
840
874
  if task_type == "implementation":
841
875
  _check_implementation_entry_guard(
842
876
  evidence,