okstra 0.136.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.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +1 -1
  2. package/docs/architecture.md +3 -2
  3. package/docs/contributor-change-matrix.md +1 -1
  4. package/docs/project-structure-overview.md +32 -5
  5. package/package.json +2 -3
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +1 -1
  8. package/runtime/agents/workers/codex-worker.md +1 -1
  9. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  10. package/runtime/bin/okstra-spawn-followups.py +4 -9
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/plan-body-verification.md +2 -2
  15. package/runtime/prompts/lead/report-writer.md +11 -10
  16. package/runtime/prompts/lead/team-contract.md +4 -2
  17. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  18. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  19. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  20. package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
  21. package/runtime/python/okstra_ctl/consumers.py +7 -5
  22. package/runtime/python/okstra_ctl/context_cost.py +4 -4
  23. package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
  24. package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
  25. package/runtime/python/okstra_ctl/error_report.py +2 -7
  26. package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
  27. package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
  28. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
  29. package/runtime/python/okstra_ctl/path_hints.py +3 -3
  30. package/runtime/python/okstra_ctl/paths.py +17 -2
  31. package/runtime/python/okstra_ctl/recap.py +5 -12
  32. package/runtime/python/okstra_ctl/render.py +18 -19
  33. package/runtime/python/okstra_ctl/report_finalize.py +8 -4
  34. package/runtime/python/okstra_ctl/report_language.py +83 -0
  35. package/runtime/python/okstra_ctl/run.py +262 -328
  36. package/runtime/python/okstra_ctl/set_work_status.py +2 -1
  37. package/runtime/python/okstra_ctl/stage_targets.py +330 -101
  38. package/runtime/python/okstra_ctl/time_report.py +4 -9
  39. package/runtime/python/okstra_ctl/wizard.py +47 -49
  40. package/runtime/python/okstra_ctl/work_categories.py +2 -2
  41. package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
  42. package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
  43. package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
  44. package/runtime/python/okstra_ctl/worktree.py +37 -29
  45. package/runtime/python/okstra_project/__init__.py +4 -0
  46. package/runtime/python/okstra_project/dirs.py +7 -0
  47. package/runtime/python/okstra_project/state.py +78 -8
  48. package/runtime/validators/lib/fixtures.sh +2 -0
  49. package/runtime/validators/validate-run.py +3 -21
  50. package/src/commands/inspect/stage-map.mjs +12 -19
