okstra 0.196.0 → 0.197.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 (34) hide show
  1. package/dist/cli-registry.mjs +6 -0
  2. package/dist/cli-registry.mjs.map +1 -1
  3. package/docs/cli.md +14 -1
  4. package/docs/project-structure-overview.md +1 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
  8. package/runtime/prompts/lead/plan-body-verification.md +21 -3
  9. package/runtime/prompts/profiles/implementation-planning.md +3 -2
  10. package/runtime/prompts/wizard/prompts.ko.json +2 -2
  11. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
  13. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
  15. package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
  16. package/runtime/python/okstra_ctl/conformance.py +10 -0
  17. package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
  18. package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
  19. package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
  20. package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
  21. package/runtime/python/okstra_ctl/plan_items.py +14 -0
  22. package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
  23. package/runtime/python/okstra_ctl/run.py +54 -0
  24. package/runtime/python/okstra_ctl/session_transcript.py +4 -4
  25. package/runtime/python/okstra_ctl/stage_close.py +244 -0
  26. package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
  27. package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
  28. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
  29. package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
  30. package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
  31. package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
  32. package/runtime/skills/okstra-run/SKILL.md +63 -4
  33. package/runtime/validators/validate-implementation-plan-stages.py +109 -23
  34. package/runtime/validators/validate-run.py +30 -6
@@ -0,0 +1,131 @@
1
+ """사용자 확인형 TDD 우회 원장 — `<task-root>/qa/tdd-bypass.json`.
2
+
3
+ S10c 는 모든 stage 의 첫 step 에 `RED:` 를, 뒤 step 중 하나에 `GREEN:` 을
4
+ 요구하고, S10e 는 그 요구를 `doc-only` / `config-only` / `pure-rename` 세
5
+ 사유로만 면제한다. 세 사유 중 어느 것도 맞지 않는 stage 는 통과할 값이
6
+ 없으므로, 계획서가 가장 가까운 토큰을 골라 자기를 잘못 기술하게 된다
7
+ (실측 2026-09-10, fontsninja-v3-site dev-10628-3 implementation-planning 002:
8
+ 제품 변경이 이전 run 에서 이미 커밋·적합성 PASS 까지 끝난 stage 를
9
+ `config-only` 로 신고).
10
+
11
+ 그래서 네 번째 사유 `user-bypass` 는 계획서의 선언만으로는 성립하지 않는다.
12
+ 사용자가 `okstra prepare --tdd-bypass "<stage>:<reason>"` 로 이 파일에 사유를
13
+ 원문 그대로 남겨야 검증기가 인정한다 — `qa/self-mock-waivers.json` 이 gate A/B
14
+ 의 우회를 담는 방식과 같은 idiom 이고, 같은 `{reason, acknowledgedBy}` 계약을
15
+ 쓴다. 계획서가 스스로에게 면제를 발급하는 경로는 없다.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Any, Mapping
21
+
22
+ from .json_boundary import (
23
+ JsonBoundaryError,
24
+ load_owned_object,
25
+ write_owned_object_atomic,
26
+ )
27
+
28
+ ARTIFACT = "tdd bypass ledger"
29
+ FILENAME = "tdd-bypass.json"
30
+ SCHEMA_VERSION = "1.0"
31
+
32
+ # `tddExemption` 에 적는 값. S10e 가 이 토큰을 볼 때만 원장을 조회한다.
33
+ REASON_TOKEN = "user-bypass"
34
+
35
+
36
+ class TddBypassError(ValueError):
37
+ """TDD 우회 원장 입력이 계약을 위반했다."""
38
+
39
+
40
+ def bypass_file(task_root: Path) -> Path:
41
+ """이 task 의 우회 원장 경로. conformance 산출물과 같은 `qa/` 아래다."""
42
+ return task_root / "qa" / FILENAME
43
+
44
+
45
+ def parse_bypass_arg(value: object) -> tuple[int, str] | None:
46
+ """`--tdd-bypass` 값 `<stage>:<reason>` 를 (stage, reason) 로 분해.
47
+
48
+ 형식이 아니거나 stage 가 1 이상의 정수가 아니면 None — 호출 측이 무엇을
49
+ 받았는지 그대로 보여 주며 거절한다.
50
+ """
51
+ if not isinstance(value, str) or ":" not in value:
52
+ return None
53
+ raw_stage, reason = value.split(":", 1)
54
+ raw_stage, reason = raw_stage.strip(), reason.strip()
55
+ if not raw_stage.isdigit() or not reason:
56
+ return None
57
+ stage = int(raw_stage)
58
+ if stage < 1:
59
+ return None
60
+ return stage, reason
61
+
62
+
63
+ def _entries(ledger: object) -> list[dict[str, Any]]:
64
+ rows = ledger.get("entries") if isinstance(ledger, Mapping) else None
65
+ return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
66
+
67
+
68
+ def record_bypass(
69
+ path: Path, stage: int, reason: str, *, at: str, acknowledged_by: str = "user",
70
+ ) -> None:
71
+ """stage 의 우회 사유를 원문 그대로 기록한다(같은 stage 는 마지막 값이 이긴다).
72
+
73
+ 사용자가 사유를 고쳐 다시 부여하는 것이 정상 경로이므로 중복은 오류가
74
+ 아니라 교체다. 파일이 없으면 만든다 — 전제 파일 부재로 거절하면 사용자가
75
+ 빈 원장을 손으로 만들어야 한다.
76
+ """
77
+ if not isinstance(stage, int) or stage < 1:
78
+ raise TddBypassError(f"stage must be a positive integer, got {stage!r}")
79
+ if not isinstance(reason, str) or not reason.strip():
80
+ raise TddBypassError("reason must be a non-empty string")
81
+ if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
82
+ raise TddBypassError("acknowledgedBy must be a non-empty string")
83
+ ledger: dict[str, Any]
84
+ if path.is_file():
85
+ try:
86
+ ledger = load_owned_object(path, artifact=ARTIFACT)
87
+ except JsonBoundaryError as exc:
88
+ raise TddBypassError(str(exc)) from exc
89
+ else:
90
+ ledger = {"schemaVersion": SCHEMA_VERSION, "entries": []}
91
+ rows = [row for row in _entries(ledger) if row.get("stage") != stage]
92
+ rows.append({
93
+ "stage": stage,
94
+ "reason": reason.strip(),
95
+ "acknowledgedBy": acknowledged_by.strip(),
96
+ "at": at,
97
+ })
98
+ ledger["schemaVersion"] = ledger.get("schemaVersion") or SCHEMA_VERSION
99
+ ledger["entries"] = sorted(rows, key=lambda row: row["stage"])
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ try:
102
+ write_owned_object_atomic(path, ledger, artifact=ARTIFACT)
103
+ except JsonBoundaryError as exc:
104
+ raise TddBypassError(str(exc)) from exc
105
+
106
+
107
+ def granted_stages(path: Path) -> dict[int, str]:
108
+ """`{stage: reason}` — 사용자가 우회를 부여한 stage 들.
109
+
110
+ 파일이 없으면 빈 map 이다(우회 없음). 사유나 승인자가 빈 행은 우회로
111
+ 세지 않는다 — 그 행은 사용자가 무엇을 승인했는지 말하지 못한다.
112
+ """
113
+ if not path.is_file():
114
+ return {}
115
+ try:
116
+ ledger = load_owned_object(path, artifact=ARTIFACT)
117
+ except JsonBoundaryError as exc:
118
+ raise TddBypassError(str(exc)) from exc
119
+ granted: dict[int, str] = {}
120
+ for row in _entries(ledger):
121
+ stage = row.get("stage")
122
+ reason = row.get("reason")
123
+ acknowledged_by = row.get("acknowledgedBy")
124
+ if not isinstance(stage, int) or isinstance(stage, bool) or stage < 1:
125
+ continue
126
+ if not isinstance(reason, str) or not reason.strip():
127
+ continue
128
+ if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
129
+ continue
130
+ granted[stage] = reason.strip()
131
+ return granted
@@ -35,10 +35,10 @@ from .state import Prompt, WizardError, WizardState, _is_role_selection_step
35
35
  from .prompts import _domain_prompt
36
36
  from .picker_navigation import (
37
37
  accept_picker_answer,
38
- is_split_checkbox,
39
- merge_split_checkbox_answer,
38
+ is_split_picker,
39
+ merge_split_picker_answer,
40
40
  present_picker,
41
- split_checkbox,
41
+ split_picker,
42
42
  )
43
43
  from .roles import _submit_role_prompt, next_role_prompt
44
44
  from .steps_identity import _submit_task_pick
@@ -140,15 +140,18 @@ def next_prompt(state: WizardState) -> Prompt:
140
140
  def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
141
141
  """호스트 네이티브 선택기 한도에 맞춘 화면.
