okstra 0.196.0 → 0.197.1

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 (39) 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 +36 -5
  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/group_context.py +96 -3
  22. package/runtime/python/okstra_ctl/plan_items.py +14 -0
  23. package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
  24. package/runtime/python/okstra_ctl/run.py +77 -0
  25. package/runtime/python/okstra_ctl/session_transcript.py +4 -4
  26. package/runtime/python/okstra_ctl/set_work_status.py +30 -1
  27. package/runtime/python/okstra_ctl/stage_close.py +244 -0
  28. package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
  29. package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
  30. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
  31. package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
  32. package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
  33. package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
  34. package/runtime/skills/okstra-brief-gen/SKILL.md +24 -7
  35. package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
  36. package/runtime/skills/okstra-run/SKILL.md +63 -4
  37. package/runtime/templates/reports/group-context.template.md +1 -1
  38. package/runtime/validators/validate-implementation-plan-stages.py +109 -23
  39. package/runtime/validators/validate-run.py +63 -8
@@ -0,0 +1,244 @@
1
+ """`okstra stage-close` — 이미 랜딩한 stage 를 기록만 남겨 done 으로 닫는다.
2
+
3
+ stage 완료의 정본은 `runs/implementation-planning/consumers.jsonl` 의 `done`
4
+ 행이고, 그 행은 두 경로로만 생겼다: implementation run 이 정상 종료하거나,
5
+ `backfill_done_from_carry` 가 carry 사이드카에서 복원하거나. 두 경로 모두
6
+ 없는 상태가 실재한다 — 제품 변경은 커밋되고 conformance 결과도 PASS 인데
7
+ run 이 carry 를 쓰기 전에 끝난 경우다. 그러면 `stage-map` 은 `doneStages: []`
8
+ 을 보고하고, 다음 계획은 아무 일도 없었던 것처럼 그 stage 를 다시 쓰며, 거기서
9
+ 나오는 RED 기대는 전부 도달 불가다(실측 2026-09-10, fontsninja-v3-site
10
+ dev-10628-3: 마지막 행이 `started`, carry 디렉터리는 빈 폴더, stage 결과는
11
+ `overall: PASS`).
12
+
13
+ 이 명령은 그 복구 경로다. 커밋과 conformance 결과라는 두 증거를 확인한 뒤
14
+ done 행을 append 한다 — 증거 없이 닫는 수단은 아니다.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from okstra_project import (
24
+ ResolverError,
25
+ StateError,
26
+ resolve_project_root,
27
+ resolve_task_identity,
28
+ )
29
+
30
+ from .conformance import (
31
+ conformance_result_file,
32
+ decide_conformance_gate,
33
+ qa_result_from_dict,
34
+ )
35
+ from .consumers import append_consumer, last_lifecycle_status_by_stage, read_consumers
36
+ from .json_boundary import JsonBoundaryError, load_owned_object
37
+ from .paths import RunRef, task_conformance_manifest_file, task_qa_dir
38
+ from .stage_map import StageMapError, load_latest_plan_stage_map
39
+
40
+ # 이 status 를 이미 가진 stage 는 리드가 판정을 내린 것이다. 되쓰기는 그 판정을
41
+ # 덮는 일이므로 여기서 하지 않는다.
42
+ _SETTLED = ("done", "failed")
43
+
44
+
45
+ def _git_commit_exists(repo_root: Path, commit: str) -> bool:
46
+ probe = subprocess.run(
47
+ ["git", "-C", str(repo_root), "cat-file", "-e", f"{commit}^{{commit}}"],
48
+ capture_output=True,
49
+ )
50
+ return probe.returncode == 0
51
+
52
+
53
+ def _manifest_entry(task_root: Path, stage: int) -> dict | None:
54
+ """이 stage 의 conformance entry, 선언이 없으면 None.
55
+
56
+ stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라 디렉터리 segment 와 표기가
57
+ 다를 수 있으므로 `-stage-<N>` 접미사로 맞춘다 — `_clear_stale_stage_waiver`
58
+ 와 같은 규칙이다.
59
+ """
60
+ path = task_conformance_manifest_file(task_root)
61
+ if not path.is_file():
62
+ return None
63
+ try:
64
+ manifest = load_owned_object(path, artifact="conformance manifest")
65
+ except JsonBoundaryError as exc:
66
+ raise StateError(str(exc), stage="conformance") from exc
67
+ entries = manifest.get("entries")
68
+ if not isinstance(entries, list):
69
+ return None
70
+ suffix = f"-stage-{stage}"
71
+ return next(
72
+ (
73
+ entry for entry in entries
74
+ if isinstance(entry, dict)
75
+ and isinstance(entry.get("stageKey"), str)
76
+ and entry["stageKey"].endswith(suffix)
77
+ ),
78
+ None,
79
+ )
80
+
81
+
82
+ def _conformance_state(task_root: Path, stage: int) -> tuple[bool, str]:
83
+ """(닫아도 되는가, 사람이 읽는 근거).
84
+
85
+ 선언 자체가 없으면 게이트할 것이 없다. 있으면 그 stage 의 결과 사이드카로
86
+ `decide_conformance_gate` 를 그대로 돌린다 — 검증기가 쓰는 것과 같은 판정
87
+ 함수라, 여기서 통과한 stage 는 검증기에서도 통과한다.
88
+ """
89
+ entry = _manifest_entry(task_root, stage)
90
+ if entry is None:
91
+ return True, "no conformance entry declared for this stage"
92
+ key = str(entry.get("stageKey"))
93
+ sidecar = conformance_result_file(task_qa_dir(task_root), key)
94
+ result = None
95
+ if sidecar.is_file():
96
+ try:
97
+ result = qa_result_from_dict(
98
+ load_owned_object(sidecar, artifact="conformance result")
99
+ )
100
+ except JsonBoundaryError:
101
+ # 읽을 수 없는 결과는 결과가 아니다 — MISSING 으로 게이트에 넘겨
102
+ # 판정을 `decide_conformance_gate` 가 내리게 한다. 검증기도 같다.
103
+ result = qa_result_from_dict(None)
104
+ verdict = decide_conformance_gate(entry, result)
105
+ return verdict.ok, f"{verdict.status}: {verdict.message}"
106
+
107
+
108
+ def close_stage(
109
+ project_root: Path, task_key: str, stage: int, head_commit: str,
110
+ ) -> dict:
111
+ """stage 를 done 으로 닫고 그 근거를 함께 돌려준다."""
112
+ identity = resolve_task_identity(project_root, task_key)
113
+ task_root = Path(identity["taskRoot"])
114
+ plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
115
+ if not plan_run_root.is_dir():
116
+ raise StateError(
117
+ f"this task has no implementation-planning run at {plan_run_root} — "
118
+ "there is no Stage Map to close a stage of",
119
+ stage="plan-run",
120
+ )
121
+
122
+ try:
123
+ stage_map = load_latest_plan_stage_map(task_root)
124
+ except StageMapError as exc:
125
+ raise StateError(str(exc), stage=exc.code) from exc
126
+ known = sorted(
127
+ row["stage_number"] for row in stage_map.stages
128
+ if isinstance(row, dict) and isinstance(row.get("stage_number"), int)
129
+ )
130
+ if stage not in known:
131
+ raise StateError(
132
+ f"stage {stage} is not in this task's Stage Map (has {known or 'none'})",
133
+ stage="stage-map",
134
+ )
135
+
136
+ recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root))
137
+ if recorded.get(stage) in _SETTLED:
138
+ raise StateError(
139
+ f"stage {stage} is already recorded {recorded[stage]!r} — a lead's "
140
+ "ruling is not rewritten here",
141
+ stage="consumers",
142
+ )
143
+
144
+ if not _git_commit_exists(project_root, head_commit):
145
+ raise StateError(
146
+ f"{head_commit} is not a commit in {project_root} — pass the commit "
147
+ "the stage's work actually landed as",
148
+ stage="git",
149
+ )
150
+
151
+ ok, conformance = _conformance_state(task_root, stage)
152
+ if not ok:
153
+ raise StateError(
154
+ f"stage {stage} conformance does not permit closing it "
155
+ f"({conformance}) — run the stage's conformance script, or record a "
156
+ 'user waiver with prepare `--qa-waiver "<stageKey>:<reason>"`, '
157
+ "before closing the stage",
158
+ stage="conformance",
159
+ )
160
+
161
+ append_consumer(
162
+ plan_run_root,
163
+ impl_task_key=identity["taskKey"],
164
+ stage=stage,
165
+ status="done",
166
+ head_commit=head_commit,
167
+ closed_by="stage-close",
168
+ )
169
+ return {
170
+ "taskKey": identity["taskKey"],
171
+ "taskRoot": str(task_root),
172
+ "stage": stage,
173
+ "headCommit": head_commit,
174
+ "conformance": conformance,
175
+ "consumersPath": str(plan_run_root / "consumers.jsonl"),
176
+ }
177
+
178
+
179
+ _CLI_EPILOG = r"""Usage:
180
+ okstra stage-close <task-key> --stage <N> --from-commit <sha>
181
+ okstra stage-close <task-key> --stage <N> --from-commit <sha> --project <dir>
182
+
183
+ Records the `done` row an implementation run would have written, for a stage
184
+ whose work is already committed but which never registered as done (the run
185
+ ended before writing its carry sidecar). Refuses unless the Stage Map has that
186
+ stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a
187
+ commit in the project repo, and the stage's conformance gate permits progress.
188
+
189
+ Output: JSON { ok, taskKey, taskRoot, stage, headCommit, conformance,
190
+ consumersPath }. Exit 1 on a refusal (the reason names what to do), 2 when
191
+ PROJECT_ROOT cannot be resolved.
192
+ """
193
+
194
+
195
+ def main(argv: list[str] | None = None) -> int:
196
+ parser = argparse.ArgumentParser(
197
+ description="Close an already-landed implementation stage as done.",
198
+ epilog=_CLI_EPILOG,
199
+ formatter_class=argparse.RawDescriptionHelpFormatter,
200
+ prog="okstra stage-close")
201
+ parser.add_argument("task_key", metavar="task-key",
202
+ help="project-id:task-group:task-id")
203
+ parser.add_argument("--stage", type=int, required=True,
204
+ help="the Stage Map number to close")
205
+ parser.add_argument("--from-commit", required=True, dest="from_commit",
206
+ help="the commit the stage's work landed as")
207
+ parser.add_argument("--project-root", "--project", default="",
208
+ help="use this directory as PROJECT_ROOT")
209
+ parser.add_argument("--cwd", default=".",
210
+ help="resolve PROJECT_ROOT starting from here")
211
+ args = parser.parse_args(argv)
212
+
213
+ def emit(payload: dict) -> None:
214
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
215
+
216
+ try:
217
+ resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
218
+ except ResolverError as exc:
219
+ emit({"ok": False, "stage": "resolve", "reason": str(exc)})
220
+ return 2
221
+
222
+ if args.stage < 1:
223
+ emit({"ok": False, "stage": "stage-map",
224
+ "reason": f"--stage must be a positive integer, got {args.stage}"})
225
+ return 1
226
+
227
+ try:
228
+ closed = close_stage(
229
+ Path(resolved), args.task_key, args.stage, args.from_commit.strip(),
230
+ )
231
+ except StateError as exc:
232
+ emit({
233
+ "ok": False,
234
+ "stage": getattr(exc, "stage", None) or "stage-close",
235
+ "reason": str(exc),
236
+ })
237
+ return 1
238
+
239
+ emit({"ok": True, **closed})
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ raise SystemExit(main())
@@ -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 "