okstra 0.91.1 → 0.92.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.91.1",
3
+ "version": "0.92.1",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.91.1",
3
- "builtAt": "2026-06-18T06:51:56.885Z",
2
+ "package": "0.92.1",
3
+ "builtAt": "2026-06-18T08:35:38.504Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -358,7 +358,7 @@
358
358
  }
359
359
  },
360
360
  "defaults_or_custom": {
361
- "label": "역할별 모델·실행 옵션 단계입니다 (참여 워커 구성을 바꾸는 게 아닙니다).\n이번 run 워커: {workers}\n· 기본값으로 진행 — 모든 역할을 추천 모델로 둡니다. 추천 기본값: lead·report-writer=opus, claude=opus, codex=gpt-5.5, antigravity=auto (실제 값은 runtime 기본값으로 해소). directive·관련 task 없이 바로 진행.\n· 커스터마이즈 — 역할별 모델을 직접 고르고, 추가 directive·관련 task 도 지정합니다.",
361
+ "label": "역할별 모델 선택 단계입니다 (참여 워커 구성을 바꾸는 게 아닙니다).\n이번 run 에서 모델을 고를 역할:\n{role_models}\n· 기본값으로 진행 — 추천 모델을 그대로 쓰고, directive·관련 task 없이 바로 넘어갑니다.\n· 커스터마이즈 — 역할별 모델을 직접 고르고, 추가 directive·관련 task 도 지정합니다.\n(추천 값은 runtime 기본값으로 해소됩니다. 분석에 참여하지 않는 역할은 위 목록에 나오지 않습니다 — 예: antigravity 는 implementation 의 executor 로 선택했을 때만 표시됩니다.)",
362
362
  "echo_template": "customize: {value}",
363
363
  "options": {
364
364
  "defaults": "기본값으로 진행 (역할별 추천 모델 그대로)",
@@ -0,0 +1,217 @@
1
+ """Implementation stage run orchestration.
2
+
3
+ This module owns the lifecycle for one ``implementation`` stage run: recover
4
+ consumer state, select an available Stage Map entry, provision the isolated
5
+ stage worktree, and publish the selected stage into run context.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import datetime as _dt
10
+ import subprocess
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import stage_targets
17
+ from .stage_reconcile import auto_reconcile_best_effort
18
+
19
+
20
+ class ImplementationStageError(Exception):
21
+ """Implementation stage selection or provisioning failed."""
22
+
23
+
24
+ @dataclass
25
+ class StageSelection:
26
+ """Resolved implementation stage and its isolated worktree coordinates."""
27
+
28
+ stage: int
29
+ worktree_path: str
30
+ worktree_branch: str
31
+ worktree_base_ref: str
32
+ worktree_status: str
33
+ worktree_note: str
34
+ started_head_commit: str
35
+ concurrent_stages: list[int] = field(default_factory=list)
36
+
37
+
38
+ def _git_out(cwd: str | Path, *args: str) -> str:
39
+ result = subprocess.run(
40
+ ["git", "-C", str(cwd), *args],
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ return result.stdout.strip() if result.returncode == 0 else ""
45
+
46
+
47
+ def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
48
+ if isinstance(exc, ImplementationStageError):
49
+ return exc
50
+ return ImplementationStageError(str(exc))
51
+
52
+
53
+ def select_and_provision_implementation_stage(
54
+ inp: Any,
55
+ ctx_stage_map: list[dict[str, Any]],
56
+ task_group_segment: str,
57
+ task_id_segment: str,
58
+ task_key: str,
59
+ executor_worktree_status: str,
60
+ ) -> StageSelection:
61
+ """Select a ready implementation stage and provision its stage worktree.
62
+
63
+ The returned selection is path-independent with respect to run artifacts:
64
+ callers can compute stage-specific run paths after this function resolves
65
+ the selected stage.
66
+ """
67
+ del task_key # The registry uses the split identity fields.
68
+
69
+ from .consumers import read_consumers, backfill_done_from_carry
70
+ from . import worktree as _worktree
71
+ from . import worktree_registry as _reg
72
+
73
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
74
+ backfill_done_from_carry(plan_run_root)
75
+ auto_reconcile_best_effort(inp, plan_run_root)
76
+ consumed = read_consumers(plan_run_root)
77
+ done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
78
+ started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
79
+ reserved_stages = _reg.list_active_stage_numbers(
80
+ inp.project_id,
81
+ inp.task_group,
82
+ inp.task_id,
83
+ )
84
+
85
+ try:
86
+ batch = stage_targets.resolve_effective_stages(
87
+ ctx_stage_map,
88
+ done_stages,
89
+ inp.stage,
90
+ started_stages=started_stages,
91
+ reserved_stages=reserved_stages,
92
+ )
93
+ except stage_targets.StageTargetError as exc:
94
+ raise _as_implementation_stage_error(exc) from exc
95
+
96
+ selected = batch[0]
97
+ concurrent_stages = sorted(reserved_stages - done_stages - {selected})
98
+
99
+ if executor_worktree_status.startswith("skipped"):
100
+ head = _git_out(inp.project_root, "rev-parse", "HEAD")
101
+ return StageSelection(
102
+ stage=selected,
103
+ worktree_path="",
104
+ worktree_branch="",
105
+ worktree_base_ref="",
106
+ worktree_status=executor_worktree_status,
107
+ worktree_note="",
108
+ started_head_commit=head,
109
+ concurrent_stages=concurrent_stages,
110
+ )
111
+
112
+ head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
113
+ if head_sha:
114
+ _reg.set_implementation_base(
115
+ inp.project_id,
116
+ inp.task_group,
117
+ inp.task_id,
118
+ head_sha,
119
+ )
120
+ anchor = _reg.get_implementation_base(
121
+ inp.project_id,
122
+ inp.task_group,
123
+ inp.task_id,
124
+ ) or ""
125
+
126
+ selected_stage = next(
127
+ s for s in ctx_stage_map if s["stage_number"] == selected
128
+ )
129
+ consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
130
+ try:
131
+ stage_base = stage_targets.resolve_stage_base_commit(
132
+ selected_stage,
133
+ consumer_done_rows,
134
+ anchor_base_commit=anchor,
135
+ candidate_base=head_sha,
136
+ project_root=Path(inp.project_root),
137
+ plan_run_root=plan_run_root,
138
+ )
139
+ except stage_targets.StageTargetError as exc:
140
+ raise _as_implementation_stage_error(exc) from exc
141
+
142
+ try:
143
+ prov = _worktree.provision_stage_worktree(
144
+ project_root=Path(inp.project_root),
145
+ project_id=inp.project_id,
146
+ task_group_segment=task_group_segment,
147
+ task_id_segment=task_id_segment,
148
+ work_category=inp.work_category,
149
+ stage_number=selected,
150
+ base_commit=stage_base,
151
+ )
152
+ except RuntimeError as exc:
153
+ from .git_reconcile import guidance
154
+
155
+ hint = guidance(
156
+ plan_run_root=plan_run_root,
157
+ project_id=inp.project_id,
158
+ task_group=inp.task_group,
159
+ task_id=inp.task_id,
160
+ work_category=inp.work_category,
161
+ )
162
+ raise ImplementationStageError(
163
+ f"stage worktree provisioning failed: {exc}\n{hint}"
164
+ ) from exc
165
+
166
+ return StageSelection(
167
+ stage=selected,
168
+ worktree_path=prov.path,
169
+ worktree_branch=prov.branch,
170
+ worktree_base_ref=prov.base_ref,
171
+ worktree_status=prov.status,
172
+ worktree_note=prov.note,
173
+ started_head_commit=prov.base_ref,
174
+ concurrent_stages=concurrent_stages,
175
+ )
176
+
177
+
178
+ def apply_implementation_stage(
179
+ inp: Any,
180
+ ctx: dict[str, Any],
181
+ ctx_stage_map: list[dict[str, Any]],
182
+ sel: StageSelection,
183
+ ) -> None:
184
+ """Publish a selected stage into run context and ``consumers.jsonl``."""
185
+ from .consumers import append_consumer
186
+
187
+ ctx["parsed_stage_map"] = ctx_stage_map
188
+ ctx["effective_stages"] = [sel.stage]
189
+ csv = str(sel.stage)
190
+ ctx["EFFECTIVE_STAGES"] = csv
191
+ ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
192
+ ctx["STAGE_BATCH_DIRECTIVE"] = (
193
+ f"- **Stage for this implementation run:** `{csv}`. "
194
+ "Execute exactly this Stage Map stage — this is the authoritative scope. "
195
+ "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
196
+ "and reserved this stage."
197
+ )
198
+ inp.stage = csv
199
+ print(f"selected stages: {csv}", file=sys.stdout)
200
+
201
+ if sel.worktree_status and not sel.worktree_status.startswith("skipped"):
202
+ ctx["EXECUTOR_WORKTREE_PATH"] = sel.worktree_path
203
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = sel.worktree_branch
204
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = sel.worktree_base_ref
205
+ ctx["EXECUTOR_WORKTREE_STATUS"] = sel.worktree_status
206
+ ctx["EXECUTOR_WORKTREE_NOTE"] = sel.worktree_note
207
+
208
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
209
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
210
+ append_consumer(
211
+ plan_run_root,
212
+ impl_task_key=ctx["TASK_KEY"],
213
+ stage=sel.stage,
214
+ status="started",
215
+ started_at=now,
216
+ head_commit=sel.started_head_commit,
217
+ )
@@ -89,6 +89,11 @@ from .workers import (
89
89
  )
90
90
  from .workflow import compute_workflow_state, load_phase_forbidden
91
91
  from .locks import worktree_provision_mutex
92
+ from . import stage_targets as _stage_targets
93
+ from . import implementation_stage as _implementation_stage
94
+ from .stage_reconcile import (
95
+ auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
96
+ )
92
97
  from .worktree import (
93
98
  WorktreeProvision,
94
99
  okstra_clean_gate_excludes,
@@ -418,7 +423,12 @@ def _validate_stage_structure(plan_path: str) -> None:
418
423
  )
419
424
 
420
425
 
421
- RUN_STEP_BUDGET = 8
426
+ RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
427
+ _FVTarget = _stage_targets.FinalVerificationTarget
428
+
429
+
430
+ def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> PrepareError:
431
+ return PrepareError(str(exc))
422
432
 
423
433
 
424
434
  def _resolve_effective_stages(
@@ -429,98 +439,40 @@ def _resolve_effective_stages(
429
439
  started_stages: set = None,
430
440
  reserved_stages: set = None,
431
441
  ) -> list:
432
- """Return the ordered list of stage numbers this run executes.
433
-
434
- `requested` is "auto" or a decimal string. For "auto" the run batches all
435
- ready stages (depends-on all done, itself neither done nor occupied) in
436
- stage-number order up to `budget` effective steps. A numeric request is a
437
- single forced stage. `started_stages`/`reserved_stages` are stages a
438
- concurrent run already holds (consumers.jsonl `started` and the registry
439
- stage reservations); they are excluded so two simultaneous runs never pick
440
- the same stage. Raises PrepareError on rejection cases."""
441
- started_stages = started_stages or set()
442
- reserved_stages = reserved_stages or set()
443
- occupied = done_stages | started_stages | reserved_stages
444
- if requested != "auto":
445
- try:
446
- n = int(requested)
447
- except ValueError:
448
- raise PrepareError(
449
- f"--stage must be 'auto' or an integer, got {requested!r}"
450
- )
451
- target = next((s for s in stages if s["stage_number"] == n), None)
452
- if target is None:
453
- raise PrepareError(
454
- f"--stage {n} not in Stage Map "
455
- f"(have {[s['stage_number'] for s in stages]})"
456
- )
457
- if n in done_stages:
458
- raise PrepareError(
459
- f"--stage {n} already completed (consumers.jsonl status:done exists)"
460
- )
461
- if n in started_stages or n in reserved_stages:
462
- raise PrepareError(
463
- f"--stage {n} already in progress or reserved by another run"
464
- )
465
- return [n]
466
-
467
- ready = [
468
- s for s in stages
469
- if s["stage_number"] not in occupied
470
- and all(d in done_stages for d in s["depends_on"])
471
- ]
472
- if not ready:
473
- raise PrepareError(
474
- "no stage is ready: every remaining stage has unsatisfied depends-on"
442
+ """Compatibility wrapper for the public stage-target policy module."""
443
+ try:
444
+ return _stage_targets.resolve_effective_stages(
445
+ stages,
446
+ done_stages,
447
+ requested,
448
+ budget=budget,
449
+ started_stages=started_stages,
450
+ reserved_stages=reserved_stages,
475
451
  )
476
- batch: list = []
477
- total = 0
478
- for s in ready:
479
- sc = s.get("step_count", 0) or 0
480
- if batch and total + sc > budget:
481
- break
482
- batch.append(s["stage_number"])
483
- total += sc
484
- return batch
452
+ except _stage_targets.StageTargetError as exc:
453
+ raise _stage_target_prepare_error(exc) from exc
485
454
 
486
455
 
487
456
  def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
488
- """True iff `ancestor` is an ancestor of `descendant` in project_root's git
489
- history (`git merge-base --is-ancestor`, exit 0). Used to detect whether a
490
- predecessor stage's done commit has been merged into the task worktree HEAD."""
491
- r = _subprocess.run(
492
- ["git", "merge-base", "--is-ancestor", ancestor, descendant],
493
- cwd=str(project_root), capture_output=True, text=True,
494
- )
495
- return r.returncode == 0
457
+ """Compatibility wrapper for tests and older internal callers."""
458
+ return _stage_targets.commit_is_ancestor(project_root, ancestor, descendant)
496
459
 
497
460
 
498
461
  def _check_multi_dep_merged(project_root, plan_run_root, latest,
499
462
  pred_commits: dict, candidate_base: str,
500
463
  stage_n: int) -> None:
501
- """모든 선행 done commit candidate 에 (내용 기준으로라도) 머지됐는지
502
- 검증. patch-equivalent 면 보정 row 를 자동 기록(다음 run 은 ancestor 로
503
- 바로 통과). 아니면 회복 안내를 담아 PrepareError."""
504
- from .git_reconcile import content_merged, _record_reconciled
505
- for d, head in pred_commits.items():
506
- if _commit_is_ancestor(project_root, head, candidate_base):
507
- continue
508
- match = content_merged(project_root, head, candidate_base)
509
- if match.status in ("ancestor", "patch-equivalent"):
510
- if plan_run_root is not None:
511
- _record_reconciled(
512
- plan_run_root,
513
- impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
514
- stage=d, new_commit=match.matched_commit,
515
- replaced=head, reason="auto-patch-id")
516
- continue
517
- raise PrepareError(
518
- f"multi-dependency stage {stage_n}: predecessor stage {d} "
519
- f"({head[:8]}) is not merged into the task worktree "
520
- f"({candidate_base[:8]}). Merge stage branches "
521
- f"(e.g. the `-s{d}` branches) into the task worktree "
522
- "(or into main, then refresh the worktree) and retry."
464
+ """Compatibility wrapper for the stage-target dependency gate."""
465
+ try:
466
+ _stage_targets.check_multi_dep_merged(
467
+ project_root,
468
+ plan_run_root,
469
+ latest,
470
+ pred_commits,
471
+ candidate_base,
472
+ stage_n,
523
473
  )
474
+ except _stage_targets.StageTargetError as exc:
475
+ raise _stage_target_prepare_error(exc) from exc
524
476
 
525
477
 
526
478
  def _resolve_stage_base_commit(
@@ -531,70 +483,18 @@ def _resolve_stage_base_commit(
531
483
  project_root=None,
532
484
  plan_run_root=None,
533
485
  ) -> str:
534
- """Pick the git base commit a stage's isolated worktree branches from.
535
-
536
- - 독립 (`depends-on (none)`): anchor (= implementation_base_commit fixed
537
- at first stage entry).
538
- - 단일 의존 (`depends-on X`): predecessor X 의 `done.head_commit`.
539
- - 다중 의존 (`depends-on X,Y…`): spec §9 옵션 A 자동 감지. candidate_base
540
- (= task-key worktree HEAD) 에 모든 선행 done commit 이 ancestor 면
541
- candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
542
-
543
- Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
544
- from .consumers import latest_done_by_stage
545
- latest = latest_done_by_stage(consumer_done_rows)
546
- deps = stage.get("depends_on") or []
547
- if len(deps) >= 2:
548
- n = stage["stage_number"]
549
- # 1) 모든 선행의 done head_commit 수집
550
- pred_commits = {}
551
- for d in deps:
552
- head = (latest.get(d) or {}).get("head_commit")
553
- if not head:
554
- raise PrepareError(
555
- f"predecessor stage {d} has no done row with head_commit "
556
- f"in consumers.jsonl; multi-dependency stage {n} cannot start"
557
- )
558
- pred_commits[d] = head
559
- # 2) candidate (task-key worktree HEAD) 필요
560
- if not candidate_base or project_root is None:
561
- raise PrepareError(
562
- f"candidate base missing for multi-dependency stage {n}; "
563
- "task-key worktree HEAD could not be resolved"
564
- )
565
- # 3) 모든 선행 done 이 candidate 에 (ancestor 또는 patch-equivalent 로)
566
- # 머지됐는지 — patch-equivalent 면 보정 row 를 자동 기록한다.
567
- _check_multi_dep_merged(project_root, plan_run_root, latest,
568
- pred_commits, candidate_base, n)
569
- return candidate_base
570
- if not deps:
571
- if not anchor_base_commit:
572
- raise PrepareError(
573
- f"anchor base commit missing for independent stage "
574
- f"{stage['stage_number']}; first-stage prepare should have "
575
- "fixed it via worktree_registry.set_implementation_base"
576
- )
577
- return anchor_base_commit
578
- # 단일 의존
579
- pred = deps[0]
580
- head = (latest.get(pred) or {}).get("head_commit") or ""
581
- if head:
582
- return head
583
- raise PrepareError(
584
- f"predecessor stage {pred} has no done row with head_commit in "
585
- "consumers.jsonl; cannot derive base for stage "
586
- f"{stage['stage_number']}"
587
- )
588
-
589
-
590
- @dataclass
591
- class _FVTarget:
592
- scope: str # "whole-task" | "single-stage"
593
- base: str
594
- head: str
595
- worktree_path: str
596
- stages: list # list[int]
597
- reports: list # list[str], report_path 값(없으면 "")
486
+ """Compatibility wrapper for stage worktree base selection."""
487
+ try:
488
+ return _stage_targets.resolve_stage_base_commit(
489
+ stage,
490
+ consumer_done_rows,
491
+ anchor_base_commit,
492
+ candidate_base=candidate_base,
493
+ project_root=project_root,
494
+ plan_run_root=plan_run_root,
495
+ )
496
+ except _stage_targets.StageTargetError as exc:
497
+ raise _stage_target_prepare_error(exc) from exc
598
498
 
599
499
 
600
500
  def _resolve_whole_task_target(
@@ -602,66 +502,37 @@ def _resolve_whole_task_target(
602
502
  task_worktree_path: str, task_head: str, task_dirty: bool,
603
503
  merged: dict,
604
504
  ) -> "_FVTarget":
605
- """전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
606
- worktree clean 이어야 한다. 위반 시 PrepareError."""
607
- from .consumers import latest_done_by_stage
608
- done_by_stage = latest_done_by_stage(done_rows)
609
- for s in stage_map:
610
- n = s["stage_number"]
611
- if n not in done_by_stage:
612
- raise PrepareError(
613
- f"final-verification(whole-task): stage {n} not done — "
614
- f"run implementation --stage {n} first"
615
- )
616
- if not merged.get(n, False):
617
- sha = done_by_stage[n].get("head_commit", "")
618
- raise PrepareError(
619
- f"final-verification(whole-task): stage {n} done commit "
620
- f"{sha} not merged into task worktree HEAD — merge stage "
621
- "branches then retry"
622
- )
623
- if task_dirty:
624
- raise PrepareError(
625
- "final-verification: worktree has uncommitted source changes "
626
- "(outside .okstra/) — commit or stash before verifying"
505
+ """Compatibility wrapper for whole-task verification target selection."""
506
+ try:
507
+ return _stage_targets.resolve_whole_task_target(
508
+ stage_map=stage_map,
509
+ done_rows=done_rows,
510
+ anchor_base=anchor_base,
511
+ task_worktree_path=task_worktree_path,
512
+ task_head=task_head,
513
+ task_dirty=task_dirty,
514
+ merged=merged,
627
515
  )
628
- stages = [s["stage_number"] for s in stage_map]
629
- reports = [done_by_stage[n].get("report_path", "") for n in stages]
630
- return _FVTarget(
631
- scope="whole-task", base=anchor_base, head=task_head,
632
- worktree_path=task_worktree_path, stages=stages, reports=reports,
633
- )
516
+ except _stage_targets.StageTargetError as exc:
517
+ raise _stage_target_prepare_error(exc) from exc
634
518
 
635
519
 
636
520
  def _resolve_single_stage_target(
637
521
  *, requested_stage: str, done_rows: list, stage_base: str,
638
522
  stage_worktree_path: str, stage_head: str, stage_dirty: bool,
639
523
  ) -> "_FVTarget":
640
- """단독-stage 검증 target. stage N done + stage worktree 존재 + clean.
641
- 다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
642
- from .consumers import latest_done_by_stage
643
- n = int(requested_stage)
644
- done_by_stage = latest_done_by_stage(done_rows)
645
- if n not in done_by_stage:
646
- raise PrepareError(
647
- f"final-verification(single-stage): stage {n} not done — "
648
- f"run implementation --stage {n} first"
649
- )
650
- if not stage_worktree_path:
651
- raise PrepareError(
652
- f"final-verification(single-stage): stage worktree not found for "
653
- f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
654
- )
655
- if stage_dirty:
656
- raise PrepareError(
657
- "final-verification: worktree has uncommitted source changes "
658
- "(outside .okstra/) — commit or stash before verifying"
524
+ """Compatibility wrapper for single-stage verification target selection."""
525
+ try:
526
+ return _stage_targets.resolve_single_stage_target(
527
+ requested_stage=requested_stage,
528
+ done_rows=done_rows,
529
+ stage_base=stage_base,
530
+ stage_worktree_path=stage_worktree_path,
531
+ stage_head=stage_head,
532
+ stage_dirty=stage_dirty,
659
533
  )
660
- return _FVTarget(
661
- scope="single-stage", base=stage_base, head=stage_head,
662
- worktree_path=stage_worktree_path, stages=[n],
663
- reports=[done_by_stage[n].get("report_path", "")],
664
- )
534
+ except _stage_targets.StageTargetError as exc:
535
+ raise _stage_target_prepare_error(exc) from exc
665
536
 
666
537
 
667
538
  def _parse_stage_map_into_ctx(plan_path: str) -> list:
@@ -1444,33 +1315,42 @@ class _ModelBindings:
1444
1315
  executor_model_meta: ModelAssignment
1445
1316
 
1446
1317
 
1318
+ def recommended_role_models() -> dict[str, str]:
1319
+ """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1320
+ wizard 의 안내 표기가 공유하는 단일 기준점."""
1321
+ lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
1322
+ return {
1323
+ "lead": lead_default,
1324
+ "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus"),
1325
+ "codex": _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5"),
1326
+ "antigravity": _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "auto"),
1327
+ "report-writer": _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default),
1328
+ }
1329
+
1330
+
1447
1331
  def _resolve_worker_models(inp: PrepareInputs) -> dict:
1448
1332
  """lead/claude/codex/antigravity/report-writer 모델을 alias → (display, execution) 로 해소."""
1449
- lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
1450
- claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus")
1451
- codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
1452
- antigravity_default = _default("OKSTRA_DEFAULT_ANTIGRAVITY_MODEL", "auto")
1453
- report_writer_default = _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default)
1333
+ rec = recommended_role_models()
1454
1334
  return {
1455
1335
  "lead": resolve_model_metadata(
1456
1336
  provider="claude", raw_value=inp.lead_model,
1457
- default_display=lead_default, default_execution=lead_default,
1337
+ default_display=rec["lead"], default_execution=rec["lead"],
1458
1338
  ),
1459
1339
  "cw": resolve_model_metadata(
1460
1340
  provider="claude", raw_value=inp.claude_model,
1461
- default_display=claude_default, default_execution=claude_default,
1341
+ default_display=rec["claude"], default_execution=rec["claude"],
1462
1342
  ),
1463
1343
  "co": resolve_model_metadata(
1464
1344
  provider="codex", raw_value=inp.codex_model,
1465
- default_display=codex_default, default_execution=codex_default,
1345
+ default_display=rec["codex"], default_execution=rec["codex"],
1466
1346
  ),
1467
1347
  "ge": resolve_model_metadata(
1468
1348
  provider="antigravity", raw_value=inp.antigravity_model,
1469
- default_display=antigravity_default, default_execution=antigravity_default,
1349
+ default_display=rec["antigravity"], default_execution=rec["antigravity"],
1470
1350
  ),
1471
1351
  "rw": resolve_model_metadata(
1472
1352
  provider="claude", raw_value=inp.report_writer_model,
1473
- default_display=report_writer_default, default_execution=report_writer_default,
1353
+ default_display=rec["report-writer"], default_execution=rec["report-writer"],
1474
1354
  ),
1475
1355
  }
1476
1356
 
@@ -1511,23 +1391,13 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1511
1391
  )