@@ -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,117 +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
- def _resolve_effective_stages(
448
- stages: list,
449
- done_stages: set,
450
- requested: str,
451
- budget: int = RUN_STEP_BUDGET,
452
- started_stages: set = None,
453
- reserved_stages: set = None,
454
- ) -> list:
455
- """Compatibility wrapper for the public stage-target policy module."""
456
- try:
457
- return _stage_targets.resolve_effective_stages(
458
- stages,
459
- done_stages,
460
- requested,
461
- budget=budget,
462
- started_stages=started_stages,
463
- reserved_stages=reserved_stages,
464
- )
465
- except _stage_targets.StageTargetError as exc:
466
- raise _stage_target_prepare_error(exc) from exc
467
-
468
-
469
- def _check_multi_dep_merged(project_root, plan_run_root, latest,
470
- pred_commits: dict, candidate_base: str,
471
- stage_n: int) -> None:
472
- """Compatibility wrapper for the stage-target dependency gate."""
473
- try:
474
- _stage_targets.check_multi_dep_merged(
475
- project_root,
476
- plan_run_root,
477
- latest,
478
- pred_commits,
479
- candidate_base,
480
- stage_n,
481
- )
482
- except _stage_targets.StageTargetError as exc:
483
- raise _stage_target_prepare_error(exc) from exc
484
-
485
-
486
- def _resolve_stage_base_commit(
487
- stage: dict,
488
- consumer_done_rows: list,
489
- anchor_base_commit: str,
490
- candidate_base: str = "",
491
- project_root=None,
492
- plan_run_root=None,
493
- ) -> str:
494
- """Compatibility wrapper for stage worktree base selection."""
495
- try:
496
- return _stage_targets.resolve_stage_base_commit(
497
- stage,
498
- consumer_done_rows,
499
- anchor_base_commit,
500
- candidate_base=candidate_base,
501
- project_root=project_root,
502
- plan_run_root=plan_run_root,
503
- )
504
- except _stage_targets.StageTargetError as exc:
505
- raise _stage_target_prepare_error(exc) from exc
506
-
507
-
508
- def _resolve_whole_task_target(
509
- *, stage_map: list, done_rows: list, anchor_base: str,
510
- task_worktree_path: str, task_head: str, task_dirty: bool,
511
- merged: dict,
512
- ) -> "_FVTarget":
513
- """Compatibility wrapper for whole-task verification target selection."""
514
- try:
515
- return _stage_targets.resolve_whole_task_target(
516
- stage_map=stage_map,
517
- done_rows=done_rows,
518
- anchor_base=anchor_base,
519
- task_worktree_path=task_worktree_path,
520
- task_head=task_head,
521
- task_dirty=task_dirty,
522
- merged=merged,
523
- )
524
- except _stage_targets.StageTargetError as exc:
525
- raise _stage_target_prepare_error(exc) from exc
526
-
527
-
528
- def _resolve_single_stage_target(
529
- *, requested_stage: str, done_rows: list, stage_base: str,
530
- stage_worktree_path: str, stage_head: str, stage_dirty: bool,
531
- ) -> "_FVTarget":
532
- """Compatibility wrapper for single-stage verification target selection."""
533
- try:
534
- return _stage_targets.resolve_single_stage_target(
535
- requested_stage=requested_stage,
536
- done_rows=done_rows,
537
- stage_base=stage_base,
538
- stage_worktree_path=stage_worktree_path,
539
- stage_head=stage_head,
540
- stage_dirty=stage_dirty,
541
- )
542
- except _stage_targets.StageTargetError as exc:
543
- raise _stage_target_prepare_error(exc) from exc
544
436
 
545
437
 
546
438
  def _stage_map_reject_detail(stages, errs):