142
142
 
143
- 단일 선택이 한도를 넘으면 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는
144
- 쪽으로 나누지 않는다 — 종전엔 한 줄씩 토글하는 쪽으로 내렸는데, claude-code
145
- 한도 4 에서 후보 12개는 쪽당 2개가 됐고, 쪽 사본이 추천 표시를 단 채 단일
146
- 선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히 하나)에 걸려
147
- 화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면). 대신
148
- 네이티브 질문 묶음에 실리는 크기(claude-code 4×4=16)면 같은 화면의 체크박스
149
- 질문 여러 개로 자른다(`split_checkbox`) — 묶음이 네이티브에 실릴 때만이고,
150
- 아니면 `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 전체
151
- 목록을 한 번에 보인다.
143
+ 한도를 넘는 픽은 체크박스든 단일 선택이든 네이티브 질문 묶음에 실리는
144
+ 크기(claude-code 4×4=16)까지 같은 화면의 체크박스 질문 여러 개로 자른다
145
+ (`split_picker`) — 사용자는 탭을 옮겨 다니며 한 번에 답한다. 단일 선택을
146
+ 쪽으로 나누던 종전 화면은 후보 하나를 고르는 데 "다음 선택지" 를 누를
147
+ 때마다 턴이 하나씩 들었다(실측 2026-09-09, 후보 13개인 critic 화면).
148
+
149
+ 묶음에 못 실리면(옵션 16개 초과, 라벨 중복, 질문 묶음이 없는 세션)
150
+ 단일 선택은 종전대로 쪽으로 나누고(`present_picker`), 체크박스는
151
+ `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 전체 목록을
152
+ 한 번에 보인다. 체크박스는 쪽으로 나누지 않는다 — 쪽 사본이 추천 표시를
153
+ 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히
154
+ 하나)에 걸려 화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면).
152
155
  """
153
156
  if "native_single_select" not in state.available_functions:
154
157
  return prompt
@@ -157,14 +160,14 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
157
160
  return prompt
158
161
  prompt = prompt.questions[0]
159
162
  port = default_host_registry().resolve(state.host_runtime).interaction()
163
+ split = split_picker(
164
+ prompt,
165
+ max_options=port.native_option_limit,
166
+ max_questions=port.native_question_limit,
167
+ )
168
+ if split is not prompt and _interaction_plan(state, split).kind == "native-group":
169
+ return split
160
170
  if prompt.multi:
161
- split = split_checkbox(
162
- prompt,
163
- max_options=port.native_option_limit,
164
- max_questions=port.native_question_limit,
165
- )
166
- if split is not prompt and _interaction_plan(state, split).kind == "native-group":
167
- return split
168
171
  return prompt
169
172
  return present_picker(state, prompt, limit=port.native_option_limit)
170
173
 
@@ -219,7 +222,7 @@ def _sim_answer(prompt: Prompt) -> str:
219
222
  def _sim_advance(state: WizardState, prompt: Prompt) -> None:
220
223
  """기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