1512
1392
 
1513
1393
 
1514
- @dataclass
1515
- class StageSelection:
1516
- """Result of `_select_and_provision_implementation_stage`.
1517
-
1518
- Path-independent: carries the chosen stage + its isolated worktree
1519
- coordinates, separated from any run-artifact path so the caller can
1520
- resolve run paths AFTER the stage is known. `worktree_*` are empty when
1521
- the surrounding flow degraded (non-git / nested worktree); in that case
1522
- `started_head_commit` is the project HEAD instead of the worktree base."""
1523
- stage: int
1524
- worktree_path: str
1525
- worktree_branch: str
1526
- worktree_base_ref: str
1527
- worktree_status: str
1528
- worktree_note: str
1529
- started_head_commit: str
1530
- concurrent_stages: list = field(default_factory=list)
1394
+ StageSelection = _implementation_stage.StageSelection
1395
+
1396
+
1397
+ def _implementation_stage_prepare_error(
1398
+ exc: _implementation_stage.ImplementationStageError,
1399
+ ) -> PrepareError:
1400
+ return PrepareError(str(exc))
1531
1401
 
1532
1402
 
1533
1403
  def _select_and_provision_implementation_stage(
@@ -1538,102 +1408,18 @@ def _select_and_provision_implementation_stage(
1538
1408
  task_key: str,
1539
1409
  executor_worktree_status: str,
1540
1410
  ) -> StageSelection:
1541
- """Resolve the first ready implementation stage and provision its isolated
1542
- worktree, without touching any run-artifact path.
1543
-
1544
- spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 가 backward compat
1545
- 로 batch 를 반환하지만 첫 번째만 실행한다 — stage 마다 격리 worktree·branch
1546
- 가 필요해 batch 가 의미를 잃기 때문. `executor_worktree_status` 가
1547
- "skipped*" 면 worktree 없이 degrade 하고 project HEAD 만 기록한다."""
1548
- from .consumers import read_consumers, backfill_done_from_carry
1549
- from . import worktree as _worktree
1550
- from . import worktree_registry as _reg
1551
-
1552
- plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1553
- # carry sidecars are the SSOT for stage completion; recover any `done` rows
1554
- # the lead failed to append before the dependency gate reads them.
1555
- backfill_done_from_carry(plan_run_root)
1556
- _auto_reconcile_best_effort(inp, plan_run_root)
1557
- consumed = read_consumers(plan_run_root)
1558
- done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
1559
- started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
1560
- reserved_stages = _reg.list_active_stage_numbers(
1561
- inp.project_id, inp.task_group, inp.task_id,
1562
- )
1563
-
1564
- batch = _resolve_effective_stages(
1565
- ctx_stage_map, done_stages, inp.stage,
1566
- started_stages=started_stages, reserved_stages=reserved_stages,
1567
- )
1568
- selected = batch[0]
1569
- # done stage 는 동시 run 이 아니다 — done 기록 시 점유는 해제되지만
1570
- # (consumers._release_stage_reservation), crash·구버전 기록의 잔존
1571
- # 예약이 남을 수 있어 읽기에서도 차감한다.
1572
- concurrent_stages = sorted(reserved_stages - done_stages - {selected})
1573
-
1574
- # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1575
- # stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
1576
- if executor_worktree_status.startswith("skipped"):
1577
- head = _git_out(inp.project_root, "rev-parse", "HEAD")
1578
- return StageSelection(
1579
- stage=selected,
1580
- worktree_path="",
1581
- worktree_branch="",
1582
- worktree_base_ref="",
1583
- worktree_status=executor_worktree_status,
1584
- worktree_note="",
1585
- started_head_commit=head,
1586
- concurrent_stages=concurrent_stages,
1587
- )
1588
-
1589
- # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
1590
- head_sha = _git_out(inp.project_root, "rev-parse", "HEAD")
1591
- if head_sha:
1592
- _reg.set_implementation_base(
1593
- inp.project_id, inp.task_group, inp.task_id, head_sha,
1594
- )
1595
- anchor = _reg.get_implementation_base(
1596
- inp.project_id, inp.task_group, inp.task_id,
1597
- ) or ""
1598
-
1599
- # stage 격리 worktree 발급 — 의존 종류별 base 결정
1600
- selected_stage = next(
1601
- s for s in ctx_stage_map if s["stage_number"] == selected
1602
- )
1603
- consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
1604
- stage_base = _resolve_stage_base_commit(
1605
- selected_stage, consumer_done_rows, anchor_base_commit=anchor,
1606
- candidate_base=head_sha, project_root=Path(inp.project_root),
1607
- plan_run_root=plan_run_root,
1608
- )
1411
+ """Compatibility wrapper for implementation stage selection."""
1609
1412
  try:
1610
- prov = _worktree.provision_stage_worktree(
1611
- project_root=Path(inp.project_root),
1612
- project_id=inp.project_id,
1613
- task_group_segment=task_group_segment,
1614
- task_id_segment=task_id_segment,
1615
- work_category=inp.work_category,
1616
- stage_number=selected,
1617
- base_commit=stage_base,
1413
+ return _implementation_stage.select_and_provision_implementation_stage(
1414
+ inp,
1415
+ ctx_stage_map,
1416
+ task_group_segment,
1417
+ task_id_segment,
1418
+ task_key,
1419
+ executor_worktree_status,
1618
1420
  )
1619
- except RuntimeError as exc:
1620
- from .git_reconcile import guidance
1621
- hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
1622
- task_group=inp.task_group, task_id=inp.task_id,
1623
- work_category=inp.work_category)
1624
- raise PrepareError(
1625
- f"stage worktree provisioning failed: {exc}\n{hint}") from exc
1626
-
1627
- return StageSelection(
1628
- stage=selected,
1629
- worktree_path=prov.path,
1630
- worktree_branch=prov.branch,
1631
- worktree_base_ref=prov.base_ref,
1632
- worktree_status=prov.status,
1633
- worktree_note=prov.note,
1634
- started_head_commit=prov.base_ref,
1635
- concurrent_stages=concurrent_stages,
1636
- )
1421
+ except _implementation_stage.ImplementationStageError as exc:
1422
+ raise _implementation_stage_prepare_error(exc) from exc
1637
1423
 
1638
1424
 
1639
1425
  def _apply_implementation_stage(
@@ -1642,44 +1428,8 @@ def _apply_implementation_stage(
1642
1428
  ctx_stage_map: list,
1643
1429
  sel: StageSelection,
1644
1430
  ) -> None:
1645
- """Wire a resolved `StageSelection` into ctx + consumers.jsonl. ctx-dependent
1646
- counterpart to `_select_and_provision_implementation_stage`."""
1647
- from .consumers import append_consumer
1648
- import datetime as _dt
1649
-
1650
- ctx["parsed_stage_map"] = ctx_stage_map
1651
- ctx["effective_stages"] = [sel.stage]
1652
- csv = str(sel.stage)
1653
- ctx["EFFECTIVE_STAGES"] = csv
1654
- ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
1655
- ctx["STAGE_BATCH_DIRECTIVE"] = (
1656
- f"- **Stage for this implementation run:** `{csv}`. "
1657
- "Execute exactly this Stage Map stage — this is the authoritative scope. "
1658
- "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
1659
- "and reserved this stage."
1660
- )
1661
- inp.stage = csv
1662
- # Observable contract: callers (okstra.sh, e2e harness) scan stdout for the
1663
- # resolved stage. Keep this line — it is the run's stage-selection receipt.
1664
- print(f"selected stages: {csv}", file=sys.stdout)
1665
-
1666
- if sel.worktree_status and not sel.worktree_status.startswith("skipped"):
1667
- ctx["EXECUTOR_WORKTREE_PATH"] = sel.worktree_path
1668
- ctx["EXECUTOR_WORKTREE_BRANCH"] = sel.worktree_branch
1669
- ctx["EXECUTOR_WORKTREE_BASE_REF"] = sel.worktree_base_ref
1670
- ctx["EXECUTOR_WORKTREE_STATUS"] = sel.worktree_status
1671
- ctx["EXECUTOR_WORKTREE_NOTE"] = sel.worktree_note
1672
-
1673
- now = _dt.datetime.now(_dt.timezone.utc).isoformat()
1674
- plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
1675
- append_consumer(
1676
- plan_run_root,
1677
- impl_task_key=ctx["TASK_KEY"],
1678
- stage=sel.stage,
1679
- status="started",
1680
- started_at=now,
1681
- head_commit=sel.started_head_commit,
1682
- )
1431
+ """Compatibility wrapper for publishing implementation stage selection."""
1432
+ _implementation_stage.apply_implementation_stage(inp, ctx, ctx_stage_map, sel)
1683
1433
 
1684
1434
 
1685
1435
  def _git_out(cwd, *args) -> str:
@@ -1689,17 +1439,7 @@ def _git_out(cwd, *args) -> str:
1689
1439
 
1690
1440
 
1691
1441
  def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
1692
- try:
1693
- from .git_reconcile import auto_reconcile
1694
- for it in auto_reconcile(
1695
- project_root=Path(inp.project_root), plan_run_root=plan_run_root,
1696
- project_id=inp.project_id, task_group=inp.task_group,
1697
- task_id=inp.task_id, work_category=inp.work_category):
1698
- print(f"git-reconcile: stage {it.stage} done commit "
1699
- f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
1700
- "(patch-equivalent)", file=sys.stdout)
1701
- except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
1702
- print(f"git-reconcile skipped: {exc}", file=sys.stderr)
1442
+ _stage_auto_reconcile_best_effort(inp, plan_run_root)
1703
1443
 
1704
1444
 
1705
1445
  def _is_ancestor(cwd, commit, head) -> bool:
@@ -0,0 +1,33 @@
1
+ """Best-effort git reconciliation shared by stage prepare flows."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
10
+ """Run patch-equivalence reconciliation without changing prepare outcome.
11
+
12
+ Reconciliation is advisory: failures are reported to stderr and the normal
13
+ dependency gates still make the authoritative decision.
14
+ """
15
+ try:
16
+ from .git_reconcile import auto_reconcile
17
+
18
+ for item in auto_reconcile(
19
+ project_root=Path(inp.project_root),
20
+ plan_run_root=plan_run_root,
21
+ project_id=inp.project_id,
22
+ task_group=inp.task_group,
23
+ task_id=inp.task_id,
24
+ work_category=inp.work_category,
25
+ ):
26
+ print(
27
+ f"git-reconcile: stage {item.stage} done commit "
28
+ f"{item.recorded[:8]} -> {item.suggested_commit[:8]} "
29
+ "(patch-equivalent)",
30
+ file=sys.stdout,
31
+ )
32
+ except Exception as exc:
33
+ print(f"git-reconcile skipped: {exc}", file=sys.stderr)
@@ -0,0 +1,277 @@
1
+ """Stage readiness and verification target rules.
2
+
3
+ This module owns the policy that decides which Stage Map stage can run, which
4
+ commit it branches from, and what final-verification should inspect. It keeps
5
+ the stage lifecycle rules behind one interface instead of leaking raw
6
+ ``consumers.jsonl`` rows and git ancestry checks into prepare callers.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ RUN_STEP_BUDGET = 8
17
+
18
+
19
+ class StageTargetError(Exception):
20
+ """Stage target selection or verification precondition failed."""
21
+
22
+
23
+ @dataclass
24
+ class FinalVerificationTarget:
25
+ scope: str # "whole-task" | "single-stage"
26
+ base: str
27
+ head: str
28
+ worktree_path: str
29
+ stages: list[int]
30
+ reports: list[str]
31
+
32
+
33
+ def resolve_effective_stages(
34
+ stages: list[dict[str, Any]],
35
+ done_stages: set[int],
36
+ requested: str,
37
+ budget: int = RUN_STEP_BUDGET,
38
+ started_stages: set[int] | None = None,
39
+ reserved_stages: set[int] | None = None,
40
+ ) -> list[int]:
41
+ """Return ordered stage numbers this run should execute.
42
+
43
+ ``requested`` is ``"auto"`` or a decimal string. Auto selection returns all
44
+ ready stages within the step budget, excluding done/started/reserved stages.
45
+ Numeric selection returns one forced stage.
46
+ """
47
+ started_stages = started_stages or set()
48
+ reserved_stages = reserved_stages or set()
49
+ occupied = done_stages | started_stages | reserved_stages
50
+ if requested != "auto":
51
+ try:
52
+ n = int(requested)
53
+ except ValueError as exc:
54
+ raise StageTargetError(
55
+ f"--stage must be 'auto' or an integer, got {requested!r}"
56
+ ) from exc
57
+ target = next((s for s in stages if s["stage_number"] == n), None)
58
+ if target is None:
59
+ raise StageTargetError(
60
+ f"--stage {n} not in Stage Map "
61
+ f"(have {[s['stage_number'] for s in stages]})"
62
+ )
63
+ if n in done_stages:
64
+ raise StageTargetError(
65
+ f"--stage {n} already completed (consumers.jsonl status:done exists)"
66
+ )
67
+ if n in started_stages or n in reserved_stages:
68
+ raise StageTargetError(
69
+ f"--stage {n} already in progress or reserved by another run"
70
+ )
71
+ return [n]
72
+
73
+ ready = [
74
+ s for s in stages
75
+ if s["stage_number"] not in occupied
76
+ and all(d in done_stages for d in s["depends_on"])
77
+ ]
78
+ if not ready:
79
+ raise StageTargetError(
80
+ "no stage is ready: every remaining stage has unsatisfied depends-on"
81
+ )
82
+ batch: list[int] = []
83
+ total = 0
84
+ for stage in ready:
85
+ step_count = stage.get("step_count", 0) or 0
86
+ if batch and total + step_count > budget:
87
+ break
88
+ batch.append(stage["stage_number"])
89
+ total += step_count
90
+ return batch
91
+
92
+
93
+ def commit_is_ancestor(project_root: Path, ancestor: str, descendant: str) -> bool:
94
+ """True iff ``ancestor`` is an ancestor of ``descendant`` in git history."""
95
+ result = subprocess.run(
96
+ ["git", "merge-base", "--is-ancestor", ancestor, descendant],
97
+ cwd=str(project_root),
98
+ capture_output=True,
99
+ text=True,
100
+ )
101
+ return result.returncode == 0
102
+
103
+
104
+ def check_multi_dep_merged(
105
+ project_root: Path,
106
+ plan_run_root: Path | None,
107
+ latest: dict[int, dict[str, Any]],
108
+ pred_commits: dict[int, str],
109
+ candidate_base: str,
110
+ stage_n: int,
111
+ ) -> None:
112
+ """Ensure all predecessor commits are merged into the candidate base."""
113
+ from .git_reconcile import content_merged, _record_reconciled
114
+
115
+ for dep_stage, head in pred_commits.items():
116
+ if commit_is_ancestor(project_root, head, candidate_base):
117
+ continue
118
+ match = content_merged(project_root, head, candidate_base)
119
+ if match.status in ("ancestor", "patch-equivalent"):
120
+ if plan_run_root is not None:
121
+ _record_reconciled(
122
+ plan_run_root,
123
+ impl_task_key=(latest.get(dep_stage) or {}).get("impl_task_key", ""),
124
+ stage=dep_stage,
125
+ new_commit=match.matched_commit,
126
+ replaced=head,
127
+ reason="auto-patch-id",
128
+ )
129
+ continue
130
+ raise StageTargetError(
131
+ f"multi-dependency stage {stage_n}: predecessor stage {dep_stage} "
132
+ f"({head[:8]}) is not merged into the task worktree "
133
+ f"({candidate_base[:8]}). Merge stage branches "
134
+ f"(e.g. the `-s{dep_stage}` branches) into the task worktree "
135
+ "(or into main, then refresh the worktree) and retry."
136
+ )
137
+
138
+
139
+ def resolve_stage_base_commit(
140
+ stage: dict[str, Any],
141
+ consumer_done_rows: list[dict[str, Any]],
142
+ anchor_base_commit: str,
143
+ candidate_base: str = "",
144
+ project_root: Path | None = None,
145
+ plan_run_root: Path | None = None,
146
+ ) -> str:
147
+ """Pick the git base commit a stage's isolated worktree branches from."""
148
+ from .consumers import latest_done_by_stage
149
+
150
+ latest = latest_done_by_stage(consumer_done_rows)
151
+ deps = stage.get("depends_on") or []
152
+ if len(deps) >= 2:
153
+ stage_number = stage["stage_number"]
154
+ pred_commits: dict[int, str] = {}
155
+ for dep_stage in deps:
156
+ head = (latest.get(dep_stage) or {}).get("head_commit")
157
+ if not head:
158
+ raise StageTargetError(
159
+ f"predecessor stage {dep_stage} has no done row with head_commit "
160
+ f"in consumers.jsonl; multi-dependency stage {stage_number} cannot start"
161
+ )
162
+ pred_commits[dep_stage] = head
163
+ if not candidate_base or project_root is None:
164
+ raise StageTargetError(
165
+ f"candidate base missing for multi-dependency stage {stage_number}; "
166
+ "task-key worktree HEAD could not be resolved"
167
+ )
168
+ check_multi_dep_merged(
169
+ project_root,
170
+ plan_run_root,
171
+ latest,
172
+ pred_commits,
173
+ candidate_base,
174
+ stage_number,
175
+ )
176
+ return candidate_base
177
+ if not deps:
178
+ if not anchor_base_commit:
179
+ raise StageTargetError(
180
+ f"anchor base commit missing for independent stage "
181
+ f"{stage['stage_number']}; first-stage prepare should have "
182
+ "fixed it via worktree_registry.set_implementation_base"
183
+ )
184
+ return anchor_base_commit
185
+ pred = deps[0]
186
+ head = (latest.get(pred) or {}).get("head_commit") or ""
187
+ if head:
188
+ return head
189
+ raise StageTargetError(
190
+ f"predecessor stage {pred} has no done row with head_commit in "
191
+ "consumers.jsonl; cannot derive base for stage "
192
+ f"{stage['stage_number']}"
193
+ )
194
+
195
+
196
+ def resolve_whole_task_target(
197
+ *,
198
+ stage_map: list[dict[str, Any]],
199
+ done_rows: list[dict[str, Any]],
200
+ anchor_base: str,
201
+ task_worktree_path: str,
202
+ task_head: str,
203
+ task_dirty: bool,
204
+ merged: dict[int, bool],
205
+ ) -> FinalVerificationTarget:
206
+ """Resolve whole-task final-verification target, enforcing all gates."""
207
+ from .consumers import latest_done_by_stage
208
+
209
+ done_by_stage = latest_done_by_stage(done_rows)
210
+ for stage in stage_map:
211
+ n = stage["stage_number"]
212
+ if n not in done_by_stage:
213
+ raise StageTargetError(
214
+ f"final-verification(whole-task): stage {n} not done — "
215
+ f"run implementation --stage {n} first"
216
+ )
217
+ if not merged.get(n, False):
218
+ sha = done_by_stage[n].get("head_commit", "")
219
+ raise StageTargetError(
220
+ f"final-verification(whole-task): stage {n} done commit "
221
+ f"{sha} not merged into task worktree HEAD — merge stage "
222
+ "branches then retry"
223
+ )
224
+ if task_dirty:
225
+ raise StageTargetError(
226
+ "final-verification: worktree has uncommitted source changes "
227
+ "(outside .okstra/) — commit or stash before verifying"
228
+ )
229
+ stages = [s["stage_number"] for s in stage_map]
230
+ reports = [done_by_stage[n].get("report_path", "") for n in stages]
231
+ return FinalVerificationTarget(
232
+ scope="whole-task",
233
+ base=anchor_base,
234
+ head=task_head,
235
+ worktree_path=task_worktree_path,
236
+ stages=stages,
237
+ reports=reports,
238
+ )
239
+
240
+
241
+ def resolve_single_stage_target(
242
+ *,
243
+ requested_stage: str,
244
+ done_rows: list[dict[str, Any]],
245
+ stage_base: str,
246
+ stage_worktree_path: str,
247
+ stage_head: str,
248
+ stage_dirty: bool,
249
+ ) -> FinalVerificationTarget:
250
+ """Resolve single-stage final-verification target, enforcing all gates."""
251
+ from .consumers import latest_done_by_stage
252
+
253
+ n = int(requested_stage)
254
+ done_by_stage = latest_done_by_stage(done_rows)
255
+ if n not in done_by_stage:
256
+ raise StageTargetError(
257
+ f"final-verification(single-stage): stage {n} not done — "
258
+ f"run implementation --stage {n} first"
259
+ )
260
+ if not stage_worktree_path:
261
+ raise StageTargetError(
262
+ f"final-verification(single-stage): stage worktree not found for "
263
+ f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
264
+ )
265
+ if stage_dirty:
266
+ raise StageTargetError(
267
+ "final-verification: worktree has uncommitted source changes "
268
+ "(outside .okstra/) — commit or stash before verifying"
269
+ )
270
+ return FinalVerificationTarget(
271
+ scope="single-stage",
272
+ base=stage_base,
273
+ head=stage_head,
274
+ worktree_path=stage_worktree_path,
275
+ stages=[n],
276
+ reports=[done_by_stage[n].get("report_path", "")],
277
+ )
@@ -44,6 +44,7 @@ from okstra_ctl.run import (
44
44
  _reject_blocking_plan_body_gate,
45
45
  _set_data_json_approved_true_if_present,
46
46
  _validate_data_json_approval_consistency,
47
+ recommended_role_models,
47
48
  )
48
49
  from okstra_ctl.user_response import (
49
50
  UserResponseApprovalRecord,
@@ -93,6 +94,15 @@ TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
93
94
 
94
95
  EXECUTORS = ["claude", "codex", "antigravity"]
95
96
 
97
+ # 역할 한 줄 설명 — defaults_or_custom 안내에서 각 역할이 무슨 일을 하는지 보여준다.
98
+ ROLE_BLURBS = {
99
+ "lead": "Claude lead · 워커 dispatch·교차검증 수렴·최종 합성",
100
+ "claude": "독립 분석 워커 (Claude)",
101
+ "codex": "독립 분석 워커 (Codex)",
102
+ "antigravity": "독립 분석 워커 (Antigravity)",
103
+ "report-writer": "최종 리포트 작성 (분석은 하지 않음)",
104
+ }
105
+
96
106
  # Task types that consume an approved plan and need a stage-scope pick:
97
107
  # implementation executes a stage, final-verification verifies a stage
98
108
  # (or the whole task via `auto`). Both gate the approved-plan + stage steps.
@@ -2357,11 +2367,34 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
2357
2367
  f"lead-model={state.lead_model or 'default'})")
2358
2368
 
2359
2369
 
2370
+ def _role_model_lines(state: WizardState) -> str:
2371
+ """이번 run 에서 실제로 모델을 고르게 되는 역할만, 추천 모델과 함께 나열한다.
2372
+ 뒤따르는 *_model 단계의 등장 조건과 1:1 로 맞춰 안내와 실제 화면이 어긋나지
2373
+ 않게 한다 (그래서 분석에 참여하지 않는 antigravity 는 executor 일 때만 나온다)."""
2374
+ rec = recommended_role_models()
2375
+ roster = _resolved_roster(state)
2376
+ impl = state.task_type == "implementation"
2377
+
2378
+ def line(role_label: str, blurb: str, model: str) -> str:
2379
+ return f" · {role_label} ({blurb}) — 추천 {model}"
2380
+
2381
+ lines = [line("lead", ROLE_BLURBS["lead"], rec["lead"])]
2382
+ if impl:
2383
+ ex = state.executor or "claude"
2384
+ lines.append(line(f"executor={ex}", "코드 구현 실행자", rec.get(ex, "auto")))
2385
+ else:
2386
+ for w in ("claude", "codex", "antigravity"):
2387
+ if w in roster:
2388
+ lines.append(line(w, ROLE_BLURBS[w], rec[w]))
2389
+ if impl or "report-writer" in roster:
2390
+ lines.append(line("report-writer", ROLE_BLURBS["report-writer"],
2391
+ rec["report-writer"]))
2392
+ return "\n".join(lines)
2393
+
2394
+
2360
2395
  def _build_defaults_or_custom(state: WizardState) -> Prompt:
2361
- roster = (state.workers_override
2362
- or ",".join(state.profile_workers)
2363
- or "(profile default)")
2364
- t = _p(state.workspace_root, "defaults_or_custom", workers=roster)
2396
+ t = _p(state.workspace_root, "defaults_or_custom",
2397
+ role_models=_role_model_lines(state))
2365
2398
  return Prompt(
2366
2399
  step=S_DEFAULTS_OR_CUSTOM, kind="pick",
2367
2400
  label=t["label"],
@@ -3306,11 +3339,16 @@ def _remaining_screens(state: WizardState) -> int:
3306
3339
  return screens
3307
3340
 
3308
3341
 
3309
- def _screen_progress(state: WizardState) -> dict[str, int]:
3342
+ def _screen_progress(state: WizardState) -> dict[str, Any]:
3310
3343
  passed = _passed_screens(state)
3311
3344
  total = passed + _remaining_screens(state)
3312
3345
  index = passed + 1
3313
- return {"index": index, "total": total, "remaining": max(0, total - index)}
3346
+ remaining = max(0, total - index)
3347
+ suffix = "마지막 단계" if remaining == 0 else f"앞으로 {remaining} 스텝 남음"
3348
+ return {
3349
+ "index": index, "total": total, "remaining": remaining,
3350
+ "label": f"Step {index}/{total} · {suffix}",
3351
+ }
3314
3352
 
3315
3353
 
3316
3354
  def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
@@ -32,7 +32,7 @@ Every wizard call returns JSON. The two shapes you'll see:
32
32
  "progress": { "index": 5, "total": 11, "remaining": 6 } } }
33
33
  ```
34
34
 
35
- Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). `index` is the 1-based number of the **current screen** (pick_group counts as one screen), `total` is the wizard's estimate of the full screen count, and `remaining` = `total index`. The total is a forward estimate from the current answers, so it may grow by a few when the user opens a branch (e.g. choosing *Customize* adds the model screen). **Always suffix the rendered prompt with the progress marker** — see Step 3.
35
+ Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). It holds `index` (1-based number of the **current screen**; pick_group counts as one screen), `total` (the wizard's forward estimate of the full screen count may grow by a few when the user opens a branch, e.g. choosing *Customize* adds the model screen), `remaining` (`total index`), and **`label`** the ready-to-render Korean marker the wizard already composed (e.g. `Step 10/11 · 앞으로 1 스텝 남음`, or `Step 11/11 · 마지막 단계` on the final screen). **Always suffix the rendered prompt with `progress.label` verbatim** — see Step 3. Do not recompute the marker or decide "마지막 단계" yourself; only the wizard knows whether more screens follow.
36
36
 
37
37
  ```json
38
38
  { "ok": false, "error": "approved plan has no APPROVED marker: ...",
@@ -96,7 +96,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
96
96
 
97
97
  Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
98
98
 
99
- 1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix `label` with ` (Step <index>/<total> · 남은 <remaining>단계)`, using `next.progress`. When `remaining == 0` (the final `confirm` screen), use `(Step <index>/<total> · 마지막 단계)` instead. Example: `모델을 선택하세요 (Step 8/11 · 남은 3단계)`. Re-prompts after `ok: false` reuse `current.progress` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
99
+ 1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix it with ` (<next.progress.label>)` render `progress.label` exactly as the wizard sent it, never recompute it. Example: label `Step 8/11 · 앞으로 3 스텝 남음` `모델을 선택하세요 (Step 8/11 · 앞으로 3 스텝 남음)`. Re-prompts after `ok: false` reuse `current.progress.label` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
100
100
  - `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
101
101
  - `pick` + `multi: true` → `AskUserQuestion` with `multiSelect: true`, `label`, and `options`. Join the selected `value`s with `,` into a single literal CSV string (e.g. `"claude,codex,antigravity"`) and submit it as a single `--answer "claude,codex,antigravity"`. Empty selection submits `--answer ""` and the wizard re-prompts.
102
102
  - `pick_group` → one `AskUserQuestion` with one question per `questions[]` entry (tab). Map each tab's selected `value` back by `questions[].step`, assemble a JSON object, and submit it as a single literal `--answer '<json>'`.