547
439
  """빈/손상 Stage Map 의 거부 사유 문자열을 돌려준다. 정상이면 None.
548
440
 
549
441
  빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
550
- (resolve_whole_task_target `for stage in stage_map`)가 0회 순회로 vacuous
442
+ (`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
551
443
  통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
552
444
  stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
553
445
  게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
@@ -1509,20 +1401,6 @@ def _git_out(cwd, *args) -> str:
1509
1401
  return r.stdout.strip() if r.returncode == 0 else ""
1510
1402
 
1511
1403
 
1512
- def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
1513
- _stage_auto_reconcile_best_effort(inp, plan_run_root)
1514
-
1515
-
1516
- def _is_ancestor(cwd, commit, head) -> bool:
1517
- from .worktree import is_ancestor
1518
- return is_ancestor(cwd, commit, head)
1519
-
1520
-
1521
- def _is_dirty_excluding_okstra(cwd) -> bool:
1522
- from .worktree import is_dirty_excluding_okstra
1523
- return is_dirty_excluding_okstra(cwd)
1524
-
1525
-
1526
1404
  def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
1527
1405
  """Placeholder until the selected stage registry row is resolved."""
1528
1406
  return WorktreeProvision(
@@ -1552,57 +1430,46 @@ def _format_integration(integ) -> str:
1552
1430
  return "\n".join(lines)
1553
1431
 
1554
1432
 
1555
- def _reserve_final_verification_target(
1556
- 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,
1557
1457
  ) -> None:
1558
- """final-verification 검증 target registry/consumers/git 에서
1559
- 해소하고 gate 를 강제한다. 위반 시 PrepareError. 결과를 ctx 의
1560
- VERIFICATION_* 키로 주입한다."""
1561
- from .consumers import backfill_done_from_carry, read_stage_consumer_state
1562
- from . import worktree_registry as _reg
1563
-
1564
- plan_run_root = plan_run_root_from_approved_plan(inp.approved_plan_path)
1565
- # carry sidecars are the SSOT for stage completion — recover missing `done`
1566
- # rows before the whole-task gate checks every stage.
1567
- backfill_done_from_carry(plan_run_root)
1568
- _auto_reconcile_best_effort(inp, plan_run_root)
1569
- done_rows = read_stage_consumer_state(plan_run_root).done_rows
1570
-
1571
- if inp.stage and inp.stage != "auto":
1572
- n = int(inp.stage)
1573
- row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
1574
- wt_path = (row or {}).get("worktree_path", "")
1575
- stage_base = (row or {}).get("base_ref", "")
1576
- stage_branch = (row or {}).get("branch", "")
1577
- head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
1578
- target = _resolve_single_stage_target(
1579
- requested_stage=inp.stage, done_rows=done_rows,
1580
- stage_base=stage_base, stage_worktree_path=wt_path,
1581
- stage_head=head,
1582
- stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
1583
- )
1584
- ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
1585
- ctx["EXECUTOR_WORKTREE_BRANCH"] = stage_branch
1586
- 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
1587
1465
  ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
1588
1466
  ctx["EXECUTOR_WORKTREE_NOTE"] = (
1589
- f"final-verification uses implementation stage {n} worktree"
1467
+ f"final-verification uses implementation stage {stage} worktree"
1590
1468
  )
1591
- else:
1592
- wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1593
- anchor = _reg.get_implementation_base(
1594
- inp.project_id, inp.task_group, inp.task_id) or ""
1595
- # whole-task 통합(머지+teardown)+게이트 강제는 container 와 공유하는
1596
- # 순수 헬퍼가 단일 책임. ctx 주입만 run.py 측에 남긴다.
1597
- whole = _stage_targets.resolve_and_integrate_whole_task(
1598
- project_id=inp.project_id, task_group=inp.task_group,
1599
- task_id=inp.task_id, task_worktree_path=wt_path,
1600
- stage_map=ctx_stage_map, done_rows=done_rows,
1601
- anchor_base=anchor, teardown=True,
1469
+ if acquisition.integration_result is not None:
1470
+ ctx["STAGE_INTEGRATION"] = _format_integration(
1471
+ acquisition.integration_result
1602
1472
  )
1603
- ctx["STAGE_INTEGRATION"] = _format_integration(whole["integrate_result"])
1604
- target = whole["target"]
1605
-
1606
1473
  diff_stat = _git_out(target.worktree_path, "diff", "--stat",
1607
1474
  f"{target.base}..{target.head}")
1608
1475
  ctx["VERIFICATION_SCOPE"] = target.scope
@@ -1969,6 +1836,215 @@ def _provision_settings_symlink(inp: PrepareInputs) -> None:
1969
1836
  )
1970
1837
 
1971
1838
 
1839
+ @dataclass
1840
+ class _ProvisionedTask:
1841
+ """What one task-key's provisioning critical section hands to run-path compute."""
1842
+
1843
+ worktree: WorktreeProvision
1844
+ stage_run_claim: object | None
1845
+ stage_arg: int | None
1846
+
1847
+
1848
+ def _write_bundle_artifacts(
1849
+ inp: PrepareInputs,
1850
+ ctx: dict,
1851
+ project_root: Path,
1852
+ lead_runtime: str,
1853
+ claude_session_id: str,
1854
+ ) -> None:
1855
+ """Create the task/run directories and the artifacts the lead reads at launch.
1856
+
1857
+ Order is contractual. The fix-cycle ledger runs LAST here but still *before*
1858
+ the instruction-set build in the caller, so a cycle opened by this run shows
1859
+ up in the analysis packet's Fix History; finalize rewrites the manifest after
1860
+ that, which keeps the lazy-close invariant that the on-disk manifest holds
1861
+ pre-rewrite values when the cycle is judged.
1862
+ """
1863
+ _ensure_task_directories(ctx)
1864
+ migrate_legacy_run_artifacts(ctx)
1865
+ cleanup_obsolete_generated_docs(
1866
+ project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
1867
+ )
1868
+ if lead_runtime == "claude-code":
1869
+ # Always materialise the resume command script for Claude Code. Even in
1870
+ # --render-only preparation flows the user (or a later non-interactive
1871
+ # runner) may invoke it manually; deferring its creation until
1872
+ # interactive launch leaves runs/<phase>/sessions/ empty and the
1873
+ # manifest pointing at a path that does not exist. Codex runs resume via
1874
+ # artifacts/checkpoints instead, so they intentionally leave this empty.
1875
+ write_claude_resume_command_file(
1876
+ resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
1877
+ project_root=project_root,
1878
+ claude_session_id=claude_session_id,
1879
+ task_key=ctx["TASK_KEY"],
1880
+ task_type=ctx["TASK_TYPE"],
1881
+ phase_state=ctx["CURRENT_RUN_STATUS"],
1882
+ worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
1883
+ prompt_seq=ctx["RUN_PROMPTS_SEQ"],
1884
+ )
1885
+ ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
1886
+
1887
+
1888
+ def _record_run_in_central_index(
1889
+ inp: PrepareInputs,
1890
+ ctx: dict,
1891
+ workspace_root: Path,
1892
+ run_seq_override: int | None,
1893
+ ) -> None:
1894
+ """Publish this run to ~/.okstra/{recent,active}.jsonl. Never fatal.
1895
+
1896
+ A failure here leaves the central index incomplete but the bundle on disk is
1897
+ already usable, so prepare reports and continues. The rerun path pre-stamps a
1898
+ 'reserving' row before spawning (OKSTRA_RUN_SEQ_OVERRIDE forces that seq); if
1899
+ the promotion to 'running' fails, that row would sit in active.jsonl forever
1900
+ and inflate activeCount, so it is cleaned up whenever the reserved seq is known.
1901
+ """
1902
+ try:
1903
+ _record_start(
1904
+ workspace_root=workspace_root,
1905
+ ctx=ctx,
1906
+ initial_status="prepared" if inp.render_only else "running",
1907
+ canonical_argv=_canonical_argv(inp, ctx),
1908
+ cwd=os.getcwd(),
1909
+ brief_sha256=_brief_sha256(inp.brief_path),
1910
+ )
1911
+ except Exception as exc:
1912
+ print(
1913
+ f"okstra-central: record_start failed; central index will be incomplete ({exc})",
1914
+ file=sys.stderr,
1915
+ )
1916
+ if run_seq_override is not None:
1917
+ _remove_leaked_reservation(ctx, run_seq_override)
1918
+
1919
+
1920
+ def _initial_workflow_ctx(
1921
+ inp: PrepareInputs, forbidden_by_phase: dict
1922
+ ) -> dict[str, str]:
1923
+ """Workflow tokens for a run that has been prepared but not started.
1924
+
1925
+ The run status is not known yet, so both statuses are the pre-launch
1926
+ constants; `_finalize_status_and_render_manifests` recomputes the block once
1927
+ the real status is settled.
1928
+ """
1929
+ task_status, run_status = "ready-for-claude", "not-run"
1930
+ return {
1931
+ "CURRENT_TASK_STATUS": task_status,
1932
+ "CURRENT_RUN_STATUS": run_status,
1933
+ **compute_workflow_state(
1934
+ task_type=inp.task_type,
1935
+ current_run_status=run_status,
1936
+ current_task_status=task_status,
1937
+ render_only=inp.render_only,
1938
+ forbidden_by_phase=forbidden_by_phase,
1939
+ ),
1940
+ }
1941
+
1942
+
1943
+ def _related_tasks_ctx(ctx: dict, inp: PrepareInputs) -> dict[str, str]:
1944
+ """Render tokens for the tasks this run declares a relation to."""
1945
+ items = resolve_related_tasks(
1946
+ task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
1947
+ raw_related=inp.related_tasks_raw,
1948
+ )
1949
+ return {
1950
+ "RELATED_TASKS_JSON": json.dumps(items, ensure_ascii=False),
1951
+ "RELATED_TASKS_BULLETS": related_tasks_bullets(items),
1952
+ "RELATED_TASKS_INLINE": related_tasks_inline(items),
1953
+ }
1954
+
1955
+
1956
+ def _model_ctx(models: "_ModelBindings") -> dict[str, str]:
1957
+ """Render tokens for every model binding this run resolved."""
1958
+ return {
1959
+ "LEAD_MODEL": models.lead.display,
1960
+ "LEAD_MODEL_EXECUTION_VALUE": models.lead.execution,
1961
+ "CLAUDE_WORKER_MODEL": models.cw.display,
1962
+ "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": models.cw.execution,
1963
+ "CODEX_WORKER_MODEL": models.co.display,
1964
+ "CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
1965
+ "ANTIGRAVITY_WORKER_MODEL": models.ge.display,
1966
+ "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
1967
+ "REPORT_WRITER_MODEL": models.rw.display,
1968
+ "REPORT_WRITER_MODEL_EXECUTION_VALUE": models.rw.execution,
1969
+ "EXECUTOR_PROVIDER": models.executor_provider,
1970
+ "EXECUTOR_DISPLAY_NAME": models.executor_display_name,
1971
+ "EXECUTOR_WORKER_AGENT": models.executor_worker_agent,
1972
+ "EXECUTOR_MODEL_DISPLAY": models.executor_model_meta.display,
1973
+ "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
1974
+ "CRITIC_CHOICE": models.critic_choice,
1975
+ }
1976
+
1977
+
1978
+ def _provision_task_and_stage(
1979
+ inp: PrepareInputs,
1980
+ project_root: Path,
1981
+ ctx_stage_map: list,
1982
+ task_group_segment: str,
1983
+ task_id_segment: str,
1984
+ task_key: str,
1985
+ ) -> _ProvisionedTask:
1986
+ """Reserve this run's worktree and, for implementation, its Stage Map stage.
1987
+
1988
+ One worktree per task-key: requirements-discovery, error-analysis,
1989
+ implementation-planning and implementation phases of the same task all share
1990
+ this directory and branch. Runs BEFORE run-path compute: the worktree's
1991
+ degrade status (skipped-*) feeds the implementation Stage Run Claim, and the
1992
+ resolved stage namespaces the run path.
1993
+
1994
+ The whole check→select→`git worktree add`→reserve sequence (task worktree AND
1995
+ implementation stage) is one critical section per task-key: the registry lock
1996
+ alone only covers the reserve row, so without this mutex two concurrent runs
1997
+ can pick the same stage or race the same path/branch at the git level (TOCTOU).
1998
+ """
1999
+ with worktree_provision_mutex(
2000
+ okstra_home(), inp.project_id, task_group_segment, task_id_segment,
2001
+ ):
2002
+ if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
2003
+ worktree = _single_stage_final_verification_worktree(inp)
2004
+ # Single-stage final-verification namespaces its run path under
2005
+ # runs/final-verification/stage-<N> (same isolation as
2006
+ # implementation) so concurrent per-stage verifications never
2007
+ # share state/reports/worker-results.
2008
+ fv_stage_arg = int(inp.stage)
2009
+ else:
2010
+ fv_stage_arg = None
2011
+ try:
2012
+ worktree = provision_task_worktree(
2013
+ task_type=inp.task_type,
2014
+ project_root=project_root,
2015
+ project_id=inp.project_id,
2016
+ task_group_segment=task_group_segment,
2017
+ task_id_segment=task_id_segment,
2018
+ work_category=inp.work_category,
2019
+ base_ref=inp.base_ref,
2020
+ require_base_ref=True,
2021
+ )
2022
+ except RuntimeError as exc:
2023
+ raise PrepareError(
2024
+ f"task worktree provisioning failed: {exc}"
2025
+ ) from exc
2026
+
2027
+ # ---- implementation Stage Run Claim (path-independent) ----
2028
+ # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
2029
+ # lands in runs/implementation/stage-<N>. The registry stage-key is
2030
+ # reserved exactly once here (inside provision_stage_worktree), and
2031
+ # the surrounding mutex makes the registry read in the claim
2032
+ # and that reserve atomic. Other task-types skip this claim;
2033
+ # single-stage final-verification threads its explicit stage via
2034
+ # fv_stage_arg, everything else keeps stage_arg=None (flat paths).
2035
+ if inp.task_type == "implementation":
2036
+ stage_run_claim = _claim_implementation_stage_run(
2037
+ inp, ctx_stage_map, task_group_segment, task_id_segment,
2038
+ task_key, worktree.status,
2039
+ )
2040
+ # Drop any stale waiver on this stage so the run actually verifies
2041
+ # conformance (kept inside the per-task-key mutex so concurrent
2042
+ # same-task runs don't race the manifest write).
2043
+ _clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
2044
+ return _ProvisionedTask(worktree, stage_run_claim, stage_run_claim.stage)
2045
+ return _ProvisionedTask(worktree, None, fv_stage_arg)
2046
+
2047
+
1972
2048
  def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1973
