okstra 0.209.0 → 0.209.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 (43) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +9 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/duties/business-flow-investigator.json +1 -1
  6. package/runtime/prompts/launch.template.md +1 -1
  7. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +2 -2
  9. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  10. package/runtime/prompts/wizard/prompts.ko.json +15 -14
  11. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +15 -4
  12. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +7 -3
  13. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +8 -4
  14. package/runtime/python/okstra_ctl/agent/activity.py +2 -2
  15. package/runtime/python/okstra_ctl/analysis_packet.py +7 -0
  16. package/runtime/python/okstra_ctl/business_flow/engine.py +2 -2
  17. package/runtime/python/okstra_ctl/business_flow/source.py +35 -2
  18. package/runtime/python/okstra_ctl/cmux.py +93 -25
  19. package/runtime/python/okstra_ctl/convergence.py +29 -10
  20. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
  21. package/runtime/python/okstra_ctl/dispatch_core.py +6 -1
  22. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
  23. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
  24. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
  25. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
  27. package/runtime/python/okstra_ctl/plan_items.py +4 -2
  28. package/runtime/python/okstra_ctl/render.py +4 -0
  29. package/runtime/python/okstra_ctl/report_finalize.py +19 -7
  30. package/runtime/python/okstra_ctl/run.py +15 -1
  31. package/runtime/python/okstra_ctl/team.py +6 -4
  32. package/runtime/python/okstra_ctl/user_response.py +22 -0
  33. package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
  34. package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
  35. package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
  36. package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
  37. package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
  38. package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
  39. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  41. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  42. package/runtime/skills/okstra-run/SKILL.md +3 -3
  43. package/runtime/validators/validate-run.py +9 -2
@@ -275,6 +275,8 @@ The hexagonal rule that an extracted point must declare `interfaceKind: "port"`
275
275
 
276
276
  `P-Dir-1` carries the same YAGNI judgement as the legacy option item, but its comparison source is the selected-direction snapshot rather than a trade-off matrix. A new abstraction, configuration knob, widened interface, file, or stage with no original-requirement link is a hidden direction change and receives `DISAGREE(e)` on `P-Dir-1`.
277
277
 
278
+ Four `P-Dir-1` payload fields — `coreMechanism`, `architectureBoundaries`, `planningInvariants`, `userConstraints` — are verbatim copies of the selected-direction snapshot: report assembly overwrites them from the snapshot (`scripts/okstra_ctl/report_assembly.py`), and `scripts/okstra_ctl/phases/implementation_planning/validation.py` `_direction_realization_errors` rejects any other value. The planner cannot change them, so a mismatch between one of them and a later user decision (an answered clarification) is not a planner-fixable defect in those fields. Judge it against the writer-owned realization fields and the narrative's `supersessionLedger`: DISAGREE when they fail to carry the decision, and never ask for an edit to the four snapshot fields. The rendered `P-Dir-1` block restates this rule (`_DIR_SNAPSHOT_OWNED_NOTE` in `scripts/okstra_ctl/phases/implementation_planning/authoring.py`).
279
+
278
280
  `P-Opt-<N>` carries the **YAGNI judgement** and is majority-gated for the same reason as `P-Var-*`: whether an abstraction serves the stated requirement or only a forecast is a judgement about the design, not a contradiction between two spelled-out references. Raise it as `DISAGREE(e)` — an option that carries an abstraction, parameter, or configuration knob no Requirement Coverage row demands contradicts the trade-off matrix that scored it, because the complexity the matrix priced is not the complexity the option actually buys. DISAGREE on a `P-Opt-*` item under this rule means one of:
279
281
 
280
282
  - **an abstraction nobody asked for** — a helper module, strategy / factory, indirection layer, or interface whose only justification in the plan is a caller no requirement names. A second implementation already on the table is `P-Var-*` territory and is the opposite defect: do not raise both on the same behavior;