221
224
  _submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
222
- if is_split_checkbox(prompt):
225
+ if is_split_picker(prompt):
223
226
  # 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
224
227
  prompt = _next_prompt_screen(state)
225
228
  try:
@@ -402,10 +405,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
402
405
  value = accept_picker_answer(state, original, value)
403
406
  if value is None:
404
407
  return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
405
- if is_split_checkbox(prompt):
406
- # 질문 묶음으로 잘린 체크박스 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의
407
- # 제출 경로로 보낸다. 원본의 선택지로 검증한다.
408
- value = merge_split_checkbox_answer(prompt, value)
408
+ if is_split_picker(prompt):
409
+ # 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
410
+ # 경로로 보낸다. 원본의 선택지로 검증한다.
411
+ value = merge_split_picker_answer(prompt, value)
409
412
  prompt = _next_prompt_screen(state)
410
413
  elif prompt.kind == "pick_group":
411
414
  return _submit_group(state, prompt, value)
@@ -1,13 +1,16 @@
1
1
  """호스트 선택기 한도 안에서 원래 선택지를 보존하는 두 가지 강등.
2
2
 
3
- 단일 선택은 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는 쪽으로 나누지
4
- 않는다 — 쪽 사본이 추천 표시를 단 채 단일 선택으로 바뀌면 `Prompt` 의 추천
5
- 불변식에 걸리므로, 한 줄씩 토글하던 체크박스 쪽 나누기는 2026-09-09 에 뺐다.
6
- 대신 네이티브 질문 묶음(claude-code `AskUserQuestion` 의 질문 4개 × 옵션 4개)에
7
- 실리는 크기면 같은 화면 안의 체크박스 질문 여러 개로 자른다(`split_checkbox`):
8
- 사용자는 탭마다 체크하고, 답은 하나의 CSV 로 합쳐져(`merge_split_checkbox_answer`)
9
- 원래 step 의 제출 경로로 간다. 그 크기도 넘으면 `numbered-multi` 로 전체 목록을
10
- 한 번에 보인다(`engine._native_picker_screen`).
3
+ 네이티브 질문 묶음(claude-code `AskUserQuestion` 의 질문 4개 × 옵션 4개)에
4
+ 실리는 크기면 같은 화면 안의 체크박스 질문 여러 개로 자른다(`split_picker`):
5
+ 사용자는 탭마다 체크하고, 답은 하나의 CSV 로 합쳐져(`merge_split_picker_answer`)
6
+ 원래 step 의 제출 경로로 간다. 단일 선택(예: critic 처럼 최대 1개인 역할)도
7
+ 같은 방식으로 자르되 통틀어 하나만 고른 답이어야 한다.
8
+
9
+ 그 크기도 넘거나 질문 묶음이 없는 세션이면, 단일 선택은 쪽으로 나누고
10
+ (`present_picker`) 체크박스는 `numbered-multi` 로 전체 목록을 한 번에 보인다
11
+ (`engine._native_picker_screen`). 체크박스를 쪽으로 나누던 경로는 쪽 사본이
12
+ 추천 표시를 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식에 걸리므로
13
+ 2026-09-09 에 뺐다.
11
14
  """
12
15
  import json
13
16
  import math
@@ -20,10 +23,18 @@ _PAGE_PREFIX = "__okstra_picker_page__:"
20
23
  _SPLIT_SEPARATOR = "#"
21
24
 
22
25
 