2049
  """Produce a complete okstra task bundle on disk. See module docstring."""
1974
2050
  workspace_root = Path(inp.workspace_root)
@@ -2011,12 +2087,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2011
2087
  pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
2012
2088
 
2013
2089
  models = _resolve_model_bindings(inp, workers)
2014
- lead, cw, co, ge, rw = models.lead, models.cw, models.co, models.ge, models.rw
2015
- critic_choice = models.critic_choice
2016
- executor_provider = models.executor_provider
2017
- executor_display_name = models.executor_display_name
2018
- executor_worker_agent = models.executor_worker_agent
2019
- executor_model_meta = models.executor_model_meta
2020
2090
 
2021
2091
  # ---- paths under per-task mutex (writes run-context-*.json) ----
2022
2092
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
@@ -2043,67 +2113,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2043
2113
  task_id=inp.task_id,
2044
2114
  )
2045
2115
 
2046
- # ---- task worktree provisioning (every phase, every task-type) ----
2047
- # One worktree per task-key: requirements-discovery, error-analysis,
2048
- # implementation-planning and implementation phases of the same task
2049
- # all share this directory and branch. Runs BEFORE run-path compute: its
2050
- # degrade status (skipped-*) feeds implementation Stage Run Claim, and
2051
- # the resolved stage namespaces the run path.
2052
- #
2053
- # The whole check→select→`git worktree add`→reserve sequence (task
2054
- # worktree AND implementation stage) is one critical section per
2055
- # task-key: the registry lock alone only covers the reserve row, so
2056
- # without this mutex two concurrent runs can pick the same stage or
2057
- # race the same path/branch at the git level (TOCTOU).
2058
- with worktree_provision_mutex(
2059
- okstra_home(), inp.project_id, task_group_segment, task_id_segment,
2060
- ):
2061
- if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
2062
- worktree = _single_stage_final_verification_worktree(inp)
2063
- # Single-stage final-verification namespaces its run path under
2064
- # runs/final-verification/stage-<N> (same isolation as
2065
- # implementation) so concurrent per-stage verifications never
2066
- # share state/reports/worker-results.
2067
- fv_stage_arg = int(inp.stage)
2068
- else:
2069
- fv_stage_arg = None
2070
- try:
2071
- worktree = provision_task_worktree(
2072
- task_type=inp.task_type,
2073
- project_root=project_root,
2074
- project_id=inp.project_id,
2075
- task_group_segment=task_group_segment,
2076
- task_id_segment=task_id_segment,
2077
- work_category=inp.work_category,
2078
- base_ref=inp.base_ref,
2079
- require_base_ref=True,
2080
- )
2081
- except RuntimeError as exc:
2082
- raise PrepareError(
2083
- f"task worktree provisioning failed: {exc}"
2084
- ) from exc
2085
-
2086
- # ---- implementation Stage Run Claim (path-independent) ----
2087
- # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
2088
- # lands in runs/implementation/stage-<N>. The registry stage-key is
2089
- # reserved exactly once here (inside provision_stage_worktree), and
2090
- # the surrounding mutex makes the registry read in the claim
2091
- # and that reserve atomic. Other task-types skip this claim;
2092
- # single-stage final-verification threads its explicit stage via
2093
- # fv_stage_arg, everything else keeps stage_arg=None (flat paths).
2094
- if inp.task_type == "implementation":
2095
- stage_run_claim = _claim_implementation_stage_run(
2096
- inp, ctx_stage_map, task_group_segment, task_id_segment,
2097
- task_key, worktree.status,
2098
- )
2099
- stage_arg = stage_run_claim.stage
2100
- # Drop any stale waiver on this stage so the run actually verifies
2101
- # conformance (kept inside the per-task-key mutex so concurrent
2102
- # same-task runs don't race the manifest write).
2103
- _clear_stale_stage_waiver(inp, project_root, stage_run_claim.stage)
2104
- else:
2105
- stage_run_claim = None
2106
- stage_arg = fv_stage_arg
2116
+ provisioned = _provision_task_and_stage(
2117
+ inp, project_root, ctx_stage_map,
2118
+ task_group_segment, task_id_segment, task_key,
2119
+ )
2120
+ worktree = provisioned.worktree
2121
+ stage_run_claim = provisioned.stage_run_claim
2122
+ stage_arg = provisioned.stage_arg
2107
2123
 
