okstra 0.195.3 → 0.196.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +5 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/cmux.py +33 -2
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -1
- package/runtime/python/okstra_ctl/run.py +22 -0
- package/runtime/python/okstra_ctl/session_transcript.py +16 -8
- package/runtime/python/okstra_ctl/wizard/engine.py +33 -10
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +82 -5
- package/runtime/python/okstra_ctl/wizard/roles.py +6 -4
- package/runtime/skills/okstra-run/SKILL.md +1 -1
package/docs/architecture.md
CHANGED
|
@@ -99,7 +99,7 @@ The host-native Okstra lead owns judgment policy and worker orchestration. okstr
|
|
|
99
99
|
|
|
100
100
|
Canonical roles are `leader`, `analyser`, `critic`, `designer`, `planner`, `implementer`, `verifier`, `report-writer`, and `translator`. `lead` is a compatibility alias for `leader`. `executor` is a compatibility alias for `implementer`. New artifacts write only the canonical names.
|
|
101
101
|
|
|
102
|
-
Selection is role-first: `--role-count <role>=<N>` creates `RoleInstance` ordinals, `--role-model <role>=<modelRef>` and `modelDefaults` feed `ModelPool`, and a pinned model is kept only when the host can bind it exactly. The resulting `RoleExecution` owns `Invocation` and `Attempt` rows plus the stored `executionLabel`.
|
|
102
|
+
Selection is role-first: `--role-count <role>=<N>` creates `RoleInstance` ordinals, `--role-model <role>=<modelRef>` and `modelDefaults` feed `ModelPool`, and a pinned model is kept only when the host can bind it exactly. The resulting `RoleExecution` owns `Invocation` and `Attempt` rows plus the stored `executionLabel`. Worker pane titles use that label. The lead's own cmux surface is titled `<task-group>/<task-id>` by `prepare_task_bundle` (`run.py` `_title_lead_pane`, cmux backend only): prepare runs in the lead's pane, so the calling surface from `cmux identify` is the lead's. A rename cmux refuses is a stderr line, not a failed prepare. Shared Git object stores and other-stage refs are observed-projection only; they are not an audit enforcement surface.
|
|
103
103
|
|
|
104
104
|
## Runtime assets vs support assets
|
|
105
105
|
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -151,6 +151,11 @@ class CapabilityInteractionPort:
|
|
|
151
151
|
"""
|
|
152
152
|
return self._limits.max_options
|
|
153
153
|
|
|
154
|
+
@property
|
|
155
|
+
def native_question_limit(self) -> int:
|
|
156
|
+
"""이 호스트의 네이티브 질문 묶음이 한 화면에 받는 질문 수."""
|
|
157
|
+
return self._limits.max_questions
|
|
158
|
+
|
|
154
159
|
def _native_options_fit(self, prompt: WizardPrompt) -> bool:
|
|
155
160
|
labels = tuple(option.label for option in prompt.options)
|
|
156
161
|
return (
|
|
@@ -93,7 +93,9 @@ class CodexExecution:
|
|
|
93
93
|
"""
|
|
94
94
|
|
|
95
95
|
def build_command(self, request: WorkerExecRequest) -> ExecCommand:
|
|
96
|
-
|
|
96
|
+
# 출력은 파이프로 수집하므로 auto 는 색상을 끈다. 화면 표시와 기록의
|
|
97
|
+
# 색상 제거 여부는 공통 세션 기록기가 목적지에 맞춰 결정한다.
|
|
98
|
+
argv = ["codex", "exec", "--color", "always", "-C", str(request.project_root)]
|
|
97
99
|
for directory in request.policy.write_scope:
|
|
98
100
|
if directory != request.project_root:
|
|
99
101
|
argv += ["--add-dir", str(directory)]
|
|
@@ -421,7 +421,7 @@ def worker_command_line(
|
|
|
421
421
|
"""
|
|
422
422
|
return (
|
|
423
423
|
f"cd {shlex.quote(str(cwd))} && "
|
|
424
|
-
f"PATH={shlex.quote(path_value)} exec {shlex.join(argv)}"
|
|
424
|
+
f"PATH={shlex.quote(path_value)} FORCE_COLOR=1 exec {shlex.join(argv)}"
|
|
425
425
|
)
|
|
426
426
|
|
|
427
427
|
|
|
@@ -456,7 +456,7 @@ def spawn_worker_surface(
|
|
|
456
456
|
)
|
|
457
457
|
target = _pane_by_id(panes, placement.pane_id)
|
|
458
458
|
surface_uuid = _open_worker_surface(workspace, placement, target)
|
|
459
|
-
|
|
459
|
+
rename_surface(surface_uuid, title)
|
|
460
460
|
_exec_worker(surface_uuid, cwd=cwd, command=command)
|
|
461
461
|
owned = (*owned_surface_ids, surface_uuid)
|
|
462
462
|
_size_lead_pane(workspace, owned)
|
|
@@ -464,6 +464,37 @@ def spawn_worker_surface(
|
|
|
464
464
|
return surface_uuid
|
|
465
465
|
|
|
466
466
|
|
|
467
|
+
def rename_surface(surface: str, title: str) -> subprocess.CompletedProcess[str]:
|
|
468
|
+
"""Set the tab title cmux shows for one surface.
|
|
469
|
+
|
|
470
|
+
`surface` is a UUID for the worker surfaces okstra opened and a short ref
|
|
471
|
+
(`surface:N`) for the caller's own surface; cmux accepts both.
|
|
472
|
+
"""
|
|
473
|
+
return run_cmux(["rename-tab", "--surface", surface, "--title", title])
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def rename_lead_surface(title: str) -> str:
|
|
477
|
+
"""Title the surface this process runs in; "" on success, else the reason.
|
|
478
|
+
|
|
479
|
+
The lead is whatever pane invoked okstra, so the surface comes from
|
|
480
|
+
`identify` rather than from anything okstra recorded. A failure is returned
|
|
481
|
+
instead of raised: the title is a courtesy for the person watching the
|
|
482
|
+
workspace, and prepare has already written every manifest by the time it
|
|
483
|
+
is applied.
|
|
484
|
+
"""
|
|
485
|
+
surface = identify_caller().get("surface_ref", "")
|
|
486
|
+
if not surface:
|
|
487
|
+
return "cmux could not identify the calling surface"
|
|
488
|
+
try:
|
|
489
|
+
result = rename_surface(surface, title)
|
|
490
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
491
|
+
return f"cmux rename-tab failed: {exc}"
|
|
492
|
+
if result.returncode != 0:
|
|
493
|
+
detail = (result.stderr or result.stdout).strip() or f"exit {result.returncode}"
|
|
494
|
+
return f"cmux rename-tab failed: {detail}"
|
|
495
|
+
return ""
|
|
496
|
+
|
|
497
|
+
|
|
467
498
|
def close_surface(surface_uuid: str) -> None:
|
|
468
499
|
"""Close an okstra-created surface, killing whatever still runs inside it."""
|
|
469
500
|
try:
|
|
@@ -13,6 +13,7 @@ CLI 는 갈라 읽어야 한다.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
|
+
import re
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
|
|
@@ -34,6 +35,11 @@ ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
|
34
35
|
ObserveUsage = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
|
35
36
|
|
|
36
37
|
WORKER = "worker"
|
|
38
|
+
_TERMINAL_COLORS = re.compile(r"\x1b\[[0-9;:]*m")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strip_terminal_colors(text: str) -> str:
|
|
42
|
+
return _TERMINAL_COLORS.sub("", text)
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
class TranscriptWriter(Protocol):
|
|
@@ -93,7 +99,7 @@ class SplitText:
|
|
|
93
99
|
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
94
100
|
def result(line: str) -> str | None:
|
|
95
101
|
writer.write(WORKER, line)
|
|
96
|
-
return line
|
|
102
|
+
return strip_terminal_colors(line)
|
|
97
103
|
|
|
98
104
|
def progress(line: str) -> str | None:
|
|
99
105
|
writer.write(WORKER, line)
|
|
@@ -4563,6 +4563,25 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
|
|
|
4563
4563
|
) if part))
|
|
4564
4564
|
|
|
4565
4565
|
|
|
4566
|
+
def lead_pane_title(task_group: str, task_id: str) -> str:
|
|
4567
|
+
"""cmux 에서 리드 pane 에 붙는 제목. 사용자가 여러 task 의 pane 을 구분하는
|
|
4568
|
+
이름이므로 slug 가 아니라 입력한 task-group / task-id 그대로 쓴다."""
|
|
4569
|
+
return f"{task_group}/{task_id}"
|
|
4570
|
+
|
|
4571
|
+
|
|
4572
|
+
def _title_lead_pane(inp: PrepareInputs) -> None:
|
|
4573
|
+
"""리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
|
|
4574
|
+
|
|
4575
|
+
prepare 는 리드 세션(또는 리드를 띄울 pane)에서 실행되므로 호출 surface 가
|
|
4576
|
+
곧 리드 pane 이다. 제목은 화면 편의라 실패해도 run 을 막지 않지만, 무엇이
|
|
4577
|
+
막았는지는 stderr 에 남긴다 — codex 샌드박스가 cmux 소켓을 EPERM 으로 막는
|
|
4578
|
+
경우가 실제로 있다(`_resolve_terminal_backend` 참조).
|
|
4579
|
+
"""
|
|
4580
|
+
reason = cmux.rename_lead_surface(lead_pane_title(inp.task_group, inp.task_id))
|
|
4581
|
+
if reason:
|
|
4582
|
+
print(f"okstra: lead pane title not applied — {reason}", file=sys.stderr)
|
|
4583
|
+
|
|
4584
|
+
|
|
4566
4585
|
def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
4567
4586
|
"""Produce a complete okstra task bundle on disk. See module docstring."""
|
|
4568
4587
|
workspace_root = Path(inp.workspace_root)
|
|
@@ -4872,6 +4891,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4872
4891
|
|
|
4873
4892
|
_record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
|
|
4874
4893
|
|
|
4894
|
+
if terminal_backend == BACKEND_CMUX_PANE:
|
|
4895
|
+
_title_lead_pane(inp)
|
|
4896
|
+
|
|
4875
4897
|
if not inp.render_only:
|
|
4876
4898
|
_provision_settings_symlink(inp)
|
|
4877
4899
|
|
|
@@ -11,8 +11,9 @@ from datetime import datetime
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Callable
|
|
13
13
|
|
|
14
|
+
from .domain.worker_presentation import strip_terminal_colors
|
|
15
|
+
|
|
14
16
|
OKSTRA = "okstra"
|
|
15
|
-
_SPEAKER_WIDTH = 14
|
|
16
17
|
_RESET = "\x1b[0m"
|
|
17
18
|
_MUTED = "\x1b[90m"
|
|
18
19
|
_LIVE_COLORS = (
|
|
@@ -50,11 +51,20 @@ class SessionTranscript:
|
|
|
50
51
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
52
|
self._file = path.open("w", encoding="utf-8")
|
|
52
53
|
self._live = live
|
|
54
|
+
# cmux 워커는 색상 사용을 명시한다. 리드에서 상속한 비대화형 출력
|
|
55
|
+
# 설정이 워커 터미널의 색상까지 끄지 않도록 명시적 요청을 우선한다.
|
|
56
|
+
force_color = os.environ.get("FORCE_COLOR", "")
|
|
53
57
|
self._color = (
|
|
54
58
|
live
|
|
55
59
|
and sys.stdout.isatty()
|
|
56
|
-
and
|
|
57
|
-
|
|
60
|
+
and (
|
|
61
|
+
force_color not in ("", "0")
|
|
62
|
+
or (
|
|
63
|
+
force_color != "0"
|
|
64
|
+
and not os.environ.get("NO_COLOR")
|
|
65
|
+
and os.environ.get("TERM") != "dumb"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
58
68
|
)
|
|
59
69
|
self._clock = clock
|
|
60
70
|
self._archived = 0
|
|
@@ -89,9 +99,7 @@ class SessionTranscript:
|
|
|
89
99
|
self._keep(self._row(speaker, line), capped=True)
|
|
90
100
|
|
|
91
101
|
def _row(self, speaker: str, line: str) -> str:
|
|
92
|
-
|
|
93
|
-
# `[worker:grok]`(13칸) 줄이 두 칸이 되고 `[okstra]` 정렬이 깨진다.
|
|
94
|
-
label = f"[{speaker}]".ljust(_SPEAKER_WIDTH)
|
|
102
|
+
label = f"[{speaker}] "
|
|
95
103
|
return f"{self._clock()} {label}{line}".rstrip()
|
|
96
104
|
|
|
97
105
|
def _show(self, row: str, line: str) -> None:
|
|
@@ -110,7 +118,7 @@ class SessionTranscript:
|
|
|
110
118
|
f"{_MUTED}{row[:prefix_size]}{_RESET}"
|
|
111
119
|
f"{color}{row[prefix_size:]}{_RESET}"
|
|
112
120
|
)
|
|
113
|
-
print(row, flush=True)
|
|
121
|
+
print(row if self._color else strip_terminal_colors(row), flush=True)
|
|
114
122
|
|
|
115
123
|
def _keep(self, row: str, *, capped: bool) -> None:
|
|
116
124
|
if not capped:
|
|
@@ -136,7 +144,7 @@ class SessionTranscript:
|
|
|
136
144
|
self._file.close()
|
|
137
145
|
|
|
138
146
|
def _append(self, row: str) -> None:
|
|
139
|
-
self._file.write(row + "\n")
|
|
147
|
+
self._file.write(strip_terminal_colors(row) + "\n")
|
|
140
148
|
self._file.flush()
|
|
141
149
|
|
|
142
150
|
def _note_elision(self) -> None:
|
|
@@ -33,7 +33,13 @@ from .ids import (
|
|
|
33
33
|
)
|
|
34
34
|
from .state import Prompt, WizardError, WizardState, _is_role_selection_step
|
|
35
35
|
from .prompts import _domain_prompt
|
|
36
|
-
from .picker_navigation import
|
|
36
|
+
from .picker_navigation import (
|
|
37
|
+
accept_picker_answer,
|
|
38
|
+
is_split_checkbox,
|
|
39
|
+
merge_split_checkbox_answer,
|
|
40
|
+
present_picker,
|
|
41
|
+
split_checkbox,
|
|
42
|
+
)
|
|
37
43
|
from .roles import _submit_role_prompt, next_role_prompt
|
|
38
44
|
from .steps_identity import _submit_task_pick
|
|
39
45
|
from .steps_analysis import _advance_design_prep_item
|
|
@@ -135,12 +141,14 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
|
135
141
|
"""호스트 네이티브 선택기 한도에 맞춘 화면.
|
|
136
142
|
|
|
137
143
|
단일 선택이 한도를 넘으면 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는
|
|
138
|
-
나누지 않는다 —
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
목록을 한 번에 보인다.
|
|
144
152
|
"""
|
|
145
153
|
if "native_single_select" not in state.available_functions:
|
|
146
154
|
return prompt
|
|
@@ -148,10 +156,17 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
|
148
156
|
if _interaction_plan(state, prompt).kind == "native-group":
|
|
149
157
|
return prompt
|
|
150
158
|
prompt = prompt.questions[0]
|
|
159
|
+
port = default_host_registry().resolve(state.host_runtime).interaction()
|
|
151
160
|
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
|
|
152
168
|
return prompt
|
|
153
|
-
|
|
154
|
-
return present_picker(state, prompt, limit=limit)
|
|
169
|
+
return present_picker(state, prompt, limit=port.native_option_limit)
|
|
155
170
|
|
|
156
171
|
|
|
157
172
|
def _next_prompt_screen(state: WizardState) -> Prompt:
|
|
@@ -204,6 +219,9 @@ def _sim_answer(prompt: Prompt) -> str:
|
|
|
204
219
|
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
205
220
|
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
206
221
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
222
|
+
if is_split_checkbox(prompt):
|
|
223
|
+
# 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
|
|
224
|
+
prompt = _next_prompt_screen(state)
|
|
207
225
|
try:
|
|
208
226
|
if _is_role_selection_step(prompt.step):
|
|
209
227
|
# 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다 — 기본답은
|
|
@@ -384,7 +402,12 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
384
402
|
value = accept_picker_answer(state, original, value)
|
|
385
403
|
if value is None:
|
|
386
404
|
return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
387
|
-
if prompt
|
|
405
|
+
if is_split_checkbox(prompt):
|
|
406
|
+
# 질문 묶음으로 잘린 체크박스 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의
|
|
407
|
+
# 제출 경로로 보낸다. 원본의 선택지로 검증한다.
|
|
408
|
+
value = merge_split_checkbox_answer(prompt, value)
|
|
409
|
+
prompt = _next_prompt_screen(state)
|
|
410
|
+
elif prompt.kind == "pick_group":
|
|
388
411
|
return _submit_group(state, prompt, value)
|
|
389
412
|
if _is_role_selection_step(prompt.step):
|
|
390
413
|
# 고정 단일 역할의 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수
|
|
@@ -1,16 +1,93 @@
|
|
|
1
|
-
"""호스트 선택기 한도 안에서
|
|
1
|
+
"""호스트 선택기 한도 안에서 원래 선택지를 보존하는 두 가지 강등.
|
|
2
2
|
|
|
3
|
-
체크박스(`multi`)는
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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`).
|
|
7
11
|
"""
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
8
14
|
from dataclasses import replace
|
|
9
15
|
|
|
10
16
|
from .ids import PICK_TYPE_CUSTOM
|
|
11
17
|
from .state import Option, Prompt, WizardError, WizardState
|
|
12
18
|
|
|
13
19
|
_PAGE_PREFIX = "__okstra_picker_page__:"
|
|
20
|
+
_SPLIT_SEPARATOR = "#"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_checkbox(
|
|
24
|
+
prompt: Prompt, *, max_options: int, max_questions: int,
|
|
25
|
+
) -> Prompt:
|
|
26
|
+
"""옵션이 `max_options` 를 넘는 체크박스를 같은 화면의 질문 묶음으로 자른다.
|
|
27
|
+
|
|
28
|
+
질문 수는 옵션이 들어가는 최소 개수이고 옵션은 질문에 고르게 나눈다 —
|
|
29
|
+
마지막 질문이 한 줄짜리가 되면 호스트 최소 옵션 수(2)에 걸려 묶음 전체가
|
|
30
|
+
네이티브에 못 실린다. 추천은 원래 목록의 앞머리 run 이라 어느 조각에서도
|
|
31
|
+
앞머리 run 이 된다(`Prompt._check_recommendations`). `max_questions` 도
|
|
32
|
+
넘으면 자르지 않고 그대로 돌려준다.
|
|
33
|
+
"""
|
|
34
|
+
if prompt.kind != "pick" or not prompt.multi or len(prompt.options) <= max_options:
|
|
35
|
+
return prompt
|
|
36
|
+
count = len(prompt.options)
|
|
37
|
+
questions = math.ceil(count / max_options)
|
|
38
|
+
if questions > max_questions:
|
|
39
|
+
return prompt
|
|
40
|
+
base, extra = divmod(count, questions)
|
|
41
|
+
chunks: list[Prompt] = []
|
|
42
|
+
offset = 0
|
|
43
|
+
for index in range(questions):
|
|
44
|
+
size = base + (1 if index < extra else 0)
|
|
45
|
+
shown = prompt.options[offset:offset + size]
|
|
46
|
+
chunks.append(replace(
|
|
47
|
+
prompt,
|
|
48
|
+
step=f"{prompt.step}{_SPLIT_SEPARATOR}{index + 1}",
|
|
49
|
+
label=f"{prompt.label} ({offset + 1}–{offset + size}/{count})",
|
|
50
|
+
options=shown,
|
|
51
|
+
))
|
|
52
|
+
offset += size
|
|
53
|
+
return Prompt(
|
|
54
|
+
step=prompt.step,
|
|
55
|
+
kind="pick_group",
|
|
56
|
+
label=prompt.label,
|
|
57
|
+
help=prompt.help,
|
|
58
|
+
echo_template=prompt.echo_template,
|
|
59
|
+
questions=chunks,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_split_checkbox(prompt: Prompt) -> bool:
|
|
64
|
+
return prompt.kind == "pick_group" and bool(prompt.questions) and all(
|
|
65
|
+
question.multi
|
|
66
|
+
and question.step.startswith(f"{prompt.step}{_SPLIT_SEPARATOR}")
|
|
67
|
+
for question in prompt.questions
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def merge_split_checkbox_answer(prompt: Prompt, value: str) -> str:
|
|
72
|
+
"""질문 묶음 답(JSON, 조각 step → CSV)을 원래 체크박스의 CSV 한 줄로 합친다."""
|
|
73
|
+
try:
|
|
74
|
+
answers = json.loads(value or "{}")
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise WizardError(f"pick_group answer must be a JSON object: {exc}") from exc
|
|
77
|
+
if not isinstance(answers, dict):
|
|
78
|
+
raise WizardError("pick_group answer must be a JSON object")
|
|
79
|
+
known = {question.step for question in prompt.questions}
|
|
80
|
+
unknown = sorted(set(answers) - known)
|
|
81
|
+
if unknown:
|
|
82
|
+
raise WizardError(
|
|
83
|
+
f"wizard step {prompt.step!r}: answer names unknown questions {unknown}; "
|
|
84
|
+
f"expected {sorted(known)}"
|
|
85
|
+
)
|
|
86
|
+
chosen: list[str] = []
|
|
87
|
+
for question in prompt.questions:
|
|
88
|
+
raw = str(answers.get(question.step, "") or "")
|
|
89
|
+
chosen.extend(item.strip() for item in raw.split(",") if item.strip())
|
|
90
|
+
return ",".join(chosen)
|
|
14
91
|
|
|
15
92
|
|
|
16
93
|
def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
|
|
@@ -10,10 +10,12 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
|
|
|
10
10
|
체크박스 화면(`role-models:<role>`)은 실행 가능한 전체 후보를 한 번에 싣는다.
|
|
11
11
|
기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가 앞이고 권장
|
|
12
12
|
수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도(claude-code 4,
|
|
13
|
-
codex 3, grok 15)를 넘으면
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
|
|
17
19
|
"""
|
|
18
20
|
from __future__ import annotations
|
|
19
21
|
|
|
@@ -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 interaction plan is `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`. 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,
|