okstra 0.191.2 → 0.192.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 +2 -2
- package/docs/cli.md +2 -1
- package/docs/project-structure-overview.md +3 -1
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +4 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +7 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/prompts/wizard/prompts.ko.json +52 -23
- package/runtime/python/okstra_ctl/convergence.py +63 -1
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
- package/runtime/python/okstra_ctl/next_phase.py +18 -8
- package/runtime/python/okstra_ctl/plan_items.py +6 -4
- package/runtime/python/okstra_ctl/report_finalize.py +57 -10
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
- package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
- package/runtime/python/okstra_ctl/wizard/state.py +39 -27
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +2 -2
|
@@ -34,6 +34,8 @@ class WizardState:
|
|
|
34
34
|
role_counts: dict[str, int] = field(default_factory=dict)
|
|
35
35
|
role_models: dict[str, list[str]] = field(default_factory=dict)
|
|
36
36
|
role_selection_order: list[str] = field(default_factory=list)
|
|
37
|
+
# 체크박스 화면에서 "직접 선택" 을 골라 전체 후보 화면을 기다리는 역할들.
|
|
38
|
+
role_models_custom: list[str] = field(default_factory=list)
|
|
37
39
|
|
|
38
40
|
# bootstrap
|
|
39
41
|
workspace_root: str = ""
|
|
@@ -212,32 +214,43 @@ class Prompt:
|
|
|
212
214
|
막는다.
|
|
213
215
|
"""
|
|
214
216
|
values = [option.value for option in self.options]
|
|
215
|
-
if PICK_TYPE_CUSTOM
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
if PICK_TYPE_CUSTOM in values:
|
|
218
|
+
after = values[values.index(PICK_TYPE_CUSTOM) + 1:]
|
|
219
|
+
offenders = [value for value in after if value != _ABORT_OPTION]
|
|
220
|
+
if offenders:
|
|
221
|
+
raise WizardError(
|
|
222
|
+
f"wizard step {self.step!r}: the free-input option "
|
|
223
|
+
f"({PICK_TYPE_CUSTOM!r}) must come after every real choice — "
|
|
224
|
+
f"only {_ABORT_OPTION!r} may follow it, but {offenders} do"
|
|
225
|
+
)
|
|
226
|
+
# 추천 불변식은 탈출구가 없는 목록에도 적용된다.
|
|
225
227
|
self._check_recommendations()
|
|
226
228
|
|
|
227
229
|
def _check_recommendations(self) -> None:
|
|
228
|
-
"""추천은
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
"""단일 선택의 추천은 정확히 하나이고 1번이다. 탈출구는 추천이 아니다.
|
|
231
|
+
|
|
232
|
+
실측(2026-09-09, task 선택 화면): 남은 task 세 줄이 전부 `(추천)` 을
|
|
233
|
+
달고 나왔고, 리드는 산문에서 2번을 권했다. 추천이 여럿이면 라벨은
|
|
234
|
+
아무것도 고르지 않은 것이고, 추천이 1번이 아니면 사용자는 목록을
|
|
235
|
+
끝까지 읽어야 추천을 찾는다. 체크박스(`multi`)는 추천이 기본 선택
|
|
236
|
+
집합이라 여럿일 수 있되 앞머리에 모여 있다. 그리고 `직접 입력` /
|
|
237
|
+
`중단` 은 앞의 선택지가 전부 맞지 않을 때의 탈출구이므로 추천 대상이
|
|
238
|
+
될 수 없다.
|
|
233
239
|
"""
|
|
234
240
|
flags = [option.recommended for option in self.options]
|
|
235
|
-
if any(flags)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
if any(flags):
|
|
242
|
+
if self.multi:
|
|
243
|
+
if any(flags[index] for index in range(1, len(flags))
|
|
244
|
+
if not flags[index - 1]):
|
|
245
|
+
raise WizardError(
|
|
246
|
+
f"wizard step {self.step!r}: recommended options must "
|
|
247
|
+
"be the leading run of the list"
|
|
248
|
+
)
|
|
249
|
+
elif sum(flags) != 1 or not flags[0]:
|
|
250
|
+
raise WizardError(
|
|
251
|
+
f"wizard step {self.step!r}: a single-select step carries "
|
|
252
|
+
"exactly one recommendation and it is the first option"
|
|
253
|
+
)
|
|
241
254
|
escapes = {PICK_TYPE_CUSTOM, _ABORT_OPTION}
|
|
242
255
|
marked = [
|
|
243
256
|
option.value for option in self.options
|
|
@@ -436,8 +449,6 @@ def _convert_v1_provider_selections(
|
|
|
436
449
|
)
|
|
437
450
|
if cross_requirement.min_count < cross_requirement.max_count:
|
|
438
451
|
state.role_counts[cross_requirement.role] = selected_count
|
|
439
|
-
count_id = f"role-count:{cross_requirement.role}"
|
|
440
|
-
state.role_selection_order.append(count_id)
|
|
441
452
|
if not providers:
|
|
442
453
|
providers = [
|
|
443
454
|
model.provider_id
|
|
@@ -458,9 +469,9 @@ def _convert_v1_provider_selections(
|
|
|
458
469
|
if model_ref is None:
|
|
459
470
|
break
|
|
460
471
|
state.role_models.setdefault(cross_requirement.role, []).append(model_ref)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
)
|
|
472
|
+
if cross_requirement.role in state.role_models:
|
|
473
|
+
# 여러 인스턴스 역할의 답은 체크박스 한 장이다.
|
|
474
|
+
state.role_selection_order.append(f"role-models:{cross_requirement.role}")
|
|
464
475
|
|
|
465
476
|
provider_rows = (
|
|
466
477
|
(
|
|
@@ -572,7 +583,7 @@ def _validate_v2_state_fields(data: dict[str, Any]) -> None:
|
|
|
572
583
|
|
|
573
584
|
|
|
574
585
|
def _is_role_selection_step(step_id: str) -> bool:
|
|
575
|
-
return step_id.startswith(("role-
|
|
586
|
+
return step_id.startswith(("role-model:", "role-models:", "role-models-custom:"))
|
|
576
587
|
|
|
577
588
|
|
|
578
589
|
def _discard_implicit_leader_selection(state: WizardState) -> None:
|
|
@@ -661,6 +672,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
661
672
|
"executor": "", "critic": "", "critic_pending_text": False,
|
|
662
673
|
"execution_identity_version": 1,
|
|
663
674
|
"role_counts": {}, "role_models": {}, "role_selection_order": [],
|
|
675
|
+
"role_models_custom": [],
|
|
664
676
|
"reuse_previous": None,
|
|
665
677
|
"use_defaults": None, "workers_override": "",
|
|
666
678
|
"workers_custom_pending": False,
|
|
@@ -129,6 +129,28 @@ def _next_phase_cell(raw: Any) -> str:
|
|
|
129
129
|
return f"{cell} ({pointer['status']})"
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def _recommended_task_entry(remaining: list[dict], latest_key: str) -> dict | None:
|
|
133
|
+
"""남은 task 중 이 run 이 권하는 하나.
|
|
134
|
+
|
|
135
|
+
포인터가 `ready` 인 task 는 지금 바로 다음 phase 를 시작할 수 있는 task 다
|
|
136
|
+
— 그중 최신(카탈로그는 updatedAt 내림차순)을 권한다. `ready` 가 없으면
|
|
137
|
+
마지막으로 만진 task(`latest-task.json`)를, 그것도 목록에 없으면 최신
|
|
138
|
+
task 를 권한다. 실측(2026-09-09): 세 줄 전부가 `(추천)` 이었고 리드는
|
|
139
|
+
산문에서 2번을 권했다 — 추천이 여럿이면 라벨은 아무것도 고르지 않은
|
|
140
|
+
것이다.
|
|
141
|
+
"""
|
|
142
|
+
if not remaining:
|
|
143
|
+
return None
|
|
144
|
+
for entry in remaining:
|
|
145
|
+
pointer = next_phase.promote(entry.get("nextRecommendedPhase"))
|
|
146
|
+
if pointer["status"] == next_phase.STATUS_READY:
|
|
147
|
+
return entry
|
|
148
|
+
for entry in remaining:
|
|
149
|
+
if latest_key and entry.get("taskKey") == latest_key:
|
|
150
|
+
return entry
|
|
151
|
+
return remaining[0]
|
|
152
|
+
|
|
153
|
+
|
|
132
154
|
def _build_task_pick(state: WizardState) -> Prompt:
|
|
133
155
|
t = _p(state.workspace_root, "task_pick")
|
|
134
156
|
project_root = Path(state.project_root)
|
|
@@ -137,8 +159,14 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
137
159
|
latest_key = latest.get("taskKey") or ""
|
|
138
160
|
latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
|
|
139
161
|
remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"]
|
|
162
|
+
# 추천은 하나이고 1번이다. 나머지는 최신순 그대로.
|
|
163
|
+
recommended = _recommended_task_entry(remaining, latest_key)
|
|
164
|
+
ordered = (
|
|
165
|
+
[recommended, *(e for e in remaining if e is not recommended)]
|
|
166
|
+
if recommended is not None else remaining
|
|
167
|
+
)
|
|
140
168
|
options: list[Option] = []
|
|
141
|
-
for entry in
|
|
169
|
+
for entry in ordered[:_recommendation_budget(state)]:
|
|
142
170
|
key = entry.get("taskKey") or ""
|
|
143
171
|
ttype = entry.get("taskType") or ""
|
|
144
172
|
# catalog entries are flat (render_task_catalog_discovery) — there is
|
|
@@ -147,7 +175,8 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
147
175
|
nxt = _next_phase_cell(entry.get("nextRecommendedPhase"))
|
|
148
176
|
suffix = latest_suffix if key == latest_key else ""
|
|
149
177
|
label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
|
|
150
|
-
options.append(_opt(value=key, label=label,
|
|
178
|
+
options.append(_opt(value=key, label=label,
|
|
179
|
+
recommended=entry is recommended))
|
|
151
180
|
for value, label in _static_options(t):
|
|
152
181
|
options.append(_opt(value=value, label=label))
|
|
153
182
|
return Prompt(step=S_TASK_PICK, kind="pick",
|
|
@@ -335,9 +364,11 @@ def _build_task_group(state: WizardState) -> Prompt:
|
|
|
335
364
|
t = _p(state.workspace_root, "task_group_no_suggestion")
|
|
336
365
|
recent_prefix = t.get("recent_label_prefix", "")
|
|
337
366
|
options: list[Option] = []
|
|
338
|
-
|
|
367
|
+
# 추천은 하나뿐이다: 가장 최근에 task·brief 활동이 있던 group — 새 task 는
|
|
368
|
+
# 진행 중인 작업 흐름에 속할 가능성이 가장 크다. 나머지는 후보다.
|
|
369
|
+
for index, tg in enumerate(recent):
|
|
339
370
|
options.append(_opt(f"{_RECENT_PREFIX}{tg}", f"{recent_prefix}{tg}",
|
|
340
|
-
recommended=
|
|
371
|
+
recommended=index == 0))
|
|
341
372
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
342
373
|
return Prompt(
|
|
343
374
|
step=S_TASK_GROUP, kind="pick",
|
|
@@ -410,9 +441,11 @@ def _build_task_id(state: WizardState) -> Prompt:
|
|
|
410
441
|
t = _p(state.workspace_root, "task_id_no_suggestion")
|
|
411
442
|
recent_prefix = t.get("recent_label_prefix", "")
|
|
412
443
|
options: list[Option] = []
|
|
444
|
+
# 새 task 의 id 로 같은 group 의 기존 id 를 권할 근거는 없다 — 후보로만
|
|
445
|
+
# 싣고 추천은 비운다. 추천이 있는 경우는 brief frontmatter 의 제안뿐이고,
|
|
446
|
+
# 그것은 위의 `task_id_with_suggestion` 분기다.
|
|
413
447
|
for tid in recent:
|
|
414
|
-
options.append(_opt(f"{_RECENT_PREFIX}{tid}", f"{recent_prefix}{tid}"
|
|
415
|
-
recommended=True))
|
|
448
|
+
options.append(_opt(f"{_RECENT_PREFIX}{tid}", f"{recent_prefix}{tid}"))
|
|
416
449
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
417
450
|
return Prompt(
|
|
418
451
|
step=S_TASK_ID, kind="pick",
|
|
@@ -713,12 +746,21 @@ def _build_brief_path_pick(state: WizardState) -> Prompt:
|
|
|
713
746
|
def add(value: str, label: str) -> None:
|
|
714
747
|
if len(options) >= budget:
|
|
715
748
|
return
|
|
716
|
-
|
|
749
|
+
# 첫 후보만 추천이다: 이 task 의 기존 brief → (있으면) 표준 경로의
|
|
750
|
+
# brief → 그룹 시작 순서상 첫 미착수 brief. 각각이 그 자리에서 가장
|
|
751
|
+
# 타당한 입력이고, 그 뒤 줄들은 대안이다.
|
|
752
|
+
options.append(_opt(value, label, recommended=not options))
|
|
717
753
|
|
|
718
754
|
if existing:
|
|
719
755
|
add("__existing__",
|
|
720
756
|
t["options"]["__existing__"].format(existing=existing))
|
|
721
|
-
|
|
757
|
+
# 표준 경로는 파일이 실제로 있을 때만 후보다. 없는 파일을 고르면
|
|
758
|
+
# `_require_file` 이 거절하므로, 그 줄은 선택지가 아니라 함정이고 추천은
|
|
759
|
+
# 더더욱 아니다.
|
|
760
|
+
if (
|
|
761
|
+
standard and standard != existing
|
|
762
|
+
and (Path(state.project_root) / standard).is_file()
|
|
763
|
+
):
|
|
722
764
|
add("__standard__",
|
|
723
765
|
t["options"]["__standard__"].format(standard=standard))
|
|
724
766
|
brief_label = t["labels"].get("brief_candidate", "{path}")
|
|
@@ -293,6 +293,7 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
|
|
|
293
293
|
conversion_payload.setdefault("kimiModel", state.kimi_model)
|
|
294
294
|
state.role_counts.clear()
|
|
295
295
|
state.role_models.clear()
|
|
296
|
+
state.role_models_custom.clear()
|
|
296
297
|
state.role_selection_order.clear()
|
|
297
298
|
_convert_v1_provider_selections(state, conversion_payload)
|
|
298
299
|
directive = inputs.get("directive")
|
|
@@ -61,7 +61,7 @@ The final `confirm` step is a normal `pick` step with three options — `Proceed
|
|
|
61
61
|
|
|
62
62
|
Never invent additional questions. **Never drop, hide, merge, reorder, or truncate** a `pick` / `pick_group` option — relay every `options[]` entry, including entries that carry a `(default)` / `(recommended)` suffix. Do not collapse a multi-option pick into a "recommended + Enter directly / Other" shortlist. The wizard's arrays are the complete authoritative choice sets, regardless of the current host UI's usual option limit. The run-prompt recommendation rule (1–2 recommendations + Enter directly) shapes the **option set** only for prompts this skill authors itself, never for wizard-provided options — you may not add, drop, or reorder a wizard option to produce a shortlist. It does not excuse you from recommending: before relaying a wizard step whose answer turns on something readable (the carried report, the sidecar the user already wrote, the prior Stage Map), read it, put what you found in the question body, and name which of the wizard's own options you recommend and why. Relaying a step with no context and no recommendation hands the whole question back to the user — see the lifecycle core contract "Asking the user (BLOCKING)".
|
|
63
63
|
|
|
64
|
-
**One recommendation, shown in one place.**
|
|
64
|
+
**One recommendation, shown in one place, and it is option 1.** The option carrying `recommended: true` is what this run computed, and the wizard already placed it first — append ` (추천)` to that option's label when you render it, and to no other. A checkbox step (`multi: true`) may flag several leading options: they are the recommended set, rendered the same way. Your prose recommendation names one of the flagged options. If you believe a different option is right, do not quietly recommend it in prose while the flagged one still reads as recommended on screen: say you disagree, name both, and let the user pick. When no option carries the flag, the run computed nothing for this step — recommend one from what you read and mark that one, and do not present the first option as a default just because it is first. **Enforced:** `scripts/okstra_ctl/wizard/state.py` `Prompt.__post_init__` refuses a step whose free-input option is not last, whose single-select recommendation is not exactly one option placed first (a checkbox step's recommended options must be its leading run), or that marks the free-input / abort escape as a recommendation.
|
|
65
65
|
|
|
66
66
|
## Step 1: Preflight
|
|
67
67
|
|
|
@@ -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:
|
|
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, the recommended set is flagged, and the last row (`직접 선택`) opens `role-models-custom:<role>` with every executable candidate. 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,
|
|
@@ -8108,8 +8108,8 @@ def _validate_translation_sidecar(
|
|
|
8108
8108
|
failures.append(
|
|
8109
8109
|
f"final-report has reportLanguage {lang!r} but no translation "
|
|
8110
8110
|
f"sidecar at {sidecar.name}. The human HTML rendered from the "
|
|
8111
|
-
"English source instead; re-run `report-finalize --only "
|
|
8112
|
-
"render-views`
|
|
8111
|
+
"English source instead; re-run `report-finalize --only translate "
|
|
8112
|
+
"--only render-views` to dispatch the translator and overlay it."
|
|
8113
8113
|
)
|
|
8114
8114
|
|
|
8115
8115
|
|