2108
2124
  ctx = compute_and_write_run_context(
2109
2125
  workspace_root=workspace_root, project_root=project_root,
@@ -2154,13 +2170,6 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2154
2170
  # ---- material + related-tasks ----
2155
2171
  profile_content = _expand_profile_includes(profile_file)
2156
2172
  review_material = build_analysis_material(inp.brief_path, inp.directive)
2157
- related_items = resolve_related_tasks(
2158
- task_manifest_path=Path(ctx["TASK_MANIFEST_PATH"]),
2159
- raw_related=inp.related_tasks_raw,
2160
- )
2161
- related_tasks_json_str = json.dumps(related_items, ensure_ascii=False)
2162
- bullets = related_tasks_bullets(related_items)
2163
- inline = related_tasks_inline(related_items)
2164
2173
 
2165
2174
  # ---- relative paths for brief + clarification ----
2166
2175
  brief_relative = relative_to_project_root(inp.brief_path, project_root)
@@ -2169,17 +2178,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2169
2178
  if inp.clarification_response_path else ""
2170
2179
  )
2171
2180
 
2172
- # ---- initial workflow state (current_run_status not yet known) ----
2173
- initial_task_status = "ready-for-claude"
2174
- initial_run_status = "not-run"
2175
2181
  forbidden_by_phase = load_phase_forbidden(workspace_root)
