okstra 0.137.0 → 0.138.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.
@@ -1948,10 +1948,15 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1948
1948
  "- Worker prompt paths in `task-manifest.json` are assigned prompt-history "
1949
1949
  "locations, not pre-rendered completion markers. An absent file means its "
1950
1950
  "dispatch history has not been materialized yet.\n"
1951
- "- Construct each required worker prompt and follow the lifecycle core plus "
1952
- "selected adapter to persist its assigned history through `write_artifact` "
1953
- "and start it through `dispatch_worker`. Existing-history handling must stay "
1954
- "idempotent under those contracts.\n"
1951
+ "- Code-backed `dispatch_worker` mappings leave missing roster prompts to "
1952
+ "`okstra_ctl.initial_prompt_materialization` and its canonical "
1953
+ "`materialize_initial_prompts()` entry point. The selected adapter supplies "
1954
+ "its declared delivery mode; do not construct or overwrite those prompts "
1955
+ "manually.\n"
1956
+ "- For native in-process dispatch, use the same canonical initial-prompt headers, "
1957
+ "persist the assigned prompt history through `write_artifact`, and apply "
1958
+ "the adapter-declared `lazy-path-reference` delivery mode before starting the "
1959
+ "worker. This native path does not apply to reverify or critic dispatches.\n"
1955
1960
  "- Use `await_workers` through the selected adapter and confirm terminal state "
1956
1961
  "plus every required Result Path. File presence alone is never a signal to "
1957
1962
  "skip; worker selection and skipped status come from `team-state`.\n"
@@ -96,11 +96,7 @@ from .workflow import compute_workflow_state, load_phase_forbidden
96
96
  from .locks import worktree_provision_mutex
97
97
  from . import stage_targets as _stage_targets
98
98
  from .plan_run_root import plan_run_root_from_approved_plan
99
- from . import stage_integrate as _stage_integrate
100
99
  from . import implementation_stage as _implementation_stage
101
- from .stage_reconcile import (
102
- auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
103
- )
104
100
  from .work_categories import resolve_work_category