23
- def split_checkbox(
26
+ def split_picker(
24
27
  prompt: Prompt, *, max_options: int, max_questions: int,
25
28
  ) -> Prompt:
26
- """옵션이 `max_options` 를 넘는 체크박스를 같은 화면의 질문 묶음으로 자른다.
29
+ """옵션이 `max_options` 를 넘는 픽을 같은 화면의 체크박스 질문 묶음으로 자른다.
30
+
31
+ 조각은 원래 픽이 단일 선택이어도 체크박스다. 단일 선택 조각으로 두면 고를
32
+ 것이 없는 조각에도 답이 있어야 해서 조각마다 "여기 없음" 한 줄이 들어가고,
33
+ 조각당 실선택지가 하나 줄어 후보 13개짜리 critic 화면은 조각 5개가 돼
34
+ 묶음에 아예 못 실린다(claude-code 한도 4×4). 체크박스 조각은 아무것도
35
+ 고르지 않은 조각이 곧 답 없는 조각이라 그 줄이 필요 없다. 원래 픽이 단일
36
+ 선택이었다는 사실은 그룹의 `multi=False` 로 남고, `merge_split_picker_answer`
37
+ 가 통틀어 하나만 골랐는지 본다.
27
38
 
28
39
  질문 수는 옵션이 들어가는 최소 개수이고 옵션은 질문에 고르게 나눈다 —
29
40
  마지막 질문이 한 줄짜리가 되면 호스트 최소 옵션 수(2)에 걸려 묶음 전체가
@@ -31,7 +42,7 @@ def split_checkbox(
31
42
  앞머리 run 이 된다(`Prompt._check_recommendations`). `max_questions` 도
32
43
  넘으면 자르지 않고 그대로 돌려준다.
33
44
  """
34
- if prompt.kind != "pick" or not prompt.multi or len(prompt.options) <= max_options:
45
+ if prompt.kind != "pick" or len(prompt.options) <= max_options:
35
46
  return prompt
36
47
  count = len(prompt.options)
37
48
  questions = math.ceil(count / max_options)
@@ -48,6 +59,7 @@ def split_checkbox(
48
59
  step=f"{prompt.step}{_SPLIT_SEPARATOR}{index + 1}",
49
60
  label=f"{prompt.label} ({offset + 1}–{offset + size}/{count})",
50
61
  options=shown,
62
+ multi=True,
51
63
  ))
52
64
  offset += size
53
65
  return Prompt(
@@ -56,11 +68,12 @@ def split_checkbox(
56
68
  label=prompt.label,
57
69
  help=prompt.help,
58
70
  echo_template=prompt.echo_template,
71
+ multi=prompt.multi,
59
72
  questions=chunks,
60
73
  )
61
74
 
62
75
 
63
- def is_split_checkbox(prompt: Prompt) -> bool:
76
+ def is_split_picker(prompt: Prompt) -> bool:
64
77
  return prompt.kind == "pick_group" and bool(prompt.questions) and all(
65
78
  question.multi
66
79
  and question.step.startswith(f"{prompt.step}{_SPLIT_SEPARATOR}")
@@ -68,8 +81,13 @@ def is_split_checkbox(prompt: Prompt) -> bool:
68
81
  )
69
82
 
70
83
 
71
- def merge_split_checkbox_answer(prompt: Prompt, value: str) -> str:
72
- """질문 묶음 답(JSON, 조각 step → CSV)을 원래 체크박스의 CSV 한 줄로 합친다."""
84
+ def merge_split_picker_answer(prompt: Prompt, value: str) -> str:
85
+ """질문 묶음 답(JSON, 조각 step → CSV)을 원래 픽의 CSV 한 줄로 합친다.
86
+
87
+ 원래 픽이 단일 선택이면(그룹의 `multi=False`) 통틀어 한 줄만 고른 답이어야
88
+ 한다 — 두 조각에서 고른 답을 그대로 합치면 원래 step 은 `a,b` 를 값 하나로
89
+ 받아 "선택지가 아니다" 라고만 말한다.
90
+ """
73
91
  try:
74
92
  answers = json.loads(value or "{}")
75
93
  except json.JSONDecodeError as exc:
@@ -87,6 +105,11 @@ def merge_split_checkbox_answer(prompt: Prompt, value: str) -> str:
87
105
  for question in prompt.questions:
88
106
  raw = str(answers.get(question.step, "") or "")
89
107
  chosen.extend(item.strip() for item in raw.split(",") if item.strip())
108
+ if not prompt.multi and len(chosen) > 1:
109
+ raise WizardError(
110
+ f"wizard step {prompt.step!r}: this screen takes one choice, "
111
+ f"but {len(chosen)} were picked across its tabs: {', '.join(chosen)}"
112
+ )
90
113
  return ",".join(chosen)
91
114
 
92
115
 
@@ -7,15 +7,16 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
7
7
  (`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
8
8
  고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
9
9
 
10
- 체크박스 화면(`role-models:<role>`)은 실행 가능한 전체 후보를 한 번에 싣는다.
11
- 기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가 앞이고 권장
12
- 수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도(claude-code 4,
13
- codex 3, grok 15)를 넘으면 네이티브 질문 묶음에 실리는 크기(claude-code 4×4)
14
- 까지는 같은 화면의 체크박스 질문 여러 개로 자르고(`picker_navigation.split_checkbox`),
15
- 그것도 넘으면 `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 번호
16
- 목록이 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는 두 번째 화면으로
17
- 나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중 3개만 보이고, 두
18
- 번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
10
+ 모델 화면(`role-models:<role>`, `role-model:<role>:1`)은 실행 가능한 전체 후보를
11
+ 한 번에 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가
12
+ 앞이고 권장 수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도
13
+ (claude-code 4, codex 3, grok 15)를 넘으면 체크박스든 단일 선택이든 네이티브
14
+ 질문 묶음에 실리는 크기(claude-code 4×4)까지는 같은 화면의 체크박스 질문 여러
15
+ 개로 자르고(`picker_navigation.split_picker`), 그것도 넘으면 체크박스는
16
+ `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 번호 목록이,
17
+ 단일 선택은 쪽 나누기가 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는
18
+ 두 번째 화면으로 나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중
19
+ 3개만 보이고, 두 번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
19
20
  """
20
21
  from __future__ import annotations
21
22
 
@@ -199,12 +200,16 @@ def _available_role_models(
199
200
 
200
201
  def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
201
202
  # 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
202
- # 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
203
+ # 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다. 경고는 라벨이
204
+ # 아니라 설명에 실린다: 체크박스 탭의 답은 고른 라벨을 `, ` 로 이어 붙인
205
+ # 한 줄이라(claude-code relay), 쉼표가 든 라벨 하나가 그 화면 전체를
206
+ # 네이티브 묶음에서 떨어뜨려 쪽 나누기로 되돌린다.
203
207
  warnings = t["options"].get("skip_warnings", {})
204
208
  warning = warnings.get(requirement.role, "") if isinstance(warnings, dict) else ""
205
209
  return _opt(
206
210
  ROLE_SKIP_TOKEN,
207
- t["options"]["skip"].format(skip_warning=warning),
211
+ t["options"]["skip"],
212
+ warning,
208
213
  recommended=recommended,
209
214
  )
210
215
 
@@ -13,6 +13,7 @@ from .convergence_reverify_prompt import RENDERED_BY_LINE
13
13
  from .convergence_critic_verify_prompt import (
14
14
  RENDERED_BY_LINE as CRITIC_VERIFY_RENDERED_BY_LINE,
15
15
  )
16
+ from .plan_items import RENDERED_BY_LINE as PLAN_VERIFY_RENDERED_BY_LINE
16
17
  from .worker_prompt_body import analysis_worker_label
17
18
  from .json_boundary import load_owned_object
18
19
  from .worker_prompt_policy import (
@@ -20,6 +21,7 @@ from .worker_prompt_policy import (
20
21
  IMPLEMENTATION_HEADERS,
21
22
  CRITIC_VERIFY_DISPATCH_KIND,
22
23
  PromptPlan,
24
+ is_plan_verify_dispatch_kind,
23
25
  resolve_prompt_plan_for_manifest,
24
26
  )
25
27
  from .worker_prompt_headers import EVIDENCE_LEDGER_HEADER
@@ -325,7 +327,19 @@ def validate_reverify_prompt(
325
327
  ):
326
328
  errors.append("phase boundary block must precede reverify instructions")
327
329
  instructions = normalized[_task_instructions_offset(normalized):]
328
- if dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
330
+ if is_plan_verify_dispatch_kind(dispatch_kind):
331
+ # 계획 본문 라운드의 정본 렌더러는 convergence 가 아니라 `okstra
332
+ # plan-items prompt` 다. 같은 서명을 요구하면 통과할 값이 하나도 없다
333
+ # (2026-09-09 dev-10642 implementation-planning 001: 라운드 0회).
334
+ if PLAN_VERIFY_RENDERED_BY_LINE not in instructions:
335
+ errors.append(
336
+ "plan-verify instruction is not the output of `okstra plan-items "
337
+ "prompt` (missing the `**Rendered by:**` line) — render it with "
338
+ "`okstra plan-items prompt --run-manifest <run-manifest>` and pass "
339
+ "that output verbatim as --instruction; hand-written plan item "
340
+ "queues are refused"
341
+ )
342
+ elif dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
329
343
  if CRITIC_VERIFY_RENDERED_BY_LINE not in instructions:
330
344
  errors.append(
331
345
  "critic-verify instruction is not the output of `okstra convergence "
@@ -83,16 +83,59 @@ WORKER_ERROR_CONTRACT_FILENAME = "worker-error-contract.md"
83
83
  # 따르되 라운드 원장 밖이라 번호가 없다. 실측(2026-09-09 dev-10642): 이 kind 가
84
84
  # 없어 gap 검증 프롬프트를 만들 수 없었고 gap 3건 전부 `gapsUnverified` 로 남았다.
85
85
  CRITIC_VERIFY_DISPATCH_KIND = "critic-verify"
86
+ REVERIFY_DISPATCH_KIND_PREFIX = "reverify-r"
87
+ # 계획 본문 검증(§5.5.9) 라운드의 dispatch kind. 결과 파일명 규약이 이미
88
+ # `<role>-worker-plan-verify-r<N>-…` 이라(`plan-body-verification.md` §"Round
89
+ # protocol", `dispatch_state._plan_verify_result_workers`) kind 도 같은 번호를
90
+ # 단다. 실측(2026-09-09 dev-10642 implementation-planning 001): 이 kind 가 없어
91
+ # 계획 항목 큐를 `reverify-r<N>` 으로 보낼 수밖에 없었고, 그러면 검증 계약이
92
+ # `okstra convergence reverify-prompt` 서명을 요구하는데 계획 항목 큐의 정본
93
+ # 렌더러는 `okstra plan-items prompt` 라 통과할 값이 하나도 없었다. 라운드가
94
+ # 한 번도 열리지 못한 채 `planBodyVerification.roundCount` 가 0 으로 남았다.
95
+ PLAN_VERIFY_DISPATCH_KIND_PREFIX = "plan-verify-r"
96
+
97
+
98
+ def _numbered_round(dispatch_kind: str, prefix: str) -> int | None:
99
+ """`<prefix><N>` 의 N. 접두사가 다르거나 N 이 양의 정수가 아니면 None."""
100
+ if not dispatch_kind.startswith(prefix):
101
+ return None
102
+ suffix = dispatch_kind[len(prefix):]
103
+ if not suffix.isdigit() or int(suffix) < 1:
104
+ return None
105
+ return int(suffix)
86
106
 
87
107
 
88
108
  def is_verification_dispatch_kind(dispatch_kind: str) -> bool:
89
- """번호 reverify 라운드와 critic gap 검증 — 검증 프롬프트 계약을 받는 kind."""
109
+ """번호 reverify·계획 본문 라운드와 critic gap 검증 — 검증 프롬프트 계약을
110
+ 받는 kind."""
90
111
  return (
91
- dispatch_kind.startswith("reverify-r")
112
+ dispatch_kind.startswith(REVERIFY_DISPATCH_KIND_PREFIX)
113
+ or dispatch_kind.startswith(PLAN_VERIFY_DISPATCH_KIND_PREFIX)
92
114
  or dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND
93
115
  )
94
116
 
95
117
 
118
+ def is_plan_verify_dispatch_kind(dispatch_kind: str) -> bool:
119
+ """계획 본문 검증 라운드인가. 검증 계약이 요구하는 렌더러 서명이 번호
120
+ reverify 와 다르므로 계약 검사가 이 둘을 갈라야 한다."""
121
+ return _numbered_round(dispatch_kind, PLAN_VERIFY_DISPATCH_KIND_PREFIX) is not None
122
+
123
+
124
+ def verification_dispatch_round(dispatch_kind: str) -> int | None:
125
+ """검증 kind 가 적는 라운드 번호. 번호 없는 kind(`critic-verify`)는 1,
126
+ 검증 kind 가 아니거나 번호가 깨졌으면 None.
127
+
128
+ 예약(`agent-prompt materialize`)과 디스패치가 같은 값을 적어야 한다 —
129
+ validate-run 이 team-state 의 kind 와 예약된 invocation 의 `dispatchKind`
130
+ 를 대조하므로, 두 계산이 갈리면 그 디스패치가 거부된다."""
131
+ if dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
132
+ return 1
133
+ for prefix in (REVERIFY_DISPATCH_KIND_PREFIX, PLAN_VERIFY_DISPATCH_KIND_PREFIX):
134
+ if dispatch_kind.startswith(prefix):
135
+ return _numbered_round(dispatch_kind, prefix)
136
+ return None
137
+
138
+
96
139
  @dataclass(frozen=True)
97
140
  class PromptPlan:
98
141
  audience: PromptAudience
@@ -53,7 +53,7 @@ The wizard tells you which relay operation to use via `next.interaction.kind`. S
53
53
  - `kind: "done"` → input collection finished; move to Step 5.
54
54
  - `kind: "aborted"` → the user picked abort; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
55
55
 
56
- When native single selection is available, the runtime divides long lists and unsupported multi-selections into selectable screens. Render the returned screen without rebuilding the full list. This applies to plan confirmation, stages, role counts, and provider/model lists. Submit navigation and completion option values normally; the runtime retains the current step until its answer is complete. A ban on textual choice lists does not authorize asking the user to type a model identifier. If a required selector is unavailable, preserve state and follow the relay's recovery. Genuine text steps still collect text.
56
+ When native single selection is available, the runtime divides long lists and unsupported multi-selections into selectable screens. Render the returned screen without rebuilding the full list. This applies to plan confirmation, stages, role counts, and provider/model lists. Submit navigation and completion option values normally; the runtime retains the current step until its answer is complete. A ban on textual choice lists does not authorize asking the user to type a model identifier. If a required selector is unavailable, preserve state and follow the relay's `recovery` object. Genuine text steps still collect text.
57
57
 
58
58
  Submit the answer shape required by `interaction.answerProtocol`; do not add normalization beyond the registered relay's explicit mapping. Invalid, out-of-range, or ambiguous answers return `ok: false` and must re-render the same complete interaction.
59
59
 
@@ -83,11 +83,11 @@ On `Okstra preflight: ready`, require `Runtime readiness: ready` before Step 2.
83
83
 
84
84
  Carry the fixed `Lead entry mode` line into Step 2's `--entry-mode` as a literal. It is the mode the run must launch in, which is not always the mode this session is in: a host adapter may report that this session cannot host the lead while the run itself is ready. `spawn-process` means okstra starts its own lead process and this session is not the lead — when the line reads `spawn-process` and a readiness check carries `action: spawn-unsandboxed-codex-lead`, tell the user in one line that their current Codex session is sandboxed so okstra will open an unsandboxed lead instead, then continue. Never substitute `current-session` for a `spawn-process` answer, and never recompute the value from the host ID.
85
85
 
86
- For a ready response, read the absolute path in the fixed `Relay contract` line with the current host's file-read primitive. Do not derive the path from the host ID or search `PATH`. In that file, find the `Wizard interaction relay` JSON block, require `schemaVersion: 1` and `runtime` equal to the fixed `Runtime` line, then take its `semanticFunctions` allowlist and intersect it with the functions the live harness exposes. The live harness does not expose tools named `native_single_select`. Map each allowlist token to the matching `interactions` kind (`native_single_select` → `native-single`, `native_multi_select` → `native-multi`, `native_question_group` → `native-group`). Include the token in the intersection only when this session can call the string in that kind's `function` field. If the kind is absent from `interactions`, omit the token. Pass only that intersection to Step 2; `plain_text_input` must be present. Keep the parsed `interactions` object for Step 3's function/input/response conversion. An unreadable file, malformed block, runtime mismatch, absent `plain_text_input`, or later interaction kind missing from the object is a host relay contract failure: show the problem and stop rather than guessing.
86
+ For a ready response, read the absolute path in the fixed `Relay contract` line with the current host's file-read primitive. Do not derive the path from the host ID or search `PATH`. In that file, find the `Wizard interaction relay` JSON block, require `schemaVersion: 1` and `runtime` equal to the fixed `Runtime` line, then take its `semanticFunctions` allowlist and intersect it with the functions the live harness exposes. The live harness does not expose tools named `native_single_select`. Map each allowlist token to the matching `interactions` kind (`native_single_select` → `native-single`, `native_multi_select` → `native-multi`, `native_question_group` → `native-group`). Include the token in the intersection only when this session can call the string in that kind's `function` field. If the kind is absent from `interactions`, omit the token. Pass only that intersection to Step 2; `plain_text_input` must be present. Keep the parsed `interactions` object for Step 3's function/input/response conversion, and the parsed `recovery` object for the branch below. An unreadable file, malformed block, runtime mismatch, absent `plain_text_input`, or later interaction kind missing from the object is a host relay contract failure: show the problem and stop rather than guessing.
87
87
 
88
88
  If the successful fixed projection has `Relay contract: -`, enter the compatibility branch below. In that branch only, declare `plain_text_input` and keep its built-in `interactions` mapping for Step 3; do not assume a native tool from the host ID. The existing `unknown command: preflight` branch remains the authoritative stale-CLI failure.
89
89
 
90
- Before calculating the effective intersection, apply the registered relay's client-specific tool selection and live mode restrictions. A callable question tool does not necessarily render a selector in the current client. In Codex, use the synchronous picker when permitted; asynchronous question cards are a desktop fallback, not a terminal picker. If the user requires a selectable interface and the current client cannot provide it, preserve the wizard state and follow the relay's recovery instead of printing the option list.
90
+ Before calculating the effective intersection, apply the registered relay's client-specific tool selection and live mode restrictions. A callable question tool does not necessarily render a selector in the current client. In Codex, use the synchronous picker when permitted; asynchronous question cards are a desktop fallback, not a terminal picker. A declared native function can still fail at call time: the client refuses the call, the session has no view to present it in, or it returns no answer. That is not a reason to stop, and not a reason to try a different native function. Follow the relay's `recovery.native-question-refused` entry the first time it happens — drop the tokens it names from the intersection, keep `plain_text_input`, tell the user in one line that the host picker is unavailable, and render every remaining screen through the text mapping. Numbered text is that entry's own recovery path, so the ban on printing a numbered list does not apply once it fires. The wizard state file is untouched by a refused call: `okstra wizard step --state-file <path> --no-submit` returns the pending prompt again.
91
91
 
92
92
  Plan adoption (`approve_plan_confirm`) is a workflow choice: present the wizard's existing options using the same selector as other `pick` steps. Follow the relay's distinction between plan decisions and execution permissions; the word "approval" alone is not a reason to replace a selector with a typed confirmation.
93
93
 
@@ -198,7 +198,7 @@ That is the entire interactive flow. The wizard handles:
198
198
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
199
199
  - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = the earliest incomplete stage whose dependencies are satisfied, or a specific stage number). Implementer slots use role-count / role-model like every other role (`executor` is only a compatibility alias for `implementer`). When an approved plan is selected and a `## PLAN DECISION` sidecar carrying `Status: approved`, exported from the report — matching the plan on source-report·seq — is detected in that run's sibling `user-responses/`, the approve-confirm step expands to 3 options (`yes_apply` recommended: approve + apply the option as exported / `yes` approve only / `no` abort) — `yes_apply` validates the option against the plan's `optionCandidates` before applying it via the existing approval·option path,
200
200
  - `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),
201
- - launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, every executable candidate is listed on that one screen (defaults first, the recommended set flagged), and when the list exceeds the host's native checkbox limit the runtime either splits it into several checkbox questions on one `pick_group` screen (interaction plan `native-group`; the question steps are `role-models:<role>#1`, `#2`, … and the answer is one JSON object keyed by them, each value a CSV) when the host's native question group holds every option, or returns the interaction plan `numbered-multi` — render the whole list, never a shortlist or pages. An optional role (`min = 0`, e.g. critic) carries a `추가 안 함` row. A fixed single role (`min = max = 1`, e.g. report-writer) is a single pick `role-model:<role>:1`. current-session lead is this session and is listed on the confirmation summary, not as a wizard step. The wizard does not fork on defaults-vs-customize, does not show a provider roster multi-pick, and does not offer a separate implementer-provider pick. Dynamic verifiers are not chosen at launch. `--workers` is compatibility-only, not a launch picker. Repeated `--role-count` / `--role-model` tokens on `renderArgv` are intentional,
201
+ - launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, every executable candidate is listed on that one screen (defaults first, the recommended set flagged), and when the list exceeds the host's native checkbox limit the runtime either splits it into several checkbox questions on one `pick_group` screen (interaction plan `native-group`; the question steps are `role-models:<role>#1`, `#2`, … and the answer is one JSON object keyed by them, each value a CSV) when the host's native question group holds every option, or returns the interaction plan `numbered-multi` — render the whole list, never a shortlist or pages. An optional role (`min = 0`, e.g. critic) carries a `추가 안 함` row. A fixed single role (`min = max = 1`, e.g. report-writer) is a single pick `role-model:<role>:1`, and a role capped at one model (`max = 1`, e.g. critic) is a single pick on its `role-models:<role>` step; both split the same way when they exceed the native option limit — the tabs are checkbox questions, the answer is the same keyed JSON object, and the wizard rejects a screen whose tabs together name more than one value. current-session lead is this session and is listed on the confirmation summary, not as a wizard step. The wizard does not fork on defaults-vs-customize, does not show a provider roster multi-pick, and does not offer a separate implementer-provider pick. Dynamic verifiers are not chosen at launch. `--workers` is compatibility-only, not a launch picker. Repeated `--role-count` / `--role-model` tokens on `renderArgv` are intentional,
202
202
  - **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 role-count·role-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,
203
203
  - **re-verification scope (`reverify_scope_pick`, `implementation-planning` clarification re-runs only)** — asked right before `confirm` when the re-run is narrowable **or** an answered `C-NNN` traces to no stage. When every answered id traces to a stage: 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). When an id is unlinked, `auto` is omitted and the user names stages or picks `full`; that unlinked id does not freeze the run at full. 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. The confirmation block's `reverify-scope` line names unlinked ids as needing stage numbers, not as a forced full re-run,
204
204
  - `release-handoff` PR template override + persist scope,
@@ -290,6 +290,65 @@ The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.
290
290
 
291
291
  You can delete the literal state-file path after this point — its job is done. Invoke `command rm` with the literal path (e.g. `command rm /var/folders/.../okstra-wizard.AbCd.json`), not a shell variable. `command` is what keeps a `rm='rm -i'` alias from turning this into a confirmation prompt nobody is there to answer.
292
292
 
293
+ <!-- BEGIN FRAGMENT: host-orchestration-implementation-planning -->
294
+ ## Host orchestration rules — implementation-planning
295
+
296
+ These are the rules the **host orchestrator** follows around an
297
+ `implementation-planning` run: when a stage genuinely cannot write a RED step,
298
+ and what only the user can grant. They are not lead phase rules — the lead's
299
+ rules live in `prompts/profiles/`.
300
+
301
+ ### Step 5.1 (implementation-planning only): user-confirmed TDD bypass offer
302
+
303
+ Every plan stage must open with a `RED:` step whose outcome is FAIL and reach a
304
+ later `GREEN:` step (validator S10c). `tddExemption` waives that, and until
305
+ 2026-09-10 only for `doc-only`, `config-only`, or `pure-rename` work — so a
306
+ stage that is truthfully none of the three had no passable value, and the plan
307
+ got through by filing the nearest category. That is the failure this flag
308
+ exists to remove: **a closed reason list with no escape makes the plan
309
+ misdescribe itself.**
310
+
311
+ `render-bundle` accepts an optional `--tdd-bypass "<stage>:<reason>"` flag
312
+ (implementation-planning only). It records a **user-acknowledged** bypass into
313
+ `<task-root>/qa/tdd-bypass.json`, and S10e then accepts that stage declaring
314
+ `tddExemption: user-bypass`. The reason is stored **verbatim**.
315
+
316
+ Offer it only when the run's own output says the stage cannot reach RED — a
317
+ planning report blocked at S10e on a `user-bypass` stage, or a plan-body
318
+ verification round whose disagreements say the expected FAIL is unreachable
319
+ (e.g. the stage worktree HEAD is already the accepted commit). Never offer it
320
+ to save a stage that simply has no test written yet; that stage's answer is the
321
+ RED step.
322
+
323
+ This is **never** a lead/worker self-exemption — only the user may grant it,
324
+ and the lead has no command that writes this record. Surface it as a 3-option
325
+ recommendation picker (per the run-prompt recommendation rule):
326
+
327
+ 1. (recommended) Keep the RED/GREEN requirement — re-plan the stage so its
328
+ first step writes the failing test.
329
+ 2. Bypass this stage — ask the user for the stage number and reason, then pass
330
+ `--tdd-bypass "<stage>:<reason>"` to `render-bundle` (reason = the user's
331
+ words, unedited).
332
+ 3. Enter directly — the user types the full `<stage>:<reason>` value.
333
+
334
+ When the user picks a bypass, append `--tdd-bypass "<stage>:<reason>"` to the
335
+ `render-bundle` invocation. Omit the flag entirely otherwise (do **not** pass
336
+ `--tdd-bypass ""`). A malformed value aborts `render-bundle` with a
337
+ `PrepareError`. The grant is per stage number and per task, and it persists
338
+ across runs of that task — the next plan of the same stage may still declare
339
+ `user-bypass` until the user's grant is removed from the ledger.
340
+
341
+ **A stage whose work already landed is usually not a bypass case.** When the
342
+ product change is committed and its conformance result is PASS but the stage
343
+ never registered as done, the honest fix is to close that stage rather than to
344
+ re-plan it without a RED step. Check `okstra stage-map <task-key>` first: when
345
+ `doneStages` omits a stage whose commit is on the stage branch, close it with
346
+ `okstra stage-close <task-key> --stage <N> --from-commit <sha>` and re-plan only
347
+ what is left. That command refuses unless the commit exists and the stage's
348
+ conformance gate permits progress, so it cannot close a stage the run validator
349
+ would have blocked.
350
+ <!-- END FRAGMENT: host-orchestration-implementation-planning -->
351
+
293
352
  <!-- BEGIN FRAGMENT: host-orchestration-implementation -->
294
353
  ## Host orchestration rules — implementation
295
354