2176
- workflow_state = compute_workflow_state(
2177
- task_type=inp.task_type,
2178
- current_run_status=initial_run_status,
2179
- current_task_status=initial_task_status,
2180
- render_only=inp.render_only,
2181
- forbidden_by_phase=forbidden_by_phase,
2182
- )
2183
2182
 
2184
2183
  # ---- assemble full ctx (the values render functions expect) ----
2185
2184
  ctx.update({
@@ -2196,27 +2195,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2196
2195
  "CLARIFICATION_RESPONSE_RELATIVE_PATH": clarification_relative,
2197
2196
  "BRIEF_FILE_PATH": str(inp.brief_path),
2198
2197
  "BRIEF_RELATIVE_PATH": brief_relative,
2199
- "LEAD_MODEL": lead.display,
2200
- "LEAD_MODEL_EXECUTION_VALUE": lead.execution,
2201
- "CLAUDE_WORKER_MODEL": cw.display,
2202
- "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
2203
- "CODEX_WORKER_MODEL": co.display,
2204
- "CODEX_WORKER_MODEL_EXECUTION_VALUE": models.codex_worker_execution,
2205
- "ANTIGRAVITY_WORKER_MODEL": ge.display,
2206
- "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": models.antigravity_worker_execution,
2207
- "REPORT_WRITER_MODEL": rw.display,
2208
- "REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
2209
- "EXECUTOR_PROVIDER": executor_provider,
2210
- "EXECUTOR_DISPLAY_NAME": executor_display_name,
2211
- "EXECUTOR_WORKER_AGENT": executor_worker_agent,
2212
- "EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
2213
- "EXECUTOR_MODEL_EXECUTION_VALUE": models.executor_execution,
2214
- "CRITIC_CHOICE": critic_choice,
2215
- "RELATED_TASKS_JSON": related_tasks_json_str,
2216
- "RELATED_TASKS_BULLETS": bullets,
2217
- "RELATED_TASKS_INLINE": inline,
2218
- "CURRENT_TASK_STATUS": initial_task_status,
2219
- "CURRENT_RUN_STATUS": initial_run_status,
2198
+ **_model_ctx(models),
2199
+ **_related_tasks_ctx(ctx, inp),
2200
+ **_initial_workflow_ctx(inp, forbidden_by_phase),
2220
2201
  "VALIDATION_STATUS": "not-run",
2221
2202
  "VALIDATION_UPDATED_AT": "",
2222
2203
  "VALIDATION_FAILURES_JSON": "[]",
@@ -2224,45 +2205,20 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2224
2205
  "LATEST_REPORT_RELATIVE_PATH": "",
2225
2206
  "RENDER_ONLY": "true" if inp.render_only else "false",
2226
2207
  "OKSTRA_VERSION": installed_version(),
2227
- **workflow_state,
2228
2208
  })