105
101
  from .worktree import (
106
102
  WorktreeProvision,
@@ -437,40 +433,13 @@ def _validate_stage_structure(plan_path: str) -> None:
437
433
 
438
434
 
439
435
  RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
440
- _FVTarget = _stage_targets.FinalVerificationTarget
441
-
442
-
443
- def _stage_target_prepare_error(exc: _stage_targets.StageTargetError) -> PrepareError:
444
- return PrepareError(str(exc))
445
-
446
-
447
-
448
-
449
-
450
-
451
- def _resolve_single_stage_target(
452
- *, requested_stage: str, done_rows: list, stage_base: str,
453
- stage_worktree_path: str, stage_head: str, stage_dirty: bool,
454
- ) -> "_FVTarget":
455
- """Compatibility wrapper for single-stage verification target selection."""
456
- try:
457
- return _stage_targets.resolve_single_stage_target(
458
- requested_stage=requested_stage,
459
- done_rows=done_rows,
460
- stage_base=stage_base,
461
- stage_worktree_path=stage_worktree_path,
462
- stage_head=stage_head,
463
- stage_dirty=stage_dirty,
464
- )
465
- except _stage_targets.StageTargetError as exc:
466
- raise _stage_target_prepare_error(exc) from exc
467
436
 
468
437
 
469
438
  def _stage_map_reject_detail(stages, errs):
470
439
  """빈/손상 Stage Map 의 거부 사유 문자열을 돌려준다. 정상이면 None.
471
440
 
472
441
  빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
473
- (resolve_whole_task_target `for stage in stage_map`)가 0회 순회로 vacuous
442
+ (`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
474
443
  통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
475
444
  stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
476
445
  게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
@@ -1432,16 +1401,6 @@ def _git_out(cwd, *args) -> str:
1432
1401
  return r.stdout.strip() if r.returncode == 0 else ""
1433
1402
 
1434
1403
 
1435
- def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
1436
- _stage_auto_reconcile_best_effort(inp, plan_run_root)
1437
-
1438
-
1439
-
1440
- def _is_dirty_excluding_okstra(cwd) -> bool:
1441
- from .worktree import is_dirty_excluding_okstra
1442
- return is_dirty_excluding_okstra(cwd)
1443
-
1444
-
1445
1404
  def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
1446
1405
  """Placeholder until the selected stage registry row is resolved."""
1447
1406
  return WorktreeProvision(
@@ -1471,57 +1430,46 @@ def _format_integration(integ) -> str:
1471
1430
  return "\n".join(lines)
1472
1431
 
1473
1432
 
1474
- def _reserve_final_verification_target(
1475
- inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
1433
+ def _final_verification_target_request(
1434
+ inp: "PrepareInputs",
1435
+ ctx_stage_map: list[dict],
1436
+ ) -> _stage_targets.FinalVerificationTargetRequest:
1437
+ stage = (
1438
+ int(inp.stage)
1439
+ if inp.stage and inp.stage != "auto"
1440
+ else None
1441
+ )
1442
+ return _stage_targets.FinalVerificationTargetRequest(
1443
+ project_root=Path(inp.project_root),
1444
+ project_id=inp.project_id,
1445
+ task_group=inp.task_group,
1446
+ task_id=inp.task_id,
1447
+ work_category=inp.work_category,
1448
+ approved_plan_path=Path(inp.approved_plan_path),
1449
+ stage=stage,
1450
+ stage_map=tuple(ctx_stage_map),
1451
+ )
1452
+
1453
+
1454
+ def _apply_final_verification_target(
1455
+ ctx: dict,
1456
+ acquisition: _stage_targets.FinalVerificationTargetAcquisition,
1476
1457
  ) -> None:
1477
- """final-verification 검증 target registry/consumers/git 에서
1478
- 해소하고 gate 를 강제한다. 위반 시 PrepareError. 결과를 ctx 의
1479
- VERIFICATION_* 키로 주입한다."""
1480
- from .consumers import backfill_done_from_carry, read_stage_consumer_state
1481
- from . import worktree_registry as _reg
1482
-
1483
- plan_run_root = plan_run_root_from_approved_plan(inp.approved_plan_path)
1484
- # carry sidecars are the SSOT for stage completion — recover missing `done`
1485
- # rows before the whole-task gate checks every stage.
1486
- backfill_done_from_carry(plan_run_root)
1487
- _auto_reconcile_best_effort(inp, plan_run_root)
1488
- done_rows = read_stage_consumer_state(plan_run_root).done_rows
1489
-
1490
- if inp.stage and inp.stage != "auto":
1491
- n = int(inp.stage)
1492
- row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
1493
- wt_path = (row or {}).get("worktree_path", "")
1494
- stage_base = (row or {}).get("base_ref", "")
1495
- stage_branch = (row or {}).get("branch", "")
1496
- head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
1497
- target = _resolve_single_stage_target(
1498
- requested_stage=inp.stage, done_rows=done_rows,
1499
- stage_base=stage_base, stage_worktree_path=wt_path,
1500
- stage_head=head,
1501
- stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
1502
- )
1503
- ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
1504
- ctx["EXECUTOR_WORKTREE_BRANCH"] = stage_branch
1505
- ctx["EXECUTOR_WORKTREE_BASE_REF"] = stage_base
1458
+ """Translate acquired domain facts into render-context fields."""
1459
+ target = acquisition.target
1460
+ if target.scope == "single-stage":
1461
+ stage = target.stages[0]
1462
+ ctx["EXECUTOR_WORKTREE_PATH"] = target.worktree_path
1463
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = acquisition.worktree_branch
1464
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = target.base
1506
1465
  ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
1507
1466
  ctx["EXECUTOR_WORKTREE_NOTE"] = (
1508
- f"final-verification uses implementation stage {n} worktree"
1467
+ f"final-verification uses implementation stage {stage} worktree"
1509
1468
  )
1510
- else:
1511
- wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1512
- anchor = _reg.get_implementation_base(
1513
- inp.project_id, inp.task_group, inp.task_id) or ""
1514
- # whole-task 통합(머지+teardown)+게이트 강제는 container 와 공유하는
1515
- # 순수 헬퍼가 단일 책임. ctx 주입만 run.py 측에 남긴다.
1516
- whole = _stage_targets.resolve_and_integrate_whole_task(
1517
- project_id=inp.project_id, task_group=inp.task_group,
1518
- task_id=inp.task_id, task_worktree_path=wt_path,
1519
- stage_map=ctx_stage_map, done_rows=done_rows,
1520
- anchor_base=anchor, teardown=True,
1469
+ if acquisition.integration_result is not None:
1470
+ ctx["STAGE_INTEGRATION"] = _format_integration(
1471
+ acquisition.integration_result
1521
1472
  )
1522
- ctx["STAGE_INTEGRATION"] = _format_integration(whole["integrate_result"])
1523
- target = whole["target"]
1524
-
1525
1473
  diff_stat = _git_out(target.worktree_path, "diff", "--stat",
1526
1474
  f"{target.base}..{target.head}")
1527
1475
  ctx["VERIFICATION_SCOPE"] = target.scope
@@ -2263,7 +2211,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2263
2211
  ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
2264
2212
  ctx["CLAUDE_RESUME_COMMAND_FILENAME"] = ""
2265
2213
  if inp.task_type == "final-verification":
2266
- _reserve_final_verification_target(inp, ctx, ctx_stage_map)
2214
+ acquisition = _stage_targets.acquire_final_verification_target(
2215
+ _final_verification_target_request(inp, ctx_stage_map)
2216
+ )
2217
+ _apply_final_verification_target(ctx, acquisition)
2267
2218
 
2268
2219
  _write_bundle_artifacts(
2269
2220
  inp, ctx, project_root, lead_runtime, claude_session_id,
@@ -8,10 +8,12 @@ the stage lifecycle rules behind one interface instead of leaking raw
8
8
  from __future__ import annotations
9
9
 
10
10
  import heapq
11
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, replace
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
+ from .stage_integrate import IntegrateResult
16
+
15
17
 
16
18
  RUN_STEP_BUDGET = 8
17
19
 
@@ -38,6 +40,29 @@ class FinalVerificationTarget:
38
40
  reports: list[str]
39
41
 
40
42
 
43
+ @dataclass(frozen=True)
44
+ class FinalVerificationTargetRequest:
45
+ """Semantic inputs needed to acquire one final-verification target."""
46
+
47
+ project_root: Path
48
+ project_id: str
49
+ task_group: str
50
+ task_id: str
51
+ work_category: str
52
+ approved_plan_path: Path
53
+ stage: int | None
54
+ stage_map: tuple[dict[str, Any], ...]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class FinalVerificationTargetAcquisition:
59
+ """Resolved target facts returned to the prepare adapter."""
60
+
61
+ target: FinalVerificationTarget
62
+ worktree_branch: str
63
+ integration_result: IntegrateResult | None
64
+
65
+
41
66
  @dataclass(frozen=True)
42
67
  class StageLifecycle:
43
68
  stage: int
@@ -473,7 +498,7 @@ def resolve_stage_base_commit(
473
498
  )
474
499
 
475
500
 
476
- def resolve_whole_task_target(
501
+ def _resolve_whole_task_target(
477
502
  *,
478
503
  stage_map: list[dict[str, Any]],
479
504
  done_rows: list[dict[str, Any]],
@@ -533,65 +558,80 @@ def _merged_stage_map(
533
558
  }
534
559
 
535
560
 
536
- def resolve_and_integrate_whole_task(
561
+ def _resolve_and_integrate_whole_task_unlocked(
537
562
  *, project_id: str, task_group: str, task_id: str,
538
563
  task_worktree_path: str, stage_map: list[dict[str, Any]],
539
564
  done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
540
565
  ) -> dict[str, Any]:
541
- """whole-task stage 통합(머지[+선택 teardown]) 게이트 강제 → target 해소.
542
-
543
- final-verification(teardown=True)과 container(teardown=False)의 단일 참조점.
544
- ctx/PrepareInputs 결합 없는 헬퍼. 머지 충돌·미머지·dirty(.okstra 외)면
545
- `PrepareError`. 반환: head_commit / merged_stage_map / target_ref / target /
546
- integrate_result.
547
-
548
- 머지(`git merge`)+teardown 은 task-key 단위 `worktree_provision_mutex` 로
549
- 감싼다 — 동시 `container up` 둘 또는 container up × whole-task
550
- final-verification 이 같은 공유 worktree 의 index/HEAD 에 동시에 머지하면
551
- index.lock 충돌·반쯤 적용된 머지로 트리가 깨진다. 두 호출 지점(container 의
552
- `_integrate_source_tree`, run.py 의 `_reserve_final_verification_target`)
553
- 모두 이 락 밖에서 진입하므로 self-deadlock 없이 여기서 1회만 잡는다."""
566
+ """Integrate and resolve a whole-task target while the caller owns the lock."""
554
567
  from .stage_integrate import IntegrateError, integrate_stages
555
568
  from .worktree import _git, is_dirty_excluding_okstra
556
- from .locks import worktree_provision_mutex
557
- from okstra_project.dirs import okstra_home
558
- from okstra_project.state import slugify
559
-
560
- with worktree_provision_mutex(
561
- okstra_home(), project_id, slugify(task_group), slugify(task_id),
562
- ):
563
- try:
564
- integ = integrate_stages(
565
- project_id=project_id, task_group=task_group, task_id=task_id,
566
- task_worktree_path=task_worktree_path, stage_map=stage_map,
567
- done_rows=done_rows, teardown=teardown,
568
- )
569
- except IntegrateError as exc:
570
- raise PrepareError(str(exc)) from exc
571
569
 
572
- head = _git(task_worktree_path, "rev-parse", "HEAD").stdout.strip()
573
- merged = _merged_stage_map(task_worktree_path, done_rows, head)
574
- try:
575
- target = resolve_whole_task_target(
576
- stage_map=stage_map, done_rows=done_rows, anchor_base=anchor_base,
577
- task_worktree_path=task_worktree_path, task_head=head,
578
- task_dirty=is_dirty_excluding_okstra(task_worktree_path),
579
- merged=merged,
580
- )
581
- except StageTargetError as exc:
582
- raise PrepareError(str(exc)) from exc
570
+ try:
571
+ integration = integrate_stages(
572
+ project_id=project_id,
573
+ task_group=task_group,
574
+ task_id=task_id,
575
+ task_worktree_path=task_worktree_path,
576
+ stage_map=stage_map,
577
+ done_rows=done_rows,
578
+ teardown=teardown,
579
+ )
580
+ except IntegrateError as exc:
581
+ raise PrepareError(str(exc)) from exc
582
+
583
+ head = _git(task_worktree_path, "rev-parse", "HEAD").stdout.strip()
584
+ merged = _merged_stage_map(task_worktree_path, done_rows, head)
585
+ try:
586
+ target = _resolve_whole_task_target(
587
+ stage_map=stage_map,
588
+ done_rows=done_rows,
589
+ anchor_base=anchor_base,
590
+ task_worktree_path=task_worktree_path,
591
+ task_head=head,
592
+ task_dirty=is_dirty_excluding_okstra(task_worktree_path),
593
+ merged=merged,
594
+ )
595
+ except StageTargetError as exc:
596
+ raise PrepareError(str(exc)) from exc
583
597
  return {
584
598
  "head_commit": head,
585
599
  "merged_stage_map": merged,
586
600
  "target_ref": target.head,
587
601
  "target": target,
588
- "integrate_result": integ,
602
+ "integrate_result": integration,
589
603
  }
590
604
 
591
605
 
592
- def resolve_single_stage_target(
606
+ def resolve_and_integrate_whole_task(
607
+ *, project_id: str, task_group: str, task_id: str,
608
+ task_worktree_path: str, stage_map: list[dict[str, Any]],
609
+ done_rows: list[dict[str, Any]], anchor_base: str, teardown: bool,
610
+ ) -> dict[str, Any]:
611
+ """Locked whole-task integration interface shared with the container."""
612
+ from okstra_project.dirs import okstra_home
613
+ from okstra_project.state import slugify
614
+
615
+ from .locks import worktree_provision_mutex
616
+
617
+ with worktree_provision_mutex(
618
+ okstra_home(), project_id, slugify(task_group), slugify(task_id),
619
+ ):
620
+ return _resolve_and_integrate_whole_task_unlocked(
621
+ project_id=project_id,
622
+ task_group=task_group,
623
+ task_id=task_id,
624
+ task_worktree_path=task_worktree_path,
625
+ stage_map=stage_map,
626
+ done_rows=done_rows,
627
+ anchor_base=anchor_base,
628
+ teardown=teardown,
629
+ )
630
+
631
+
632
+ def _resolve_single_stage_target(
593
633
  *,
594
- requested_stage: str,
634
+ requested_stage: int,
595
635
  done_rows: list[dict[str, Any]],
596
636
  stage_base: str,
597
637
  stage_worktree_path: str,
@@ -601,7 +641,7 @@ def resolve_single_stage_target(
601
641
  """Resolve single-stage final-verification target, enforcing all gates."""
602
642
  from .consumers import latest_done_by_stage
603
643
 
604
- n = int(requested_stage)
644
+ n = requested_stage
605
645
  done_by_stage = latest_done_by_stage(done_rows)
606
646
  if n not in done_by_stage:
607
647
  raise StageTargetError(
@@ -626,3 +666,149 @@ def resolve_single_stage_target(
626
666
  stages=[n],
627
667
  reports=[done_by_stage[n].get("report_path", "")],
628
668
  )
669
+
670
+
671
+ def _read_final_verification_done_rows(
672
+ request: FinalVerificationTargetRequest,
673
+ registry_coordinates: tuple[str, str, str],
674
+ ) -> list[dict[str, Any]]:
675
+ from .consumers import backfill_done_from_carry, read_stage_consumer_state
676
+ from .plan_run_root import plan_run_root_from_approved_plan
677
+ from .stage_reconcile import auto_reconcile_best_effort
678
+
679
+ plan_run_root = plan_run_root_from_approved_plan(request.approved_plan_path)
680
+ backfill_done_from_carry(plan_run_root)
681
+ project_id, task_group, task_id = registry_coordinates
682
+ auto_reconcile_best_effort(
683
+ replace(
684
+ request,
685
+ project_id=project_id,
686
+ task_group=task_group,
687
+ task_id=task_id,
688
+ ),
689
+ plan_run_root,
690
+ )
691
+ return read_stage_consumer_state(plan_run_root).done_rows
692
+
693
+
694
+ def _final_verification_registry_coordinates(
695
+ request: FinalVerificationTargetRequest,
696
+ ) -> tuple[str, str, str]:
697
+ from okstra_project.state import slugify
698
+
699
+ def segment(value: str) -> str:
700
+ return slugify(value) or "_"
701
+
702
+ return (
703
+ segment(request.project_id),
704
+ segment(request.task_group),
705
+ segment(request.task_id),
706
+ )
707
+
708
+
709
+ def _acquire_single_stage_target(
710
+ request: FinalVerificationTargetRequest,
711
+ done_rows: list[dict[str, Any]],
712
+ registry_coordinates: tuple[str, str, str],
713
+ ) -> FinalVerificationTargetAcquisition:
714
+ from . import worktree_registry
715
+ from .worktree import _git, is_dirty_excluding_okstra
716
+
717
+ stage = request.stage
718
+ assert stage is not None
719
+ row = worktree_registry.get_stage_row(*registry_coordinates, stage)
720
+ worktree_path = (row or {}).get("worktree_path", "")
721
+ head = ""
722
+ if worktree_path and Path(worktree_path).is_dir():
723
+ head_result = _git(worktree_path, "rev-parse", "HEAD")
724
+ if head_result.returncode == 0:
725
+ head = head_result.stdout.strip()
726
+ if not head:
727
+ worktree_path = ""
728
+ target = _resolve_single_stage_target(
729
+ requested_stage=stage,
730
+ done_rows=done_rows,
731
+ stage_base=(row or {}).get("base_ref", ""),
732
+ stage_worktree_path=worktree_path,
733
+ stage_head=head,
734
+ stage_dirty=(
735
+ is_dirty_excluding_okstra(worktree_path) if worktree_path else False
736
+ ),
737
+ )
738
+ return FinalVerificationTargetAcquisition(
739
+ target=target,
740
+ worktree_branch=(row or {}).get("branch", ""),
741
+ integration_result=None,
742
+ )
743
+
744
+
745
+ def _acquire_whole_task_target(
746
+ request: FinalVerificationTargetRequest,
747
+ done_rows: list[dict[str, Any]],
748
+ registry_coordinates: tuple[str, str, str],
749
+ ) -> FinalVerificationTargetAcquisition:
750
+ from . import worktree_registry
751
+
752
+ project_id, task_group, task_id = registry_coordinates
753
+ entry = worktree_registry.lookup(project_id, task_group, task_id)
754
+ worktree_path = (
755
+ entry.worktree_path if entry is not None else str(request.project_root)
756
+ )
757
+ whole = _resolve_and_integrate_whole_task_unlocked(
758
+ project_id=project_id,
759
+ task_group=task_group,
760
+ task_id=task_id,
761
+ task_worktree_path=worktree_path,
762
+ stage_map=list(request.stage_map),
763
+ done_rows=done_rows,
764
+ anchor_base=(
765
+ worktree_registry.get_implementation_base(
766
+ project_id, task_group, task_id
767
+ )
768
+ or ""
769
+ ),
770
+ teardown=True,
771
+ )
772
+ return FinalVerificationTargetAcquisition(
773
+ target=whole["target"],
774
+ worktree_branch=entry.branch if entry is not None else "",
775
+ integration_result=whole["integrate_result"],
776
+ )
777
+
778
+
779
+ def acquire_final_verification_target(
780
+ request: FinalVerificationTargetRequest,
781
+ ) -> FinalVerificationTargetAcquisition:
782
+ """Acquire a stable final-verification target behind one task-key lock."""
783
+ from okstra_project.dirs import okstra_home
784
+ from okstra_project.state import slugify
785
+
786
+ from .locks import worktree_provision_mutex
787
+
788
+ try:
789
+ with worktree_provision_mutex(
790
+ okstra_home(),
791
+ request.project_id,
792
+ slugify(request.task_group),
793
+ slugify(request.task_id),
794
+ ):
795
+ registry_coordinates = _final_verification_registry_coordinates(request)
796
+ done_rows = _read_final_verification_done_rows(
797
+ request,
798
+ registry_coordinates,
799
+ )
800
+ if request.stage is not None:
801
+ return _acquire_single_stage_target(
802
+ request,
803
+ done_rows,
804
+ registry_coordinates,
805
+ )
806
+ return _acquire_whole_task_target(
807
+ request,
808
+ done_rows,
809
+ registry_coordinates,
810
+ )
811
+ except PrepareError:
812
+ raise
813
+ except (OSError, RuntimeError, StageTargetError, ValueError) as exc:
814
+ raise PrepareError(str(exc)) from exc
@@ -81,8 +81,8 @@ from okstra_ctl.worktree import (
81
81
  compute_worktree_path,
82
82
  is_git_work_tree,
83
83
  main_worktree_path,
84
- preview_stage_worktree_decision,
85
84
  preview_worktree_decision,
85
+ resolve_stage_worktree_decision,
86
86
  )
87
87
  from okstra_ctl.paths import RunRef, task_dir, task_runs_dir
88
88
  from okstra_ctl.work_categories import resolve_work_category
@@ -3175,7 +3175,7 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
3175
3175
  )
3176
3176
  return _msg(state.workspace_root, "confirmation", "worktree_impl_auto",
3177
3177
  path=str(parent_dir))
3178
- decision = preview_stage_worktree_decision(
3178
+ decision = resolve_stage_worktree_decision(
3179
3179
  project_id=state.project_id, task_group_segment=state.task_group,
3180
3180
  task_id_segment=state.task_id,
3181
3181
  work_category=_preview_work_category(state),
@@ -16,6 +16,10 @@ from .worker_prompt_policy import (
16
16
  MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
17
17
  MAX_FINAL_VERIFICATION_BODY_LINES = 96
18
18
 
19
+ PROMPT_DELIVERY_MODE_HEADER = "**Prompt Delivery Mode:**"
20
+ PROMPT_DELIVERY_MODES = frozenset({"eager-include", "lazy-path-reference"})
21
+ MODEL_HEADER = "**Model:**"
22
+
19
23
  _DIRECTIVE_HEADING = "## Run-specific directive"
20
24
  _WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
21
25
  _PRIMARY_PACKET_RE = re.compile(
@@ -48,6 +52,7 @@ _NON_BODY_PREFIXES = (
48
52
  "**Verification head ref:**",
49
53
  "**Verification target path:**",
50
54
  "**Verification target digest:**",
55
+ PROMPT_DELIVERY_MODE_HEADER,
51
56
  )
52
57
  _REQUIRED_TARGET_PREFIXES = (
53
58
  "**Worktree:**",
@@ -81,6 +86,8 @@ class PromptRecord:
81
86
  worker_id: str
82
87
  dispatch_kind: str
83
88
  path: Path
89
+ expected_model: str | None = None
90
+ expected_delivery_mode: str | None = None
84
91
 
85
92
 
86
93
  def validate_final_verification_initial_prompt(text: str) -> list[str]:
@@ -194,6 +201,10 @@ def validate_initial_prompt_records(
194
201
  f"{record.worker_id}: {error}"
195
202
  for error in _validate_prompt_for_plan(text, plan, manifest)
196
203
  )
204
+ errors.extend(
205
+ f"{record.worker_id}: {error}"
206
+ for error in _validate_record_metadata(text, record)
207
+ )
197
208
  if plan.equality_group:
198
209
  group = equality_groups.setdefault(plan.equality_group, {})
199
210
  group[record.worker_id] = text
@@ -202,6 +213,68 @@ def validate_initial_prompt_records(
202
213
  return errors
203
214
 
204
215
 
216
+ def _validate_record_metadata(text: str, record: PromptRecord) -> list[str]:
217
+ errors = _validate_delivery_mode(
218
+ _header_values(text, PROMPT_DELIVERY_MODE_HEADER),
219
+ record.expected_delivery_mode,
220
+ )
221
+ if record.expected_model is None:
222
+ return errors
223
+ model = _model_value(_header_values(text, MODEL_HEADER))
224
+ if model is None:
225
+ errors.append(
226
+ "exactly one non-empty **Model:** <label>, <model> header is required"
227
+ )
228
+ elif model != record.expected_model:
229
+ errors.append(
230
+ f"prompt model does not match requested model: {record.expected_model}"
231
+ )
232
+ return errors
233
+
234
+
235
+ def _validate_delivery_mode(
236
+ values: list[str],
237
+ expected: str | None,
238
+ ) -> list[str]:
239
+ errors = []
240
+ if len(values) != 1 or not values[0]:
241
+ errors.append(
242
+ "exactly one non-empty **Prompt Delivery Mode:** header is required"
243
+ )
244
+ errors.extend(
245
+ f"unsupported Prompt Delivery Mode: {value}"
246
+ for value in values
247
+ if value and value not in PROMPT_DELIVERY_MODES
248
+ )
249
+ if (
250
+ expected is not None
251
+ and len(values) == 1
252
+ and values[0]
253
+ and values[0] != expected
254
+ ):
255
+ errors.append(
256
+ f"Prompt Delivery Mode does not match requested mode: {expected}"
257
+ )
258
+ return errors
259
+
260
+
261
+ def _header_values(text: str, prefix: str) -> list[str]:
262
+ return [
263
+ line.strip()[len(prefix):].strip()
264
+ for line in text.splitlines()
265
+ if line.strip().startswith(prefix)
266
+ ]
267
+
268
+
269
+ def _model_value(values: list[str]) -> str | None:
270
+ if len(values) != 1:
271
+ return None
272
+ parts = [part.strip() for part in values[0].rsplit(",", 1)]
273
+ if len(parts) != 2 or not all(parts):
274
+ return None
275
+ return parts[1]
276
+
277
+
205
278
  def _resolve_record_plan(
206
279
  manifest: Mapping[str, Any],
207
280
  record: PromptRecord,