okstra 0.209.0 → 0.209.2

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 (44) 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 +18 -7
  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_audit_ledger.py +35 -24
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
  41. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  42. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  43. package/runtime/skills/okstra-run/SKILL.md +3 -3
  44. package/runtime/validators/validate-run.py +9 -2
@@ -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,
@@ -278,7 +278,7 @@ def _single_model_options(
278
278
  """고정 단일 역할의 후보를 제공자별로 묶고 기존 추천을 유지한다."""
279
279
  pool = context.pool
280
280
  requirement = next(row for row in profile.roles if row.role == role)
281
- defaults, everything = _available_role_models(
281
+ _defaults, everything = _available_role_models(
282
282
  state, profile, requirement, context, scopes,
283
283
  )
284
284
  prompt = _p(state.workspace_root, "role_model", role=role)
@@ -297,13 +297,7 @@ def _single_model_options(
297
297
  options.append(_opt(
298
298
  model_ref,
299
299
  prompt["options"]["model"].format(
300
- model_ref=model_ref,
301
- display=model.display_name,
302
- default_suffix=(
303
- prompt["options"].get("default_suffix", "")
304
- if model_ref in defaults
305
- else ""
306
- ),
300
+ model_ref=model_ref, display=model.display_name,
307
301
  ),
308
302
  recommended=not options,
309
303
  ))
@@ -27,6 +27,7 @@ from okstra_project.state import (
27
27
  )
28
28
 
29
29
  from .ids import (
30
+ BRIEF_CARRY_SWITCH_ENTRY,
30
31
  BASE_REF_FREE_INPUT_TOKEN,
31
32
  CANONICAL_BASE_REFS,
32
33
  PICK_TYPE_CUSTOM,
@@ -559,7 +560,7 @@ def _implementation_needs_approval(
559
560
 
560
561
  def _build_task_type(state: WizardState) -> Prompt:
561
562
  t = _p(state.workspace_root, "task_type")
562
- recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
563
+ recommended_suffix = ""
563
564
  rerun_suffix = t["options"].get("_RERUN_SUFFIX", "")
564
565
  next_suffix = t["options"].get("_NEXT_SUFFIX", "")
565
566
  approve_suffix = t["options"].get("_APPROVE_SUFFIX", recommended_suffix)
@@ -826,7 +827,10 @@ def _build_brief_carry(state: WizardState) -> Prompt:
826
827
  return Prompt(
827
828
  step=S_BRIEF_CARRY, kind="pick",
828
829
  label=t["label"],
829
- options=[_opt(k, v) for k, v in t["options"].items()],
830
+ options=[
831
+ _opt(k, v, recommended=k == BRIEF_CARRY_SWITCH_ENTRY)
832
+ for k, v in t["options"].items()
833
+ ],
830
834
  echo_template=t["echo_template"],
831
835
  )
832
836
 
@@ -851,11 +855,9 @@ def _checked_out_canonical_ref(project_root: Path) -> str:
851
855
 
852
856
  def _build_base_ref_pick(state: WizardState) -> Prompt:
853
857
  t = _p(state.workspace_root, "base_ref_pick")
854
- recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
855
858
  recommended = _checked_out_canonical_ref(Path(state.project_root))
856
859
  refs = sorted(CANONICAL_BASE_REFS, key=lambda r: r != recommended)
857
- options = [_opt(r, f"{r}{recommended_suffix}", recommended=True) if r == recommended
858
- else _opt(r) for r in refs]
860
+ options = [_opt(r, recommended=r == recommended) for r in refs]
859
861
  for value, label in _static_options(t):
860
862
  options.append(_opt(value=value, label=label))
861
863
  return Prompt(
@@ -689,7 +689,7 @@ def _build_fix_cycle_confirm(state: WizardState) -> Prompt:
689
689
  return Prompt(
690
690
  step=S_FIX_CYCLE_CONFIRM, kind="pick", label=t["label"],
691
691
  options=[
692
- _opt("yes", opts["yes"]),
692
+ _opt("yes", opts["yes"], recommended=True),
693
693
  _opt("no", opts["no"]),
694
694
  _opt("abort", opts["abort"]),
695
695
  ],
@@ -344,6 +344,36 @@ def worker_result_files(
344
344
  yield path, match.worker_role, match.seq
345
345
 
346
346
 
347
+ def result_file_audit_failures(result_path: Path, audit_path: Path) -> list[str]:
348
+ """The blocking audit-sidecar findings for one worker result and its sidecar."""
349
+ rel = result_path.name
350
+ try:
351
+ content = result_path.read_text()
352
+ except OSError as exc:
353
+ return [f"worker-results file unreadable: {rel} ({exc})"]
354
+ failures: list[str] = []
355
+ if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
356
+ failures.append(
357
+ f"worker-results file `{rel}` contains a `## 0. Reading "
358
+ f"Confirmation` heading — that block moved to the audit "
359
+ f"sidecar (`{audit_path.name}`). "
360
+ f"Remove the §0 heading + body from the main file and "
361
+ f"write a fresh sidecar."
362
+ )
363
+ if not audit_path.exists():
364
+ worker_role = rel.partition("-worker-")[0] + "-worker"
365
+ failures.append(
366
+ f"worker `{worker_role}` produced `{rel}` but no audit sidecar "
367
+ f"at `{audit_path.name}` — the sidecar must carry the Reading "
368
+ f"Confirmation block (one short line per input file). Workers "
369
+ f"write this in the same step as the main worker-results file."
370
+ )
371
+ return failures
372
+ _, command_failures = read_evidence_commands(audit_path)
373
+ failures.extend(command_failures)
374
+ return failures
375
+
376
+
347
377
  def check_worker_results_audit(
348
378
  run_dir: Path,
349
379
  task_type: str,
@@ -399,36 +429,17 @@ def worker_results_audit_findings(
399
429
 
400
430
  for path, worker_role, result_seq in worker_result_files(run_dir, task_type, seq, worker):
401
431
  rel = path.name
402
- try:
403
- content = path.read_text()
404
- except OSError as exc:
405
- failures.append(f"worker-results file unreadable: {rel} ({exc})")
406
- continue
407
-
408
- if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
409
- failures.append(
410
- f"worker-results file `{rel}` contains a `## 0. Reading "
411
- f"Confirmation` heading — that block moved to the audit "
412
- f"sidecar (`{worker_role}-audit-{task_type}-{result_seq}.md`). "
413
- f"Remove the §0 heading + body from the main file and "
414
- f"write a fresh sidecar."
415
- )
416
-
417
432
  audit_path = (
418
433
  run_dir / "worker-results"
419
434
  / f"{worker_role}-audit-{task_type}-{result_seq}.md"
420
435
  )
436
+ failures.extend(result_file_audit_failures(path, audit_path))
437
+ try:
438
+ content = path.read_text()
439
+ except OSError:
440
+ continue
421
441
  if not audit_path.exists():
422
- failures.append(
423
- f"worker `{worker_role}` produced `{rel}` but no audit sidecar "
424
- f"at `{audit_path.name}` — the sidecar must carry the Reading "
425
- f"Confirmation block (one short line per input file). Workers "
426
- f"write this in the same step as the main worker-results file."
427
- )
428
442
  continue
429
-
430
- _, command_failures = read_evidence_commands(audit_path)
431
- failures.extend(command_failures)
432
443
  advisories.extend(_evidence_read_ledger_failures(
433
444
  run_dir=run_dir,
434
445
  worker_role=worker_role,
@@ -23,6 +23,7 @@ def analysis_prompt_body(
23
23
  model: str,
24
24
  plan: PromptPlan,
25
25
  approved_plan_path: str = "",
26
+ selected_direction_path: str = "",
26
27
  ) -> list[str]:
27
28
  """Render the provider-neutral body for an initial analysis audience."""
28
29
  label = analysis_worker_label(worker_id)
@@ -41,7 +42,9 @@ def analysis_prompt_body(
41
42
  f"- Task type: `{_require_string(manifest, 'taskType')}`",
42
43
  "",
43
44
  "## Inputs",
44
- *analysis_input_lines(manifest, active_context, plan, approved_plan_path),
45
+ *analysis_input_lines(
46
+ manifest, active_context, plan, approved_plan_path, selected_direction_path
47
+ ),
45
48
  "",
46
49
  mcp_pointer_line(),
47
50
  "",
@@ -56,6 +59,7 @@ def analysis_input_lines(
56
59
  active_context: Mapping[str, Any],
57
60
  plan: PromptPlan,
58
61
  approved_plan_path: str = "",
62
+ selected_direction_path: str = "",
59
63
  ) -> list[str]:
60
64
  if plan.packet_only:
61
65
  packet_path = instruction_path(manifest, active_context, "analysisPacketPath")
@@ -70,6 +74,7 @@ def analysis_input_lines(
70
74
  return existing_input_lines((
71
75
  ("Primary analysis packet", packet_path),
72
76
  ("Approved plan", approved_plan_path),
77
+ ("Selected direction snapshot", selected_direction_path),
73
78
  ))
74
79
  inputs = [
75
80
  ("Primary analysis packet", instruction_path(manifest, active_context, "analysisPacketPath")),
@@ -170,6 +170,9 @@ class PromptPlan:
170
170
  # 기준으로 판정하라고 요구하는 phase 만 참이다 — 없는 phase 에 실으면
171
171
  # 열거만 되고 열 파일이 없는 줄이 된다.
172
172
  include_approved_plan: bool = False
173
+ # 계획 워커 직무는 `selected-direction.json` 을 먼저 읽으라고 하는데, 읽기
174
+ # 범위는 `## Inputs` 에 열거된 경로뿐이다. 그래서 계획 phase 는 스냅샷을 연다.
175
+ include_selected_direction: bool = False
173
176
 
174
177
 
175
178
  def resolve_prompt_plan(
@@ -261,6 +264,14 @@ def resolve_prompt_plan(
261
264
  packet_only=True,
262
265
  required_headers=(GRILLING_LOG_HEADER,),
263
266
  )
267
+ if task_type == "implementation-planning":
268
+ return _plan(
269
+ "analysis",
270
+ duty_audience=analysis_duty,
271
+ equality_group=analysis_equality_group,
272
+ packet_only=True,
273
+ include_selected_direction=True,
274
+ )
264
275
  return _plan(
265
276
  "analysis",
266
277
  duty_audience=analysis_duty,
@@ -328,6 +339,7 @@ def _plan(
328
339
  equality_group: str | None = None,
329
340
  packet_only: bool = False,
330
341
  include_approved_plan: bool = False,
342
+ include_selected_direction: bool = False,
331
343
  allow_coding_preflight: bool = False,
332
344
  required_headers: tuple[str, ...] = (),
333
345
  max_body_lines: int | None = None,
@@ -339,6 +351,7 @@ def _plan(
339
351
  equality_group=equality_group,
340
352
  packet_only=packet_only,
341
353
  include_approved_plan=include_approved_plan,
354
+ include_selected_direction=include_selected_direction,
342
355
  allow_coding_preflight=allow_coding_preflight,
343
356
  required_headers=_worker_facing_headers(audience, required_headers),
344
357
  max_body_lines=max_body_lines,
@@ -31,6 +31,7 @@ from .final_report_paths import final_report_data_path
31
31
  from .json_boundary import JsonBoundaryError, load_owned_object
32
32
  from .path_hints import hydrate_active_run_context
33
33
  from .paths import task_conformance_manifest_file, task_qa_dir
34
+ from okstra_project.dirs import OKSTRA_RELATIVE
34
35
 
35
36
  def build_write_policy(
36
37
  invocation: Mapping[str, Any],
@@ -130,6 +131,20 @@ def _role_qa_artifact_paths(role: str, task_root: Path | None) -> tuple[Path, ..
130
131
  return ()
131
132
 
132
133
 
134
+ def _role_project_memory_paths(role: str, project_root: Path) -> tuple[Path, ...]:
135
+ """The project-memory files an approved plan step orders the executor to write.
136
+
137
+ The implementation-planning profile makes every decision draft a step that
138
+ creates `.okstra/decisions/<NNNN>-<slug>.md`, and a glossary proposal a step
139
+ that updates `.okstra/glossary.md`. Both sit outside the task root, so no
140
+ other grant reaches them and `_planned_source_paths` refused the dispatch.
141
+ """
142
+ if role != "implementer":
143
+ return ()
144
+ okstra = project_root / OKSTRA_RELATIVE
145
+ return (okstra / "decisions", okstra / "glossary.md")
146
+
147
+
133
148
  def _inherited_qa_evidence(
134
149
  role: str, task_type: str, task_root: Path | None,
135
150
  ) -> tuple[Path, ...]:
@@ -212,6 +227,7 @@ def build_invocation_write_contract(
212
227
  *artifact_paths,
213
228
  *_technical_experiment_paths(artifact_paths, task_root),
214
229
  *_role_qa_artifact_paths(role, task_root),
230
+ *_role_project_memory_paths(role, root),
215
231
  )
216
232
  )
217
233
  )
@@ -59,9 +59,9 @@ Submit the answer shape required by `interaction.answerProtocol`; do not add nor
59
59
 
60
60
  The final `confirm` step is a normal `pick` step with three options — `Proceed` / `Edit` / `Abort`(abort) — and is rendered the same way (no special handling). `Edit` rewinds to any earlier step (including `base-ref`); `Abort` terminally cancels the wizard. The branch/worktree decision the run will actually use (for `implementation`, the **stage worktree** — not the task-key directory) is folded into the Step 4 confirmation summary block as a `worktree` line, so there is no separate branch-confirm prompt.
61
61
 
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)".
62
+ Never invent additional questions. **Never drop, hide, merge, reorder, or truncate** a `pick` / `pick_group` option — relay every `options[]` entry, including the flagged recommendation. 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, and it is option 1.** The option carrying `recommended: true` is what this run computed, and the wizard already placed it first — except on model-selection steps (`role-models:*`, `role-model:*`), which keep provider display order, so the flagged model may sit anywhere in the list. 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, whose recommendation is not placed first on a single-select step or is not the leading run on a checkbox step (both position checks are skipped on `role-models:*` / `role-model:*` steps), or that marks the free-input / abort escape as a recommendation.
64
+ **One recommendation, shown in one place, and it is option 1.** Wizard labels carry no recommendation marker of their own. The option carrying `recommended: true` is what this run computed, and the wizard already placed it first — except on model-selection steps (`role-models:*`, `role-model:*`), which keep provider display order, so the flagged model may sit anywhere in the list. 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, whose recommendation is not placed first on a single-select step or is not the leading run on a checkbox step (both position checks are skipped on `role-models:*` / `role-model:*` steps), or that marks the free-input / abort escape as a recommendation.
65
65
 
66
66
  ## Step 1: Preflight
67
67
 
@@ -470,7 +470,7 @@ When the user has to choose an implementation direction — a finished `implemen
470
470
  ~/.okstra/bin/okstra option-comparison --report <final-report-implementation-option-selection-NNN.data.json>
471
471
  ```
472
472
 
473
- At `selected_direction_pick` the report path is the option value. The command prints JSON; every number in the reply comes from it. Use the recorded `weightedScore` as printed; do not recompute it. Write the reply in the user's language, in this order:
473
+ At `selected_direction_pick` the option value is the report path, or `<report path>#<option id>` for a report still awaiting a direction — pass the part before `#` to `--report`. Picking a `#` value records that direction in the report's `user-responses/` sidecar. The command prints JSON; every number in the reply comes from it. Use the recorded `weightedScore` as printed; do not recompute it. Write the reply in the user's language, in this order:
474
474
 
475
475
  1. **Lead-in** — one or two sentences on what the directions share and the axes they split on, drawn from `narrative.comparisonOverview` and each option's `coreMechanism`.
476
476
  2. **Score table** — a Markdown table: one row per `criteria[]` entry in order, a weight column, one column per option (`<id> <short name>`), and a last row with each option's `weightedScore`. Write each criterion as its identifier with a translation in the user's language, e.g. `requirement-fit(요구사항 부합)`.
@@ -5660,14 +5660,21 @@ def run_plan_body_inputs(narrative_path: Path, state_path: Path) -> int:
5660
5660
  print("validate-run: narrative has no implementationPlanning", file=sys.stderr)
5661
5661
  return 2
5662
5662
  data = {**narrative, "schemaVersion": "3.0"}
5663
- data["implementationPlanning"] = {**planning, "planBodyVerification": verification}
5663
+ data["implementationPlanning"] = {**planning, "planBodyVerification": dict(verification)}
5664
+ gate = plan_body_gate_summary(data)
5665
+ if gate is not None:
5666
+ # 상태의 gate 값은 complete-round 가 같은 재계산으로 쓴다. 그 전에 도는
5667
+ # plan-verify 에서 선언값을 대조하면 seed 값과의 시차만 잡힌다.
5668
+ data["implementationPlanning"]["planBodyVerification"].update(
5669
+ gateResult=gate["recomputed"], gateBlockedBy=gate["blockedBy"]
5670
+ )
5664
5671
  failures: list[str] = []
5665
5672
  warnings = validate_plan_body_section(data, _report_path_for_state(state_path), failures)
5666
5673
  failures, advisories = _partition_round_failures(failures)
5667
5674
  payload = {
5668
5675
  "ok": not failures,
5669
5676
  "section": SECTION_PLAN_BODY,
5670
- "gate": plan_body_gate_summary(data),
5677
+ "gate": gate,
5671
5678
  "failures": failures,
5672
5679
  "advisories": advisories,
5673
5680
  "warnings": warnings,