2229
2209
  if lead_runtime == "codex":
2230
2210
  ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
2231
2211
  ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
2232
2212
  ctx["CLAUDE_RESUME_COMMAND_FILENAME"] = ""
2233
2213
  if inp.task_type == "final-verification":
2234
- _reserve_final_verification_target(inp, ctx, ctx_stage_map)
2235
-
2236
- # ---- prepare directories + cleanup ----
2237
- _ensure_task_directories(ctx)
2238
- migrate_legacy_run_artifacts(ctx)
2239
- cleanup_obsolete_generated_docs(
2240
- project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
2241
- )
2242
- if lead_runtime == "claude-code":
2243
- # Always materialise the resume command script for Claude Code. Even in
2244
- # --render-only preparation flows the user (or a later non-interactive
2245
- # runner) may invoke it manually; deferring its creation until
2246
- # interactive launch leaves runs/<phase>/sessions/ empty and the
2247
- # manifest pointing at a path that does not exist. Codex runs resume via
2248
- # artifacts/checkpoints instead, so they intentionally leave this empty.
2249
- write_claude_resume_command_file(
2250
- resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
2251
- project_root=project_root,
2252
- claude_session_id=claude_session_id,
2253
- task_key=ctx["TASK_KEY"],
2254
- task_type=ctx["TASK_TYPE"],
2255
- phase_state=ctx["CURRENT_RUN_STATUS"],
2256
- worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
2257
- prompt_seq=ctx["RUN_PROMPTS_SEQ"],
2214
+ acquisition = _stage_targets.acquire_final_verification_target(
2215
+ _final_verification_target_request(inp, ctx_stage_map)
2258
2216
  )
2217
+ _apply_final_verification_target(ctx, acquisition)
2259
2218
 
2260
- # ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
2261
- # instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
2262
- # cycle 도 analysis-packet 의 Fix History 에 보이도록 한다. finalize 가
2263
- # 여전히 이후에 manifest 를 재작성하므로 lazy-close 의 "디스크 manifest 는
2264
- # prepare 재작성 전 값" 불변식은 유지된다.
2265
- ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
2219
+ _write_bundle_artifacts(
2220
+ inp, ctx, project_root, lead_runtime, claude_session_id,
2221
+ )
2266
2222
 
2267
2223
  # ---- write instruction-set scaffolding + lead prompt ----
2268
2224
  instruction_set = _write_instruction_set_sources(
@@ -2281,29 +2237,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2281
2237
  )
2282
2238
  _record_artifact_runtime_render_only_event(inp, ctx)
2283
2239
 
2284
- # ---- central index ----
2285
- initial_status = "prepared" if inp.render_only else "running"
2286
- canonical_argv = _canonical_argv(inp, ctx)
2287
- try:
2288
- _record_start(
2289
- workspace_root=workspace_root,
2290
- ctx=ctx,
2291
- initial_status=initial_status,
2292
- canonical_argv=canonical_argv,
2293
- cwd=os.getcwd(),
2294
- brief_sha256=_brief_sha256(inp.brief_path),
2295
- )
2296
- except Exception as exc:
2297
- print(
2298
- f"okstra-central: record_start failed; central index will be incomplete ({exc})",
2299
- file=sys.stderr,
2300
- )
2301
- # rerun 경로는 spawn 전에 'reserving' row 를 미리 박아 둔다
2302
- # (OKSTRA_RUN_SEQ_OVERRIDE 가 그 seq 를 강제한다). record_start 가
2303
- # 'reserving' → 'running' 승격에 실패하면 그 row 가 active.jsonl 에
2304
- # 영구히 남아 activeCount 를 부풀린다. 예약 seq 를 알 때만 정리한다.
2305
- if run_seq_override is not None:
2306
- _remove_leaked_reservation(ctx, run_seq_override)
2240
+ _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
2307
2241
 
2308
2242
  if not inp.render_only:
2309
2243
  _provision_settings_symlink(inp)