@@ -320,7 +322,7 @@ Plan-body verification only supports **lightweight mode** (defined in `prompts/l
320
322
 
321
323
  **Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_config` rejects a `verificationMode` outside `_VERIFICATION_MODES`, and the plan-body round is seeded with `lightweight` rather than the manifest value.
322
324
 
323
- Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement text embedded in the `Requirement Coverage` row with the cited Option / Stage / Step in the draft plan. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the row's stated requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.
325
+ Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement with the cited Option / Stage / Step in the draft plan. A legacy `Requirement Coverage` row embeds that requirement in its own `requirement` field. A selected-direction row carries only `originalRequirementId` and its `*Refs`, so the requirement is the line for that id in the primary analysis packet the prompt lists under `## Inputs`. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.
324
326
 
325
327
  For selected-direction `P-Req-*` rows, `status: externally-tracked` explicitly assigns external responsibility through `crossProjectDependencyRefs`. Verify that each reference resolves to a unique, complete `crossProjectDependencies` row and that its required work and verification signal satisfy the requirement. External-only rows need no local stage/step/file references; mixed rows retain their real local references. Do not classify absence of local references as kind `f` when the external mapping is valid. This is planned responsibility, not proof of implementation or deployment. Dangling or incomplete references are rejected by `implementation_direction._coverage_reference_errors`; semantic adequacy remains the verifier judgement.
326
328
 
@@ -670,25 +672,25 @@ Required prompt anchor headers are identical to finding convergence (see `prompt
670
672
  The `prompts/lead/convergence.md` §"Required reverify output contract"
671
673
  applies unchanged: append it verbatim after the response format below.
672
674
 
673
- The prompt-body contract check runs on every `analysis`-audience prompt, not
674
- just the critic's, so this round needs the same two lines the critic's
675
- instructions need (`prompts/lead/convergence.md` §"What the critic
676
- task-instructions file MUST contain"): a `**Prompt Delivery Mode:**` header and,
677
- under `## Inputs`, exactly one `- Primary analysis packet:` line whose path ends
678
- in `analysis-packet.md`. The literal label and the backticks are what the check
679
- matches — a bare path or a reworded label counts as zero. They are in the
680
- template below; keep them when you fill it in.
675
+ `okstra plan-items prompt` renders the `## Inputs` section itself: a
676
+ `- Primary analysis packet:` line from the run manifest's `analysisPacketPath`
677
+ and a `- Plan narrative:` line from its `reportNarrativePath`. The worker's
678
+ `**Read scope:**` header admits only paths the prompt enumerates, so these lines
679
+ are what let a verifier read the requirement a selected-direction `P-Req-*` row
680
+ names by `originalRequirementId`. The lead does not add them by hand.
681
681
 
682
682
  Carrying the packet does not license re-analysis. It is there so the verifier
683
683
  can resolve a plan item back to the requirement it claims to satisfy; the
684
684
  posture in §"Adversarial plan-body posture" still applies, and this round does
685
685
  not revisit the requirements themselves.
686
686
 
687
- Omitting either line fails `okstra team dispatch --dispatch-kind
688
- plan-verify-r<N> ...` before any process starts, reported as `<task-type>
689
- prompt contract: <worker>: exactly one Primary analysis packet path is required
690
- (found 0)`. Fix the instructions file and re-materialize with
691
- `--replace-undispatched` rather than editing the published prompt.
687
+ **Enforced:** only by the renderer — `_render_inputs` in
688
+ `scripts/okstra_ctl/phases/implementation_planning/authoring.py`, asserted by
689
+ `_assert_input_paths` in `tests/run/test_plan_items.py`. No dispatch check counts
690
+ the packet line for this round: the `Primary analysis packet` count in
691
+ `scripts/okstra_ctl/worker_prompt_contract.py` `_validate_prompt_for_plan` runs
692
+ only for the `analysis` audience, and a plan-verify dispatch is a
693
+ reverification.
692
694
 
693
695
  ````
694
696
  Perform plan-body verification for <task-key> (round 1).
@@ -698,6 +700,7 @@ Perform plan-body verification for <task-key> (round 1).
698
700
  ## Inputs
699
701
 
700
702
  - Primary analysis packet: `<path ending in analysis-packet.md>`
703
+ - Plan narrative: `<run manifest reportNarrativePath>`
701
704
 
702
705
  ## Instructions
703
706
 
@@ -757,8 +760,10 @@ Every verdict requires a non-empty explanation.
757
760
 
758
761
  **Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_vote` rejects a vote whose `explanation` is empty and whose verdict is outside `_VERDICTS`; `classify_collaborative_round` drops `verification-error` votes from both numerator and denominator.
759
762
 
760
- For `P-Req-*` items, compare only the requirement text embedded in the row
761
- against the cited plan item(s). Do not open the original brief, but do reject
763
+ For `P-Req-*` items, compare the requirement against the cited plan item(s):
764
+ the row's own `requirement` text when it has one, otherwise the primary analysis
765
+ packet's line for the row's `originalRequirementId`. Do not open the original
766
+ brief, but do reject
762
767
  coverage rows that cite no concrete option/stage/step or cite a plan item that
763
768
  does not satisfy the row's own requirement. For `documented-deviation`, do not
764
769
  auto-AGREE based on the status token: separately verify the requirement, the
@@ -67,6 +67,7 @@ from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
67
67
 
68
68
 
69
69
  from okstra_ctl.plan_items import ( # noqa: E402
70
+ content_hash,
70
71
  expected_plan_item_ids,
71
72
  extract_plan_items,
72
73
  )
@@ -501,14 +502,14 @@ def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
501
502
  non_result = any(
502
503
  row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes
503
504
  )
505
+ disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
504
506
  if (
505
507
  classification not in {"majority-disagree", "needs-reverify", "all-non-result"}
506
- and not non_result
508
+ and not (non_result and disagrees)
507
509
  ):
508
510
  return None
509
511
  if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
510
512
  return None
511
- disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
512
513
  verified = item.get("contentHash")
513
514
  if (
514
515
  not self_fix_rounds(pbv)
@@ -1572,10 +1573,7 @@ def _validate_verdicts_match_current_subjects(
1572
1573
  if not isinstance(round_count, int) or round_count < 1:
1573
1574
  return
1574
1575
  try:
1575
- current = {
1576
- str(item["id"]): str(item.get("subject") or "")
1577
- for item in extract_plan_items(ip)
1578
- }
1576
+ current = {str(item["id"]): item for item in extract_plan_items(ip)}
1579
1577
  except Exception: # noqa: BLE001
1580
1578
  # Extraction failure is already reported by the completeness check;
1581
1579
  # do not double-report it here as a spurious subject mismatch.
@@ -1587,10 +1585,14 @@ def _validate_verdicts_match_current_subjects(
1587
1585
  continue
1588
1586
  item_id = str(item.get("id") or "").strip()
1589
1587
  recorded = str(item.get("subject") or "").strip()
1590
- expected = current.get(item_id)
1591
- if expected is None or not recorded:
1588
+ extracted = current.get(item_id)
1589
+ if extracted is None or not recorded:
1590
+ continue
1591
+ if item.get("verifiedContentHash") == content_hash(extracted):
1592
+ # seed 는 subject 를 갱신하지 않는다. 표가 지금 이 위치의 본문에
1593
+ # 찍혔으면 subject 문구 차이는 위치 이동이 아니다.
1592
1594
  continue
1593
- if recorded != expected.strip():
1595
+ if recorded != str(extracted.get("subject") or "").strip():
1594
1596
  drifted.append(item_id)
1595
1597
  if drifted:
1596
1598
  failures.append(
@@ -15,8 +15,13 @@ from okstra_ctl.implementation_direction import (
15
15
  )
16
16
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
17
17
  from okstra_ctl.paths import task_dir, task_runs_dir
18
- from okstra_ctl.wizard.ids import S_SELECTED_DIRECTION_PICK
19
- from okstra_ctl.wizard.state import Prompt, WizardError, WizardState
18
+ from okstra_ctl.user_response import (
19
+ UserResponseError,
20
+ pending_direction_candidates,
21
+ record_direction_selection,
22
+ )
23
+ from okstra_ctl.wizard.ids import _ABORT_OPTION, S_SELECTED_DIRECTION_PICK
24
+ from okstra_ctl.wizard.state import Option, Prompt, WizardError, WizardState
20
25
  from okstra_ctl.wizard.prompts import _opt, _p
21
26
  from okstra_ctl.wizard.sources import _project_relative_path
22
27
 
@@ -27,6 +32,7 @@ from okstra_ctl.wizard.sources import _project_relative_path
27
32
  _SELECTION_REPORT_RE = re.compile(
28
33
  r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.data\.json$"
29
34
  )
35
+ _DIRECTION_SEPARATOR = "#"
30
36
 
31
37
 
32
38
  def _selected_direction_candidates(state: WizardState) -> list[str]:
@@ -97,18 +103,67 @@ def _planning_rerun_selected(state: WizardState) -> bool:
97
103
  )
98
104
 
99
105
 
106
+ def _task_key(state: WizardState) -> str:
107
+ return f"{state.project_id}:{state.task_group}:{state.task_id}"
108
+
109
+
110
+ def _pending_directions(
111
+ state: WizardState, rel_path: str
112
+ ) -> tuple[list[dict[str, str]], str]:
113
+ try:
114
+ return pending_direction_candidates(
115
+ Path(state.project_root) / rel_path, _task_key(state)
116
+ )
117
+ except UserResponseError:
118
+ return [], ""
119
+
120
+
121
+ def _selected_direction_options(state: WizardState) -> list[Option]:
122
+ """방향을 아직 고르지 않은 리포트는 후보마다 한 줄로 펼친다.
123
+
124
+ 리포트 경로만 고르게 하면 실제 결정(어느 방향인가)은 답변 사이드카에 있어야
125
+ 해서, 사이드카가 없는 리포트는 고르는 순간 거절됐다(실측 2026-10-04,
126
+ dev-11118: 후보 IO-001 하나를 고르자 `sidecar file not found`).
127
+ """
128
+ t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
129
+ options: list[Option] = []
130
+ recommended_taken = False
131
+ for path in _selected_direction_candidates(state):
132
+ candidates, recommended_id = _pending_directions(state, path)
133
+ if not candidates:
134
+ options.append(_opt(path, _selection_option_label(state, path, t)))
135
+ continue
136
+ match = _SELECTION_REPORT_RE.fullmatch(Path(path).name)
137
+ seq = match.group("seq") if match else ""
138
+ for row in candidates:
139
+ if row["ineligibility"]:
140
+ continue
141
+ recommended = not recommended_taken and row["id"] == recommended_id
142
+ recommended_taken = recommended_taken or recommended
143
+ options.append(_opt(
144
+ f"{path}{_DIRECTION_SEPARATOR}{row['id']}",
145
+ t["labels"]["pending_option"].format(
146
+ id=row["id"], name=row["name"], seq=seq
147
+ ),
148
+ row["goal"],
149
+ recommended=recommended,
150
+ ))
151
+ return sorted(options, key=lambda option: not option.recommended)
152
+
153
+
100
154
  def _build_selected_direction_pick(state: WizardState) -> Prompt:
101
- candidates = _selected_direction_candidates(state)
155
+ options = _selected_direction_options(state)
102
156
  t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
103
- if not candidates:
157
+ if not options:
104
158
  raise WizardError(t["errors"]["none"])
159
+ # 중단 줄이 있어야 후보가 하나뿐일 때도 사용자의 결정으로 남는다 — 옵션
160
+ # 1개짜리 화면은 자동 제출되고, 이 단계의 제출은 사용자 답변 파일을 쓴다.
161
+ options.append(_opt(_ABORT_OPTION, t["labels"]["abort"]))
105
162
  return Prompt(
106
163
  step=S_SELECTED_DIRECTION_PICK,
107
164
  kind="pick",
108
165
  label=t["label"],
109
- options=[
110
- _opt(path, _selection_option_label(state, path, t)) for path in candidates
111
- ],
166
+ options=options,
112
167
  echo_template=t["echo_template"],
113
168
  )
114
169
 
@@ -144,17 +199,24 @@ def _selection_option_label(state: WizardState, rel_path: str, t: dict) -> str:
144
199
 
145
200
  def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[str]:
146
201
  t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
147
- candidates = _selected_direction_candidates(state)
148
- if value not in candidates:
202
+ if value == _ABORT_OPTION:
203
+ state.aborted = True
204
+ return t["echo_variants"]["abort"]
205
+ if value not in {option.value for option in _selected_direction_options(state)}:
149
206
  raise WizardError(t["errors"]["unknown"].format(value=value))
207
+ path, _, option_id = value.partition(_DIRECTION_SEPARATOR)
208
+ report = Path(state.project_root) / path
209
+ recorded = ""
150
210
  try:
151
- resolve_selected_direction(
152
- Path(state.project_root) / value,
153
- expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
154
- )
155
- except DirectionSelectionError as exc:
211
+ if option_id:
212
+ recorded = t["echo_variants"]["recorded_direction"].format(
213
+ id=option_id,
214
+ sidecar=record_direction_selection(report, _task_key(state), option_id),
215
+ )
216
+ resolve_selected_direction(report, expected_task_key=_task_key(state))
217
+ except (DirectionSelectionError, UserResponseError) as exc:
156
218
  raise WizardError(str(exc)) from exc
157
- state.selected_direction_path = value
219
+ state.selected_direction_path = path
158
220
  # 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
159
221
  # clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
160
222
  # 거절을 여기서 앞당기는 대신 값을 비우고, 무엇이 비워졌는지 echo 로
@@ -162,5 +224,5 @@ def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[
162
224
  # `--selected-direction` 경로로 계획 런에 첨부된다(`run.py` 참조).
163
225
  if state.clarification_response_path:
164
226
  state.clarification_response_path = ""
165
- return t["echo_variants"]["cleared_clarification"].format(value=value)
166
- return f"selected-direction: {value}"
227
+ return t["echo_variants"]["cleared_clarification"].format(value=path) + recorded
228
+ return f"selected-direction: {path}{recorded}"
@@ -751,8 +751,10 @@ ENVIRONMENT_CORRECTION_PREAMBLE = (
751
751
 
752
752
  CRITIC_TIE_PREAMBLE = (
753
753
  "You are the critic tie-break. Only the items below are in dispute. "
754
- "Each item already has one AGREE and one DISAGREE from the two plan-body "
755
- "verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
754
+ "Each item's plan-body verifiers split on it; its `Analyser split` block lists "
755
+ "every analyser vote with the basis that analyser gave, and a "
756
+ "verification-error vote there counted for neither side. Decide the item: "
757
+ "AGREE or DISAGREE(<kind>). Your verdict "
756
758
  "settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
757
759
  "Explain whether the earlier dissent is supported or mistaken; a corrected "
758
760
  "dissent remains in the audit history without overriding your decision. "
@@ -32,6 +32,7 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
32
32
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
33
33
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
34
34
  from . import fix_cycles
35
+ from .cmux import LEAD_LOCATION_KEY
35
36
  from . import next_phase
36
37
  from .analysis_inputs import ANALYSIS_TASK_TYPES
37
38
  from .application.resolve_assignment import (
@@ -1930,6 +1931,9 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1930
1931
  payload["analysisScopeConfirmation"] = scope_confirmation
1931
1932
  if ctx.get("FIX_CYCLE_ID"):
1932
1933
  payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1934
+ cmux_lead = json.loads(ctx.get("CMUX_LEAD_JSON") or "{}")
1935
+ if cmux_lead:
1936
+ payload[LEAD_LOCATION_KEY] = cmux_lead
1933
1937
  if (
1934
1938
  CURRENT_REPORT_SCHEMA_VERSION == "3.0"
1935
1939
  or ctx.get("TASK_TYPE") == "implementation-planning"
@@ -727,13 +727,7 @@ def run_finalize(
727
727
  if step["name"] == "business-flow":
728
728
  payload["businessProcess"] = step["result"]
729
729
  # 선택 단계라 exitCode 는 0 이다. 실패를 중첩 JSON 에만 두면 리드가 못 본다.
730
- failed = [
731
- {"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
732
- for row in step["result"].get("executions") or []
733
- if isinstance(row, dict) and row.get("status") == "failed"
734
- ]
735
- if step["result"].get("error"):
736
- failed.append({"id": "", "mode": "", "error": str(step["result"]["error"])})
730
+ failed = business_flow_warnings(step["result"])
737
731
  if failed:
738
732
  payload["businessFlowWarnings"] = failed
739
733
  census = _census_summary(ctx)
@@ -742,6 +736,24 @@ def run_finalize(
742
736
  return payload
743
737
 
744
738
 
739
+ def business_flow_warnings(result: dict[str, Any]) -> list[dict[str, str]]:
740
+ # sidecar 는 task link 이력 전체를 담는다. 같은 mode 가 뒤에서 성공했으면 그 실패는 끝난 일이다.
741
+ rows = [row for row in result.get("executions") or [] if isinstance(row, dict)]
742
+ failed = [
743
+ {"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
744
+ for index, row in enumerate(rows)
745
+ if row.get("status") == "failed"
746
+ and not any(
747
+ later.get("mode") == row.get("mode")
748
+ and later.get("status") in {"complete", "partial"}
749
+ for later in rows[index + 1 :]
750
+ )
751
+ ]
752
+ if result.get("error"):
753
+ failed.append({"id": "", "mode": "", "error": str(result["error"])})
754
+ return failed
755
+
756
+
745
757
  def _census_summary(ctx: FinalizeContext) -> dict[str, Any] | None:
746
758
  """Unverdicted census cells per worker, for the lead's completion line."""
747
759
  try:
@@ -3059,6 +3059,7 @@ def _write_instruction_set_sources(
3059
3059
  json.loads(ctx.get("RELATED_TASKS_JSON", "[]")),
3060
3060
  ),
3061
3061
  coverage_census_text=coverage_census_text,
3062
+ has_selected_direction=(instruction_set / "selected-direction.json").is_file(),
3062
3063
  )
3063
3064
  if inp.task_type == "technical-verification":
3064
3065
  packet += materialize_technical_input_packet(
@@ -3967,7 +3968,7 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
3967
3968
  return detected
3968
3969
  if _prior_run_backend(project_root, inp) != BACKEND_CMUX_PANE:
3969
3970
  return detected
3970
- observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason())
3971
+ observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason(None))
3971
3972
  raise PrepareError(" ".join(part for part in (
3972
3973
  f"this task's previous {inp.task_type} run put its workers on cmux "
3973
3974
  f"panes, and now {observed}.",
@@ -3985,6 +3986,18 @@ def lead_pane_title(task_group: str, task_id: str) -> str:
3985
3986
  return f"{task_group}/{task_id}"
3986
3987
 
3987
3988
 
3989
+ def _cmux_lead_json(terminal_backend: str) -> str:
3990
+ """prepare 를 실행한 터미널의 cmux 위치. cmux 백엔드가 아니면 빈 객체.
3991
+
3992
+ 리드 프로세스의 환경은 이 터미널의 것과 다를 수 있다(`cmux.LEAD_LOCATION_KEY`
3993
+ 참조) — 그래서 리드가 뜨기 전인 지금 값을 남긴다.
3994
+ """
3995
+ location = cmux.lead_location_from_env()
3996
+ if terminal_backend != BACKEND_CMUX_PANE or location is None:
3997
+ return "{}"
3998
+ return json.dumps(cmux.lead_location_payload(location))
3999
+
4000
+
3988
4001
  def _title_lead_pane(inp: PrepareInputs) -> None:
3989
4002
  """리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
3990
4003
 
@@ -4204,6 +4217,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4204
4217
 
4205
4218
  ctx.update({
4206
4219
  "TERMINAL_BACKEND": terminal_backend,
4220
+ "CMUX_LEAD_JSON": _cmux_lead_json(terminal_backend),
4207
4221
  "EXECUTOR_WORKTREE_PATH": worktree.path,
4208
4222
  "EXECUTOR_WORKTREE_BRANCH": worktree.branch,
4209
4223
  "EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
@@ -366,7 +366,7 @@ def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> No
366
366
  from .adapters.runtime.assembly import port_for, runtime_chain
367
367
  from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
368
368
 
369
- chain = runtime_chain(_manifest_backend(manifest))
369
+ chain = runtime_chain(_manifest_backend(manifest), manifest=manifest)
370
370
  # cli-wrapper chains have no cmux port; leftover paneIds must not KeyError.
371
371
  if panes and any(port.surface == SURFACE_CMUX_PANE for port in chain):
372
372
  closer = port_for(chain, SURFACE_CMUX_PANE)
@@ -466,11 +466,13 @@ def _reclaimable_panes(
466
466
  continue
467
467
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
468
468
  if _is_cmux_run(manifest):
469
- return _still_open_surfaces(panes)
469
+ return _still_open_surfaces(panes, cmux.recorded_lead(manifest))
470
470
  return panes
471
471
 
472
472
 
473
- def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
473
+ def _still_open_surfaces(
474
+ panes: list[dict[str, str]], recorded: cmux.LeadLocation | None
475
+ ) -> list[dict[str, str]]:
474
476
  """The recorded surfaces cmux still shows.
475
477
 
476
478
  Nothing prunes `workerDispatches` — a repeat of one `dispatchId` replaces
@@ -484,7 +486,7 @@ def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
484
486
  already went away is a silent no-op, and skipping a live one strands it on
485
487
  the user's screen for the rest of the session.
486
488
  """
487
- workspace = cmux.resolve_lead_workspace()
489
+ workspace = cmux.resolve_lead_workspace(recorded)
488
490
  if not workspace:
489
491
  return panes
490
492
  try:
@@ -1602,6 +1602,28 @@ def set_direction_selection(
1602
1602
  _write_transaction(path, payload)
1603
1603
 
1604
1604
 
1605
+ def pending_direction_candidates(
1606
+ report_path: Path, task_key: str
1607
+ ) -> tuple[list[dict[str, str]], str]:
1608
+ """아직 방향을 고르지 않은 후보비교 리포트의 (후보, 추천 id). 고른 뒤면 빈 목록."""
1609
+ context = _validate_owned_report_context(report_path, expected_task_key=task_key)
1610
+ if not _direction_selection_required(context):
1611
+ return [], ""
1612
+ return _direction_candidates(context)
1613
+
1614
+
1615
+ def record_direction_selection(report_path: Path, task_key: str, option_id: str) -> Path:
1616
+ """사용자가 고른 방향을 `okstra user-response direction` 과 같은 트랜잭션으로 기록한다."""
1617
+ context = _validate_owned_report_context(report_path, expected_task_key=task_key)
1618
+ candidates, _ = _direction_candidates(context)
1619
+ ids = [row["id"] for row in candidates]
1620
+ if option_id not in ids:
1621
+ raise UserResponseError(f"direction option is not a ranked candidate: {option_id}")
1622
+ transaction_id = begin_response(report_path, task_key)
1623
+ set_direction_selection(transaction_id, ids.index(option_id) + 1, None, None)
1624
+ return finalize_response(transaction_id)
1625
+
1626
+
1605
1627
  def set_legacy_report_authoring(
1606
1628
  transaction_id: str, status: str, reason_file: Path
1607
1629
  ) -> None:
@@ -11,7 +11,6 @@ from .state import WizardError
11
11
  from .statefile import load_state_file, save_state_file
12
12
  from .confirmation import confirmation_block
13
13
  from .engine import (
14
- _auto_start_new_task_when_it_is_the_only_choice,
15
14
  init_state,
16
15
  next_prompt,
17
16
  prompt_payload,
@@ -87,7 +86,6 @@ def main(argv: list[str]) -> int:
87
86
  host_entry_mode=args.entry_mode,
88
87
  available_functions=args.available_function,
89
88
  )
90
- _auto_start_new_task_when_it_is_the_only_choice(state)
91
89
  save_state_file(state_path, state)
92
90
  first = next_prompt(state)
93
91
  print(json.dumps({"ok": True, "next": prompt_payload(state, first)},
@@ -19,16 +19,14 @@ from okstra_ctl.run import PrepareError
19
19
  from .ids import (
20
20
  GROUP_LABELS,
21
21
  GROUP_MAX_TABS,
22
- PICK_TYPE_CUSTOM,
23
22
  PROMPT_GROUPS,
24
23
  S_ABORTED,
25
24
  S_CONFIRM,
26
25
  S_DESIGN_PREP_CONFIRM,
27
26
  S_DESIGN_PREP_OVERRIDES,
28
27
  S_DONE,
29
- S_TASK_PICK,
30
28
  S_REPORT_LANGUAGE,
31
- TASK_PICK_NEW_TOKEN,
29
+ _ABORT_OPTION,
32
30
  _STEP_TO_GROUP,
33
31
  )
34
32
  from .state import Prompt, WizardError, WizardState, _is_role_selection_step
@@ -40,7 +38,6 @@ from .picker_navigation import (
40
38
  split_picker,
41
39
  )
42
40
  from .roles import _submit_role_prompt, next_role_prompt
43
- from .steps_identity import _submit_task_pick
44
41
  from .steps_analysis import _advance_design_prep_item
45
42
  from .registry import STEPS, STEP_BY_ID, _ROLE_SELECTION_BOUNDARY
46
43
 
@@ -66,18 +63,6 @@ def init_state(
66
63
  )
67
64
 
68
65
 
69
- def _auto_start_new_task_when_it_is_the_only_choice(state: WizardState) -> None:
70
- """미완료 task 가 없으면 중복된 새 작업 선택 화면을 건너뛴다."""
71
- prompt = next_prompt(state)
72
- if (
73
- prompt.step != S_TASK_PICK
74
- or [option.value for option in prompt.options] != [TASK_PICK_NEW_TOKEN]
75
- ):
76
- return
77
- _submit_task_pick(state, TASK_PICK_NEW_TOKEN)
78
- state.answered.append(S_TASK_PICK)
79
-
80
-
81
66
  def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
82
67
  """그룹의 적용가능·미답변 픽 멤버를 최대 GROUP_MAX_TABS 개 모은다.
83
68
 
@@ -102,39 +87,51 @@ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
102
87
  label=GROUP_LABELS[group_id], questions=members)
103
88
 
104
89
 
105
- def _is_lone_free_input_pick(prompt: Prompt) -> bool:
106
- """고를 것이 "직접 입력" 하나뿐인 픽인가.
90
+ def _is_lone_choice_pick(prompt: Prompt) -> bool:
91
+ """고를 것이 한 줄뿐인 단일 선택 픽인가.
107
92
 
108
- 후보가 0건이면 picker 는 탈출구 한 줄만 남는다. 그 화면은 질문이 아니라
109
- 빈 목록이고, 호스트 선택기는 옵션 2개 미만을 받지 않아(`nativeLimits`
110
- `minOptions`) 번호 1개짜리 목록으로 내려간다 — 사용자는 고를 것이 없는
111
- 목록에서 1 을 치고 나서야 값을 입력한다. 그런 화면은 건너뛰고 입력 단계로
112
- 바로 간다.
93
+ 호스트 선택기는 옵션 2개 미만을 받지 않아(`nativeLimits` `minOptions`) 이런
94
+ 화면은 번호 1개짜리 목록으로 내려간다 — 사용자는 고를 것이 없는 목록에서
95
+ 1 을 쳐야 했다(실측 2026-10-04, 방향 보고서가 하나뿐인 계획 run). 그 한 줄이
96
+ `직접 입력` 이면 입력 단계로, 실제 값이면 그 값으로 바로 간다. 고른 값은
97
+ 확인 화면 요약에 남는다. `중단` 한 줄은 사용자의 결정이 필요하므로 묻는다.
113
98
  """
114
99
  return (
115
100
  prompt.kind == "pick"
101
+ and not prompt.multi
116
102
  and len(prompt.options) == 1
117
- and prompt.options[0].value == PICK_TYPE_CUSTOM
103
+ and prompt.options[0].value != _ABORT_OPTION
118
104
  )
119
105
 
120
106
 
121
- def _take_free_input_branch(state: WizardState, prompt: Prompt) -> None:
122
- """빈 picker 를 사용자 대신 "직접 입력" 으로 답한다 (echo 는 없다)."""
123
- STEP_BY_ID[prompt.step].submit(state, PICK_TYPE_CUSTOM)
107
+ def _apply_answer(state: WizardState, prompt: Prompt, value: str) -> str:
108
+ if _is_role_selection_step(prompt.step):
109
+ echo = _submit_role_prompt(state, prompt, value)
110
+ if prompt.step not in state.role_selection_order:
111
+ state.role_selection_order.append(prompt.step)
112
+ else:
113
+ echo = STEP_BY_ID[prompt.step].submit(state, value)
124
114
  if prompt.step not in state.answered:
125
115
  state.answered.append(prompt.step)
116
+ return echo or ""
126
117
 
127
118
 
128
119
  def next_prompt(state: WizardState) -> Prompt:
129
- # 빈 picker 를 건너뛰면 그 자리에 다음 화면이 온다. 그 다음 화면이 또 빈
130
- # picker 일 수 있으므로 반복하되, 자동 전진은 step 당 한 번뿐이라 STEPS
131
- # 길이로 상한을 둔다.
132
- for _ in range(len(STEPS)):
120
+ # 한 줄짜리 picker 를 건너뛰면 그 자리에 다음 화면이 온다. 그것도 한 줄짜리일
121
+ # 수 있어 반복하되, 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
122
+ taken: set[str] = set()
123
+ while True:
133
124
  prompt = _next_prompt_screen(state)
134
- if not _is_lone_free_input_pick(prompt):
125
+ if prompt.step in taken or not _is_lone_choice_pick(prompt):
135
126
  return _native_picker_screen(state, prompt)
136
- _take_free_input_branch(state, prompt)
137
- return _next_prompt_screen(state)
127
+ taken.add(prompt.step)
128
+ attempt = copy.deepcopy(state)
129
+ try:
130
+ _apply_answer(attempt, prompt, prompt.options[0].value)
131
+ except WizardError:
132
+ # 그 한 줄이 거절되면 사용자에게 보여 거절 사유를 받게 한다.
133
+ return _native_picker_screen(state, prompt)
134
+ state.__dict__.update(attempt.__dict__)
138
135
 
139
136
 
140
137
  def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
@@ -413,16 +410,6 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
413
410
  original = next_role_prompt(state)
414
411
  if original is not None and original.step == prompt.step:
415
412
  prompt = original
416
- echo = _submit_role_prompt(state, prompt, value or "")
417
- if prompt.step not in state.answered:
418
- state.answered.append(prompt.step)
419
- if prompt.step not in state.role_selection_order:
420
- state.role_selection_order.append(prompt.step)
421
- nxt = next_prompt(state)
422
- return {"echo": echo, "next": prompt_payload(state, nxt)}
423
- step = STEP_BY_ID[prompt.step]
424
- echo = step.submit(state, value or "")
425
- if prompt.step not in state.answered:
426
- state.answered.append(prompt.step)
413
+ echo = _apply_answer(state, prompt, value or "")
427
414
  nxt = next_prompt(state)
428
- return {"echo": echo or "", "next": prompt_payload(state, nxt)}
415
+ return {"echo": echo, "next": prompt_payload(state, nxt)}
@@ -9,7 +9,7 @@ from okstra_ctl.phases.change_impact_analysis.entry import (
9
9
  )
10
10
 
11
11
  from okstra_ctl.phases.implementation_planning.wizard import (
12
- _selected_direction_candidates,
12
+ _selected_direction_options,
13
13
  _planning_rerun_selected,
14
14
  _build_selected_direction_pick,
15
15
  _submit_selected_direction_pick,
@@ -532,7 +532,7 @@ STEPS: list[Step] = [
532
532
  # 되돌릴 수 없는 막다른 길이다. 방향 없이 진행한 런은
533
533
  # `run._validate_planning_entry_inputs` 가 두 입력을 모두 이름 붙여
534
534
  # 거절하므로, 실패는 복구 가능한 자리로 옮겨간다.
535
- and bool(_selected_direction_candidates(s))
535
+ and bool(_selected_direction_options(s))
536
536
  ),
537
537
  build=_build_selected_direction_pick,
538
538
  submit=_submit_selected_direction_pick,