okstra 0.223.2 → 0.225.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.
Files changed (80) hide show
  1. package/dist/commands/execute/render-bundle.mjs +7 -5
  2. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  3. package/docs/cli.md +10 -2
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/lib/okstra/cli.sh +5 -1
  7. package/runtime/bin/lib/okstra/globals.sh +1 -0
  8. package/runtime/bin/lib/okstra/usage.sh +4 -1
  9. package/runtime/bin/okstra-provider-exec.py +32 -16
  10. package/runtime/bin/okstra.sh +1 -0
  11. package/runtime/prompts/coding-preflight/clean-code.md +8 -1
  12. package/runtime/prompts/duties/code-reviewer.json +5 -3
  13. package/runtime/prompts/wizard/prompts.ko.json +10 -0
  14. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
  15. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
  16. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
  17. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
  18. package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
  19. package/runtime/python/okstra_ctl/code_review_coverage.py +213 -0
  20. package/runtime/python/okstra_ctl/code_review_target.py +31 -2
  21. package/runtime/python/okstra_ctl/coverage_census.py +2 -2
  22. package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
  23. package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
  24. package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
  25. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  26. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
  27. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
  28. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +164 -33
  29. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +27 -3
  30. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
  31. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
  32. package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
  33. package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
  34. package/runtime/python/okstra_ctl/plan_items.py +12 -9
  35. package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
  36. package/runtime/python/okstra_ctl/render.py +2 -0
  37. package/runtime/python/okstra_ctl/run.py +23 -2
  38. package/runtime/python/okstra_ctl/team.py +12 -18
  39. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
  40. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
  41. package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
  42. package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
  43. package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
  44. package/runtime/python/okstra_ctl/wizard/registry.py +13 -0
  45. package/runtime/python/okstra_ctl/wizard/render.py +1 -0
  46. package/runtime/python/okstra_ctl/wizard/state.py +8 -0
  47. package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
  48. package/runtime/python/okstra_ctl/wizard/steps_options.py +52 -1
  49. package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
  50. package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
  51. package/runtime/python/okstra_ctl/worker_request.py +12 -1
  52. package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
  53. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  54. package/runtime/schemas/final-report-v3.0.schema.json +3 -1
  55. package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
  56. package/runtime/skills/_fragments/host-question-interface.md +1 -0
  57. package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
  58. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
  59. package/runtime/skills/_fragments/wizard-preflight.md +1 -1
  60. package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
  61. package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
  62. package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
  63. package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
  64. package/runtime/skills/okstra-chat/SKILL.md +1 -1
  65. package/runtime/skills/okstra-code-review/SKILL.md +26 -15
  66. package/runtime/skills/okstra-code-review/references/census-rules.md +38 -2
  67. package/runtime/skills/okstra-code-review/references/review-calibration.md +39 -15
  68. package/runtime/skills/okstra-container-build/SKILL.md +6 -2
  69. package/runtime/skills/okstra-discussion/SKILL.md +6 -6
  70. package/runtime/skills/okstra-inspect/SKILL.md +5 -3
  71. package/runtime/skills/okstra-manager/SKILL.md +8 -4
  72. package/runtime/skills/okstra-memory/SKILL.md +1 -1
  73. package/runtime/skills/okstra-pr-gen/SKILL.md +10 -6
  74. package/runtime/skills/okstra-rollup/SKILL.md +6 -2
  75. package/runtime/skills/okstra-run/SKILL.md +8 -7
  76. package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
  77. package/runtime/skills/okstra-setup/SKILL.md +8 -4
  78. package/runtime/skills/okstra-setup/references/project-config.md +3 -3
  79. package/runtime/skills/okstra-usage/SKILL.md +5 -1
  80. package/runtime/skills/okstra-user-response/SKILL.md +7 -3
@@ -35,6 +35,7 @@ from .dispatch_core import (
35
35
  DispatchPlan,
36
36
  await_dispatches,
37
37
  dispatch_plan,
38
+ existing_dispatch_plan as _plan_for_existing,
38
39
  )
39
40
  from .dispatch_checkpoints import (
40
41
  record_collect_checkpoints,
@@ -257,6 +258,11 @@ def _await(args) -> int:
257
258
  timeout_seconds=args.timeout_seconds,
258
259
  heartbeat_seconds=0 if args.json else args.heartbeat_seconds,
259
260
  )
