okstra 0.91.1 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.91.1",
3
+ "version": "0.92.0",
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.0",
3
+ "builtAt": "2026-06-18T07:29:46.716Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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:
@@ -1511,23 +1382,13 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1511
1382
  )
1512
1383
 
1513
1384
 
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)
1385
+ StageSelection = _implementation_stage.StageSelection
1386
+
1387
+
1388
+ def _implementation_stage_prepare_error(
1389
+ exc: _implementation_stage.ImplementationStageError,
1390
+ ) -> PrepareError:
1391
+ return PrepareError(str(exc))
1531
1392
 
1532
1393
 
1533
1394
  def _select_and_provision_implementation_stage(
@@ -1538,102 +1399,18 @@ def _select_and_provision_implementation_stage(
1538
1399
  task_key: str,
1539
1400
  executor_worktree_status: str,
1540
1401
  ) -> 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
- )
1402
+ """Compatibility wrapper for implementation stage selection."""
1609
1403
  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,
1404
+ return _implementation_stage.select_and_provision_implementation_stage(
1405
+ inp,
1406
+ ctx_stage_map,
1407
+ task_group_segment,
1408
+ task_id_segment,
1409
+ task_key,
1410
+ executor_worktree_status,
1618
1411
  )
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
- )
1412
+ except _implementation_stage.ImplementationStageError as exc:
1413
+ raise _implementation_stage_prepare_error(exc) from exc
1637
1414
 
1638
1415
 
1639
1416
  def _apply_implementation_stage(
@@ -1642,44 +1419,8 @@ def _apply_implementation_stage(
1642
1419
  ctx_stage_map: list,
1643
1420
  sel: StageSelection,
1644
1421
  ) -> 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
- )
1422
+ """Compatibility wrapper for publishing implementation stage selection."""
1423
+ _implementation_stage.apply_implementation_stage(inp, ctx, ctx_stage_map, sel)
1683
1424
 
1684
1425
 
1685
1426
  def _git_out(cwd, *args) -> str:
@@ -1689,17 +1430,7 @@ def _git_out(cwd, *args) -> str:
1689
1430
 
1690
1431
 
1691
1432
  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)
1433
+ _stage_auto_reconcile_best_effort(inp, plan_run_root)
1703
1434
 
1704
1435
 
1705
1436
  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
+ )