261
+ from .phases.implementation_planning.round_completion import complete_ready_plan_round
262
+
263
+ completion = complete_ready_plan_round(plan) if code == 0 else None
264
+ if completion and completion["status"] == "failed":
265
+ code = 2
260
266
  if should_record:
261
267
  exit_state = _load_json(plan.team_state_path, "team-state")
262
268
  lines.extend(record_collect_checkpoints(
@@ -272,11 +278,16 @@ def _await(args) -> int:
272
278
  if args.json:
273
279
  payload = _await_payload(plan, code == 0)
274
280
  payload["progressLines"] = lines
281
+ if completion:
282
+ payload["planVerification"] = completion
275
283
  _print_json(payload)
276
284
  else:
277
285
  for line in lines:
278
286
  print(line)
279
- print("ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
287
+ if completion:
288
+ print(json.dumps({"planVerification": completion}, ensure_ascii=False))
289
+ print("PLAN_VERIFICATION_FAILED" if completion and completion["status"] == "failed"
290
+ else "ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
280
291
  return code
281
292
 
282
293
 
@@ -402,23 +413,6 @@ def _observe_lead_session_from_manifest(
402
413
  observe_lead_session(project_root, team_state_path)
403
414
 
404
415
 
405
- def _plan_for_existing(
406
- project_root: Path, workspace_root: Path, run_manifest_path: Path, manifest: Mapping[str, Any]
407
- ) -> DispatchPlan:
408
- root = project_root.resolve()
409
- manifest_path = _resolve_project_path(root, str(run_manifest_path))
410
- return DispatchPlan(
411
- project_root=root,
412
- workspace_root=workspace_root.resolve(),
413
- manifest_path=manifest_path,
414
- team_state_path=_resolve_project_path(root, _require_string(manifest, "teamStatePath")),
415
- lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
416
- manifest=manifest,
417
- jobs=(),
418
- default_backend=_manifest_backend(manifest),
419
- )
420
-
421
-
422
416
  def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
423
417
  team_state = _load_json(plan.team_state_path, "team-state")
424
418
  dispatches = [d for d in team_state.get("workerDispatches", []) if isinstance(d, dict)]
@@ -17,6 +17,7 @@ refusing them costs a dispatch cycle and yields no information. A bare
17
17
  from __future__ import annotations
18
18
 
19
19
  import re
20
+ import json
20
21
  from dataclasses import dataclass
21
22
 
22
23
  VERDICT_TOKENS = frozenset({
@@ -64,7 +65,7 @@ _ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
64
65
  # `_field_match` enforces that an opening `**` is closed exactly once and that
65
66
  # a label without bold carries a bullet.
66
67
  _FIELD_KEYS = ("Verdict", "Fixability", "Note", "Explanation", "Prior dissent",
67
- "Basis", "Your evidence")
68
+ "Basis", "Your evidence", "Dependencies")
68
69
  _CANONICAL_KEYS = {key.lower(): key for key in _FIELD_KEYS}
69
70
  _FIELD_RE = re.compile(
70
71
  r"^(?P<bullet>[-*+][ \t]+)?(?P<open>\*\*)?"
@@ -234,6 +235,7 @@ class VerdictBlock:
234
235
  note: str
235
236
  explanation: str
236
237
  prior_dissent: str
238
+ dependencies: dict | None = None
237
239
 
238
240
 
239
241
  def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
@@ -269,6 +271,19 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
269
271
  f"`{fixability or '(missing)'}` — expected one of "
270
272
  f"{sorted(FIXABILITY_VALUES)}"
271
273
  )
274
+ dependencies = None
275
+ if fields.get("Dependencies"):
276
+ try:
277
+ candidate = json.loads(fields["Dependencies"])
278
+ if (isinstance(candidate, dict) and isinstance(candidate.get("complete"), bool)
279
+ and all(isinstance(candidate.get(key), list)
280
+ and all(isinstance(value, str) and value for value in candidate[key])
281
+ for key in ("itemIds", "paths"))):
282
+ dependencies = {key: candidate[key] for key in ("complete", "itemIds", "paths")}
283
+ else:
284
+ dependencies = {"complete": False, "itemIds": [], "paths": []}
285
+ except (ValueError, TypeError):
286
+ dependencies = {"complete": False, "itemIds": [], "paths": []}
272
287
  return VerdictBlock(
273
288
  item_id=item_id,
274
289
  verdict=token,
@@ -277,4 +292,5 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
277
292
  note=fields.get("Note", ""),
278
293
  explanation=fields.get("Explanation", ""),
279
294
  prior_dissent=fields.get("Prior dissent", ""),
295
+ dependencies=dependencies,
280
296
  )
@@ -68,6 +68,7 @@ from .ids import (
68
68
  S_HANDOFF_STAGE_PICK,
69
69
  S_PROJECT_EVIDENCE,
70
70
  S_PROJECT_EVIDENCE_PICK,
71
+ S_REASONING_EFFORT,
71
72
  S_PR_TEMPLATE,
72
73
  S_PR_TEMPLATE_PICK,
73
74
  S_PR_TEMPLATE_SCOPE,
@@ -333,6 +333,8 @@ def confirmation_block(state: WizardState) -> str:
333
333
  lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
334
334
  if state.fix_cycle:
335
335
  lines.append(f" fix-cycle : {state.fix_cycle}")
336
+ if state.reasoning_effort:
337
+ lines.append(f" reasoning : {state.reasoning_effort}")
336
338
  lines.append(_msg(
337
339
  state.workspace_root, "confirmation", "provider_data_scope",
338
340
  project_root=state.project_root,
@@ -123,20 +123,41 @@ def _apply_answer(state: WizardState, prompt: Prompt, value: str) -> str:
123
123
  return echo or ""
124
124
 
125
125
 
126
+ def _automatic_answers(state: WizardState, prompt: Prompt) -> list[tuple[Prompt, str]]:
127
+ """Answers the wizard takes without showing the screen.
128
+
129
+ A one-line pick takes its line. A pick whose builder set `auto_value` takes it
130
+ unless the user came back to that step through Edit; a group takes each such
131
+ member and shows the rest.
132
+ """
133
+ if _is_lone_choice_pick(prompt):
134
+ return [(prompt, prompt.options[0].value)]
135
+ members = prompt.questions if prompt.kind == "pick_group" else [prompt]
136
+ return [
137
+ (member, member.auto_value)
138
+ for member in members
139
+ if member.kind == "pick"
140
+ and member.auto_value
141
+ and member.step not in state.manual_steps
142
+ ]
143
+
144
+
126
145
  def next_prompt(state: WizardState) -> Prompt:
127
- # 한 줄짜리 picker 를 건너뛰면 그 자리에 다음 화면이 온다. 그것도 한 줄짜리일
128
- # 수 있어 반복하되, 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
146
+ # 자동으로 답한 화면 자리에 다음 화면이 온다. 그것도 자동일 수 있어 반복하되,
147
+ # 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
129
148
  taken: set[str] = set()
130
149
  while True:
131
150
  prompt = _next_prompt_screen(state)
132
- if prompt.step in taken or not _is_lone_choice_pick(prompt):
151
+ answers = [] if prompt.step in taken else _automatic_answers(state, prompt)
152
+ if not answers:
133
153
  return _native_picker_screen(state, prompt)
134
154
  taken.add(prompt.step)
135
155
  attempt = copy.deepcopy(state)
136
156
  try:
137
- _apply_answer(attempt, prompt, prompt.options[0].value)
157
+ for member, value in answers:
158
+ _apply_answer(attempt, member, value)
138
159
  except WizardError:
139
- # 그 한 줄이 거절되면 사용자에게 보여 거절 사유를 받게 한다.
160
+ # 자동 답이 거절되면 사용자에게 보여 거절 사유를 받게 한다.
140
161
  return _native_picker_screen(state, prompt)
141
162
  state.__dict__.update(attempt.__dict__)
142
163
 
@@ -238,6 +238,9 @@ S_PR_TEMPLATE_SCOPE = "pr_template_scope"
238
238
  S_FIX_CYCLE_CONFIRM = "fix_cycle_confirm"
239
239
 
240
240
 
241
+ S_REASONING_EFFORT = "reasoning_effort"
242
+
243
+
241
244
  S_CONFIRM = "confirm"
242
245
 
243
246
 
@@ -58,6 +58,7 @@ from .ids import (
58
58
  S_PROJECT_EVIDENCE_PICK,
59
59
  S_PR_TEMPLATE,
60
60
  S_PR_TEMPLATE_PICK,
61
+ S_REASONING_EFFORT,
61
62
  S_PR_TEMPLATE_SCOPE,
62
63
  S_RELATED_TASKS,
63
64
  S_RELATED_TASKS_PICK,
@@ -169,6 +170,9 @@ from .steps_plan import (
169
170
  _submit_stage_pick,
170
171
  )
171
172
  from .steps_options import (
173
+ _build_reasoning_effort,
174
+ _reasoning_effort_required,
175
+ _submit_reasoning_effort,
172
176
  _report_language_required,
173
177
  _build_report_language,
174
178
  _submit_report_language,
@@ -243,6 +247,8 @@ def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
243
247
  _reset_from(state, value)
244
248
  else:
245
249
  raise WizardError(f"unknown step: {value!r}")
250
+ if value not in state.manual_steps:
251
+ state.manual_steps.append(value)
246
252
  state.edit_target = value
247
253
  state.confirmed = None
248
254
  state.edit_target = ""
@@ -514,6 +520,13 @@ STEPS: list[Step] = [
514
520
  and S_DIRECTIVE not in s.answered),
515
521
  build=_build_directive, submit=_submit_directive,
516
522
  owns=("directive", "directive_pending_text")),
523
+ # 역할 화면과 directive 뒤 — 이 run 이 띄울 워커가 정해진 다음에 묻는다.
524
+ Step(S_REASONING_EFFORT,
525
+ applies=lambda s: (S_REASONING_EFFORT not in s.answered
526
+ and not s.directive_pending_text
527
+ and _reasoning_effort_required(s)),
528
+ build=_build_reasoning_effort, submit=_submit_reasoning_effort,
529
+ owns=("reasoning_effort",)),
517
530
  Step(S_RELATED_TASKS_PICK,
518
531
  applies=lambda s: (_identity_ready(s)
519
532
  and S_RELATED_TASKS_PICK not in s.answered),
@@ -118,6 +118,7 @@ def render_args(state: WizardState) -> dict[str, Any]:
118
118
  ),
119
119
  "pr-template-path": pr_template,
120
120
  "fix-cycle": state.fix_cycle,
121
+ "reasoning-effort": state.reasoning_effort,
121
122
  "host-session-context-json": serialize_host_session_context(
122
123
  _host_session_context(state)
123
124
  ),
@@ -132,6 +132,8 @@ class WizardState:
132
132
  # confirm / edit
133
133
  # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
134
134
  fix_cycle: str = ""
135
+ # `REASONING_EFFORTS` 중 하나. 워커가 정해지기 전에는 비어 있다.
136
+ reasoning_effort: str = ""
135
137
  confirmed: Optional[bool] = None
136
138
  confirmation_stages: str = ""
137
139
  confirmation_prompt: str = ""
@@ -143,6 +145,8 @@ class WizardState:
143
145
 
144
146
  # bookkeeping
145
147
  answered: list[str] = field(default_factory=list)
148
+ # Steps the user came back to through Edit; their auto_value no longer applies.
149
+ manual_steps: list[str] = field(default_factory=list)
146
150
 
147
151
  def to_json(self) -> dict[str, Any]:
148
152
  payload = asdict(self)
@@ -181,6 +185,9 @@ class Prompt:
181
185
  multi: bool = False # only meaningful when kind == "pick"
182
186
  # only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
183
187
  questions: list["Prompt"] = field(default_factory=list)
188
+ # The answer the wizard takes without asking, because it comes from a recorded
189
+ # source; the confirm screen shows it and its Edit menu asks for it again.
190
+ auto_value: str = ""
184
191
 
185
192
  def __post_init__(self) -> None:
186
193
  """자유 입력 선택지는 언제나 마지막이다.
@@ -622,6 +629,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
622
629
  "pr_template_path": "", "pr_template_pending_text": False,
623
630
  "pr_template_scope": "",
624
631
  "fix_cycle": "",
632
+ "reasoning_effort": "",
625
633
  "confirmed": None, "edit_target": "", "confirmation_prompt": "", "confirmation_stages": "",
626
634
  "confirmation_scope": {}, "user_authorization": {},
627
635
  }
@@ -6,12 +6,15 @@ from pathlib import Path
6
6
  from collections.abc import Mapping
7
7
  from typing import Any, Optional
8
8
 
9
- from okstra_ctl import next_phase
9
+ from okstra_ctl import next_phase, worktree_registry
10
+ from okstra_ctl.consumers import latest_done_by_stage, read_consumers
10
11
  from okstra_ctl.ids import slugify_task_segment
12
+ from okstra_ctl.paths import RunRef
11
13
  from okstra_ctl.group_context import (
12
14
  QUEUE_DONE,
13
15
  QUEUE_IN_PROGRESS,
14
16
  QUEUE_NOT_STARTED,
17
+ group_briefs,
15
18
  group_context_file,
16
19
  group_queue,
17
20
  is_group_context_file,
@@ -79,15 +82,18 @@ def _validate_base_ref(ref: str, project_root: Path) -> str:
79
82
  ref = (ref or "").strip()
80
83
  if not ref:
81
84
  raise WizardError("base ref is empty")
82
- main_wt = _git_main_worktree(project_root)
85
+ if not _ref_resolves(_git_main_worktree(project_root), ref):
86
+ raise WizardError(f"base ref not found in repository: {ref}")
87
+ return ref
88
+
89
+
90
+ def _ref_resolves(main_wt: Path, ref: str) -> bool:
83
91
  rc = subprocess.call(
84
92
  ["git", "-C", str(main_wt), "rev-parse",
85
93
  "--verify", "--quiet", f"{ref}^{{commit}}"],
86
94
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
87
95
  )
88
- if rc != 0:
89
- raise WizardError(f"base ref not found in repository: {ref}")
90
- return ref
96
+ return rc == 0
91
97
 
92
98
 
93
99
  # --- builders ---
@@ -605,8 +611,12 @@ def _build_task_type(state: WizardState) -> Prompt:
605
611
  brief_phase = _brief_recommended_task_type(state) if fresh_task else ""
606
612
  recommended = (revision_requested or state.task_type
607
613
  or _recommended_task_type(state, manifest))
614
+ # Only a recommendation okstra recorded is taken without asking: the pointer,
615
+ # a revision request, or the brief's own entry phase.
616
+ auto_value = recommended
608
617
  if not recommended and not workflow:
609
618
  recommended = state.requested_task_type or brief_phase or TASK_TYPE_VALUES[0]
619
+ auto_value = recommended if recommended in (state.requested_task_type, brief_phase) else ""
610
620
  if brief_phase and recommended == brief_phase:
611
621
  recommended_suffix = t["options"].get("_BRIEF_SUFFIX", recommended_suffix)
612
622
  pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
@@ -638,7 +648,8 @@ def _build_task_type(state: WizardState) -> Prompt:
638
648
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
639
649
  return Prompt(step=S_TASK_TYPE, kind="pick",
640
650
  label=t["label"], options=options,
641
- echo_template=t["echo_template"])
651
+ echo_template=t["echo_template"],
652
+ auto_value=auto_value)
642
653
 
643
654
 
644
655
  def _carry_in_existing_brief(state: WizardState) -> str:
@@ -835,28 +846,100 @@ def _build_brief_carry(state: WizardState) -> Prompt:
835
846
  )
836
847
 
837
848
 
838
- def _checked_out_canonical_ref(project_root: Path) -> str:
839
- """The main worktree's branch when it is one of the listed base refs, else ''.
849
+ def _checked_out_branch(main_wt: Path) -> str:
850
+ """The main worktree's branch, or '' when HEAD is detached.
840
851
 
841
852
  Projects differ in their integration branch (main vs preprod), and nothing in
842
853
  okstra records it; the branch the user keeps checked out is the observed hint.
843
854
  """
844
- try:
845
- main_wt = _git_main_worktree(project_root)
846
- except WizardError:
847
- return ""
848
855
  result = subprocess.run(
849
856
  ["git", "-C", str(main_wt), "symbolic-ref", "--quiet", "--short", "HEAD"],
850
857
  capture_output=True, text=True,
851
858
  )
852
- branch = result.stdout.strip() if result.returncode == 0 else ""
853
- return branch if branch in CANONICAL_BASE_REFS else ""
859
+ return result.stdout.strip() if result.returncode == 0 else ""
860
+
861
+
862
+ def _recorded_stage_branches(state: WizardState, task_id: str) -> list[str]:
863
+ """A task's stage branches, top of the stack first: PR heads, then done stages.
864
+
865
+ release-handoff `record-pr` writes a `pr` row per stage PR; a task that never
866
+ reached handoff only has `done` rows, whose branch lives in the worktree
867
+ registry's stage row (kept after the stage is released).
868
+ """
869
+ key = f"{state.project_id}:{state.task_group}:{task_id}"
870
+ task_root = find_task_root(Path(state.project_root), key)
871
+ if task_root is None:
872
+ return []
873
+ rows = read_consumers(RunRef.from_task_root(task_root, "implementation-planning").run_dir)
874
+ prs = [row for row in rows if row.get("status") == "pr" and row.get("branch")]
875
+ prs.sort(key=lambda row: max(row.get("stages") or [0]), reverse=True)
876
+ branches = [str(row["branch"]) for row in prs]
877
+ for stage in sorted(latest_done_by_stage(rows), reverse=True):
878
+ stage_row = worktree_registry.get_stage_row(
879
+ state.project_id, state.task_group, task_id, stage) or {}
880
+ if stage_row.get("branch"):
881
+ branches.append(str(stage_row["branch"]))
882
+ return branches
883
+
884
+
885
+ def _predecessor_base_ref(state: WizardState, main_wt: Path) -> tuple[str, bool]:
886
+ """(branch, succession) from the nearest earlier task in the group's start order.
887
+
888
+ A follow-up task in a group usually builds on the previous task's unmerged
889
+ stage branches. `succession` is True when okstra records that this task
890
+ follows that one: it is the next row in the start order (what the
891
+ predecessor's closeout named as `nextInGroup`), or this task's brief waits
892
+ for that ticket in its Related Task Graph.
893
+ """
894
+ if not (state.project_root and state.task_group and state.task_id):
895
+ return "", False
896
+ project_root = Path(state.project_root)
897
+ queue = _group_start_order(project_root, state.task_group)
898
+ brief = _project_relative_path(Path(state.brief_path), project_root) if state.brief_path else ""
899
+ current = next(
900
+ (index for index, row in enumerate(queue)
901
+ if row.task_id == state.task_id or (brief and row.brief == brief)),
902
+ None,
903
+ )
904
+ if current is None:
905
+ return "", False
906
+ waits: set[str] = set()
907
+ for item in group_briefs(project_root, state.task_group):
908
+ waits.update(item["waits_for"].get(queue[current].ticket_id, set()))
909
+ for index in range(current - 1, -1, -1):
910
+ row = queue[index]
911
+ branch = next(
912
+ (b for b in _recorded_stage_branches(state, row.task_id) if _ref_resolves(main_wt, b)),
913
+ "",
914
+ )
915
+ if branch:
916
+ return branch, index == current - 1 or row.ticket_id in waits
917
+ return "", False
918
+
919
+
920
+ def _base_ref_candidates(state: WizardState) -> tuple[list[str], str]:
921
+ """(refs in display order, recommended ref or '')."""
922
+ try:
923
+ main_wt = _git_main_worktree(Path(state.project_root))
924
+ except WizardError:
925
+ return list(CANONICAL_BASE_REFS), ""
926
+ predecessor, succession = _predecessor_base_ref(state, main_wt)
927
+ checked_out = _checked_out_branch(main_wt)
928
+ derived = [predecessor] if predecessor else []
929
+ if (checked_out and checked_out not in CANONICAL_BASE_REFS
930
+ and checked_out not in derived and _ref_resolves(main_wt, checked_out)):
931
+ derived.append(checked_out)
932
+ if succession:
933
+ recommended = predecessor
934
+ else:
935
+ recommended = checked_out if checked_out in CANONICAL_BASE_REFS else ""
936
+ refs = derived + [r for r in CANONICAL_BASE_REFS if r not in derived]
937
+ return sorted(refs, key=lambda r: r != recommended), recommended
854
938
 
855
939
 
856
940
  def _build_base_ref_pick(state: WizardState) -> Prompt:
857
941
  t = _p(state.workspace_root, "base_ref_pick")
858
- recommended = _checked_out_canonical_ref(Path(state.project_root))
859
- refs = sorted(CANONICAL_BASE_REFS, key=lambda r: r != recommended)
942
+ refs, recommended = _base_ref_candidates(state)
860
943
  options = [_opt(r, recommended=r == recommended) for r in refs]
861
944
  for value, label in _static_options(t):
862
945
  options.append(_opt(value=value, label=label))
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from typing import Callable, Optional
8
8
 
9
9
  from okstra_ctl.clarification_items import user_response_sidecars
10
+ from okstra_ctl.domain.worker_exec import REASONING_EFFORTS
10
11
  from okstra_ctl.manager_paths import MANAGER_CONTEXT_DIRECTIVE_PREFIX
11
12
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
12
13
  from okstra_ctl.report_language import is_language_tag
@@ -25,6 +26,7 @@ from .ids import (
25
26
  S_PR_TEMPLATE,
26
27
  S_PR_TEMPLATE_PICK,
27
28
  S_PR_TEMPLATE_SCOPE,
29
+ S_REASONING_EFFORT,
28
30
  S_RELATED_TASKS,
29
31
  S_RELATED_TASKS_PICK,
30
32
  S_REPORT_LANGUAGE,
@@ -33,7 +35,14 @@ from .ids import (
33
35
  _REUSE_LAST_TOKEN,
34
36
  _SIBLINGS_TOKEN,
35
37
  )
36
- from .state import Option, Prompt, WizardError, WizardState
38
+ from .state import (
39
+ Option,
40
+ Prompt,
41
+ WizardError,
42
+ WizardState,
43
+ _identity_ready,
44
+ _load_role_profile_for_state,
45
+ )
37
46
  from .prompts import _opt, _p, _pick_snippet
38
47
  from .sources import _latest_run_inputs, _require_file, _suggest_latest_final_report
39
48
 
@@ -120,6 +129,9 @@ class _OptionalCachedPickSpec:
120
129
  # 추천 옵션 라벨·echo 뒤에 덧붙일 동적 안내(예: 함께 첨부될 사이드카 개수).
121
130
  # (state, 추천 relpath) -> note. 미설정 picker 는 영향 없음.
122
131
  recommend_note: Optional[Callable[["WizardState", str], str]] = None
132
+ # Whether the wizard takes the suggestion without asking. A previous directive
133
+ # is an instruction written for another run, so reusing it stays a question.
134
+ auto_take_suggestion: bool = True
123
135
 
124
136
 
125
137
  def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
@@ -137,10 +149,17 @@ def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpe
137
149
  setattr(state, spec.cache_attr, suggestion)
138
150
  options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
139
151
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
152
+ if not suggestion:
153
+ auto_value = PICK_SKIP
154
+ elif spec.auto_take_suggestion:
155
+ auto_value = spec.recommend_token
156
+ else:
157
+ auto_value = ""
140
158
  return Prompt(
141
159
  step=spec.step, kind="pick",
142
160
  label=t["label"], options=options,
143
161
  echo_template=t["echo_template"],
162
+ auto_value=auto_value,
144
163
  )
145
164
 
146
165
 
@@ -199,6 +218,7 @@ _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
199
218
  cache_attr="last_directive_cached", target_attr="directive",
200
219
  pending_attr="directive_pending_text",
201
220
  recommend_note=_directive_context_note,
221
+ auto_take_suggestion=False,
202
222
  )
203
223
 
204
224
 
@@ -417,3 +437,34 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
417
437
  )
418
438
  state.pr_template_scope = value
419
439
  return f"pr-template-scope: {value}"
440
+
441
+
442
+ _RECOMMENDED_REASONING_EFFORT = "high"
443
+
444
+
445
+ def _reasoning_effort_required(state: WizardState) -> bool:
446
+ """워커를 띄우지 않는 프로필(release-handoff)에는 강도를 넘길 CLI 가 없다."""
447
+ return _identity_ready(state) and bool(_load_role_profile_for_state(state).roles)
448
+
449
+
450
+ def _build_reasoning_effort(state: WizardState) -> Prompt:
451
+ t = _p(state.workspace_root, "reasoning_effort")
452
+ others = [level for level in REASONING_EFFORTS if level != _RECOMMENDED_REASONING_EFFORT]
453
+ return Prompt(
454
+ step=S_REASONING_EFFORT, kind="pick", label=t["label"],
455
+ options=[
456
+ _opt(_RECOMMENDED_REASONING_EFFORT,
457
+ t["options"][_RECOMMENDED_REASONING_EFFORT], recommended=True),
458
+ *(_opt(level, t["options"][level]) for level in others),
459
+ ],
460
+ echo_template=t["echo_template"])
461
+
462
+
463
+ def _submit_reasoning_effort(state: WizardState, value: str) -> Optional[str]:
464
+ level = value.strip().lower()
465
+ if level not in REASONING_EFFORTS:
466
+ raise WizardError(
467
+ f"expected one of {', '.join(REASONING_EFFORTS)}, got: {value!r}"
468
+ )
469
+ state.reasoning_effort = level
470
+ return f"reasoning-effort: {level}"
@@ -276,6 +276,7 @@ def _build_approved_plan_pick(state: WizardState) -> Prompt:
276
276
  step=S_APPROVED_PLAN_PICK, kind="pick",
277
277
  label=label, options=options,
278
278
  echo_template=t["echo_template"],
279
+ auto_value=PICK_USE_DEFAULT if default is not None else "",
279
280
  )
280
281
 
281
282
 
@@ -341,9 +342,22 @@ def _build_approve_plan_confirm(state: WizardState) -> Prompt:
341
342
  for k, v in options_map.items()
342
343
  ],
343
344
  echo_template=t["echo_template"],
345
+ auto_value="yes" if _already_approved(state) else "",
344
346
  )
345
347
 
346
348
 
349
+ def _already_approved(state: WizardState) -> bool:
350
+ """An approved plan with no exported approval sidecar leaves nothing to decide."""
351
+ if state.html_approval_sidecar or not state.approve_plan_candidate:
352
+ return False
353
+ try:
354
+ _, fully_approved = _classify_approved_plan(
355
+ state.approve_plan_candidate, Path(state.project_root))
356
+ except (PrepareError, WizardError):
357
+ return False
358
+ return fully_approved
359
+
360
+
347
361
  def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
348
362
  allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
349
363
  if value not in allowed:
@@ -45,10 +45,19 @@ def main(argv: Sequence[str] | None = None) -> int:
45
45
  _print_json(_backend_payload(backend_plan, dry_run=True))
46
46
  return 0
47
47
  code, lines = _dispatch_recording_checkpoints(backend_plan)
48
+ from .phases.implementation_planning.round_completion import complete_ready_plan_round
49
+ from .worker_prompt_policy import is_plan_verify_dispatch_kind
50
+
51
+ completion = complete_ready_plan_round(backend_plan) if code == 0 and any(
52
+ is_plan_verify_dispatch_kind(job.dispatch_kind) for job in backend_plan.jobs
53
+ ) else None
54
+ if completion and completion["status"] == "failed":
55
+ code = 2
48
56
  _print_json({
49
57
  **_backend_payload(backend_plan, dry_run=False),
50
58
  "exitCode": code,
51
59
  "progressLines": lines,
60
+ **({"planVerification": completion} if completion else {}),
52
61
  })
53
62
  return code
54
63
  except DispatchError as exc:
@@ -12,7 +12,11 @@ import os
12
12
  import subprocess
13
13
  from pathlib import Path
14
14
 
15
- from .domain.worker_exec import ExecutionPolicy, WorkerExecRequest
15
+ from .domain.worker_exec import (
16
+ REASONING_EFFORTS,
17
+ ExecutionPolicy,
18
+ WorkerExecRequest,
19
+ )
16
20
  from .domain.worker_role import role_spec
17
21
  from .write_policy import WriteEnforcement, WritePolicy
18
22
 
@@ -39,6 +43,7 @@ def build_request(
39
43
  session_id: str = "",
40
44
  write_policy: WritePolicy | None = None,
41
45
  write_enforcement: WriteEnforcement | None = None,
46
+ reasoning_effort: str = "",
42
47
  ) -> WorkerExecRequest:
43
48
  """One dispatch, with every value the strategies will not re-derive.
44
49
 
@@ -46,6 +51,11 @@ def build_request(
46
51
  strategies hand what they are given straight to a CLI, so an unresolved
47
52
  `..` or symlink would survive into the sandbox boundary itself.
48
53
  """
54
+ if reasoning_effort and reasoning_effort not in REASONING_EFFORTS:
55
+ raise ValueError(
56
+ f"reasoning effort must be one of {', '.join(REASONING_EFFORTS)}: "
57
+ f"{reasoning_effort!r}"
58
+ )
49
59
  root = project_root.resolve()
50
60
  worktree = worktree_path.resolve() if worktree_path is not None else None
51
61
  return WorkerExecRequest(
@@ -64,6 +74,7 @@ def build_request(
64
74
  ),
65
75
  idle_timeout_seconds=idle_timeout_seconds,
66
76
  session_id=session_id,
77
+ reasoning_effort=reasoning_effort,
67
78
  )
68
79
 
69
80
 
@@ -18,6 +18,7 @@
18
18
  "schemaVersion": {"const": "2.0"},
19
19
  "executionIdentityVersion": {"const": 2},
20
20
  "entryMode": {"enum": ["current-session", "new-session"]},
21
+ "reasoningEffort": {"enum": ["", "low", "medium", "high", "xhigh"]},
21
22
  "participantAssignments": {
22
23
  "type": "array",
23
24
  "items": {"$ref": "#/$defs/ParticipantAssignment"}