okstra 0.50.0 → 0.52.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 (61) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +15 -11
  11. package/runtime/agents/workers/claude-worker.md +3 -3
  12. package/runtime/agents/workers/codex-worker.md +2 -2
  13. package/runtime/agents/workers/gemini-worker.md +2 -2
  14. package/runtime/bin/lib/okstra/cli.sh +8 -1
  15. package/runtime/bin/lib/okstra/globals.sh +3 -0
  16. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  17. package/runtime/bin/lib/okstra/usage.sh +6 -0
  18. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  19. package/runtime/bin/okstra.sh +2 -0
  20. package/runtime/prompts/launch.template.md +3 -1
  21. package/runtime/prompts/profiles/_common-contract.md +4 -4
  22. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  23. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  24. package/runtime/prompts/profiles/implementation-planning.md +8 -4
  25. package/runtime/prompts/profiles/implementation.md +1 -1
  26. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  27. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  28. package/runtime/python/okstra_ctl/migrate.py +2 -12
  29. package/runtime/python/okstra_ctl/paths.py +22 -0
  30. package/runtime/python/okstra_ctl/render.py +284 -125
  31. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  32. package/runtime/python/okstra_ctl/run.py +507 -245
  33. package/runtime/python/okstra_ctl/sequence.py +2 -5
  34. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  35. package/runtime/python/okstra_ctl/wizard.py +129 -133
  36. package/runtime/python/okstra_ctl/worktree.py +13 -5
  37. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  38. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  39. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  40. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  47. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  48. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  49. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  50. package/runtime/skills/okstra-memory/SKILL.md +28 -5
  51. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  52. package/runtime/skills/okstra-run/SKILL.md +1 -1
  53. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  54. package/runtime/templates/reports/final-report.template.md +1 -0
  55. package/runtime/templates/worker-prompt-preamble.md +3 -3
  56. package/runtime/validators/validate-implementation-plan-stages.py +57 -11
  57. package/src/_python-helper.mjs +3 -3
  58. package/src/context-cost.mjs +27 -0
  59. package/src/install.mjs +1 -0
  60. package/src/memory.mjs +50 -11
  61. package/src/uninstall.mjs +1 -0
@@ -27,6 +27,7 @@ from datetime import datetime, timezone
27
27
  from pathlib import Path
28
28
 
29
29
  from okstra_project import project_json_path, upsert_project_json
30
+ from .analysis_packet import build_analysis_packet
30
31
  from .clarification_items import unresolved_approval_blockers
31
32
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
32
33
  from .material import (
@@ -36,12 +37,13 @@ from .material import (
36
37
  resolve_related_tasks,
37
38
  )
38
39
  from .final_report_schema import load_schema
39
- from .models import resolve_model_metadata
40
+ from .models import ModelAssignment, resolve_model_metadata
40
41
  from .schema_excerpt import build_schema_excerpt
41
42
  from .path_resolve import relative_to_project_root, resolve_user_file
42
43
  from .render import (
43
44
  apply_lead_prompt_defaults,
44
45
  inject_lead_prompt_computed_tokens,
46
+ render_active_run_context,
45
47
  render_latest_task_discovery,
46
48
  render_reference_expectations,
47
49
  render_run_manifest,
@@ -86,6 +88,17 @@ APPROVED_FRONTMATTER_PATTERN = re.compile(
86
88
  re.IGNORECASE | re.MULTILINE,
87
89
  )
88
90
 
91
+ # Frontmatter implementation-option matcher.
92
+ #
93
+ # `approved:` 바로 아래 줄의 `implementation-option:` 한 줄을 식별한다.
94
+ # report-writer 가 빈 값으로 항상 emit 하므로 라인은 존재하되 값은 비어 있을
95
+ # 수 있다. `--implementation-option <name>` CLI 가 이 줄의 값만 치환한다.
96
+ # 값이 비면 implementation 은 plan 의 `Recommended Option` 으로 폴백한다.
97
+ IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN = re.compile(
98
+ r"^implementation-option:[ \t]*(.*)$",
99
+ re.MULTILINE,
100
+ )
101
+
89
102
  _FRONTMATTER_BLOCK_PATTERN = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
90
103
 
91
104
 
@@ -132,6 +145,11 @@ class PrepareInputs:
132
145
  pr_template_path: str = ""
133
146
  render_only: bool = False
134
147
  approve_plan_ack: bool = False
148
+ # implementation 전용: 유저가 implementation-planning final-report 에서 고른
149
+ # Option Candidate 이름. `--implementation-option <name>` 으로 전달되어
150
+ # approved-plan frontmatter 의 `implementation-option:` 라인을 채운다. 빈
151
+ # 문자열이면 implementation 은 plan 의 `Recommended Option` 으로 폴백한다.
152
+ implementation_option: str = ""
135
153
  # Phase 6 plan-body verification opt-out. Default True (round runs after
136
154
  # report-writer draft). Flipped to False by CLI `--no-plan-verification`.
137
155
  # Only meaningful for `--task-type implementation-planning`; the manifest
@@ -345,6 +363,62 @@ def _apply_cli_approval(path: str) -> str:
345
363
  return "frontmatter-flipped"
346
364
 
347
365
 
366
+ def _apply_cli_implementation_option(path: str, option_name: str) -> str:
367
+ """`--implementation-option <name>` 이 지정된 경우 approved-plan frontmatter 의
368
+ `implementation-option:` 라인 값을 `option_name` 으로 치환한다.
369
+
370
+ 동작 요약:
371
+ - frontmatter 의 `implementation-option:` 라인을
372
+ `implementation-option: <option_name>` 으로 교체 → `"frontmatter-set"`.
373
+ - audit 라인을 한 번 append (idempotent: 동일 audit 라인은 한 번만).
374
+ - frontmatter 가 없거나 `implementation-option:` 라인이 없으면 PrepareError
375
+ (report-writer 가 빈 값으로 이 라인을 항상 emit 하므로 라인은 존재해야 한다).
376
+
377
+ `--approve` (`_apply_cli_approval`) 의 frontmatter-치환 + audit-append 패턴을
378
+ 그대로 미러한다.
379
+ """
380
+ p = Path(path)
381
+ if not p.is_file():
382
+ raise PrepareError(f"approved plan file not found: {path}")
383
+ body = p.read_text(encoding="utf-8", errors="replace")
384
+ frontmatter = _extract_frontmatter_block(body)
385
+ if frontmatter is None:
386
+ raise PrepareError(
387
+ f"--implementation-option was given but the approved-plan file has no YAML frontmatter: {path}\n"
388
+ " expected the report to begin with `---\\n...\\n---\\n`."
389
+ )
390
+ if not IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.search(frontmatter):
391
+ raise PrepareError(
392
+ f"--implementation-option was given but the approved-plan frontmatter has no "
393
+ f"`implementation-option:` field: {path}\n"
394
+ " expected a single line of the form `implementation-option:` "
395
+ "(report-writer worker emits this empty by default)."
396
+ )
397
+
398
+ audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
399
+ audit_line = (
400
+ f"- 선택 옵션 (CLI): {audit_iso} — `{option_name}` recorded by "
401
+ "`okstra --implementation-option` (user-selected Option Candidate)"
402
+ )
403
+
404
+ # 렌더 경로(`final-report.template.md` 의 `yaml_scalar`)와 동일하게 값을
405
+ # YAML 따옴표 처리한다. 옵션명은 보통 `: `(콜론+공백)를 포함하므로 따옴표
406
+ # 없이 쓰면 frontmatter 가 invalid YAML 이 되고 CLI/렌더 출력이 어긋난다.
407
+ # 함수 치환을 써서 quoted 값 안의 백슬래시/그룹 참조가 re 치환 문법으로
408
+ # 재해석되는 것까지 막는다 (리터럴 치환).
409
+ from .render_final_report import _yaml_scalar
410
+
411
+ quoted = _yaml_scalar(option_name)
412
+ set_frontmatter = IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.sub(
413
+ lambda _m: f"implementation-option: {quoted}", frontmatter, count=1,
414
+ )
415
+ new_body = body.replace(frontmatter, set_frontmatter, 1)
416
+ if audit_line.split(" — ")[1] not in new_body:
417
+ new_body = new_body.rstrip("\n") + "\n" + audit_line + "\n"
418
+ p.write_text(new_body, encoding="utf-8")
419
+ return "frontmatter-set"
420
+
421
+
348
422
  def _ensure_task_directories(ctx: dict) -> None:
349
423
  for key in (
350
424
  "TASK_ROOT", "INSTRUCTION_SET_PATH", "RUNS_DIR", "HISTORY_DIR",
@@ -492,6 +566,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
492
566
  ("--task-brief", str(inp.brief_path)),
493
567
  ("--directive", inp.directive),
494
568
  ("--approved-plan", inp.approved_plan_path),
569
+ ("--implementation-option", inp.implementation_option),
495
570
  ("--clarification-response", inp.clarification_response_path),
496
571
  ("--workers", workers),
497
572
  ("--lead-model", inp.lead_model or ctx.get("LEAD_MODEL", "")),
@@ -546,23 +621,42 @@ def _expand_profile_includes(profile_path: Path, _depth: int = 0) -> str:
546
621
  return _INCLUDE_DIRECTIVE.sub(_sub, text)
547
622
 
548
623
 
549
- def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
550
- """Produce a complete okstra task bundle on disk. See module docstring."""
551
- workspace_root = Path(inp.workspace_root)
552
- project_root = Path(inp.project_root)
624
+ # ---------------------------------------------------------------------------
625
+ # prepare_task_bundle 단계(phase) 헬퍼.
626
+ #
627
+ # 분리하는가: prepare_task_bundle 은 input 검증 → 에셋 해소 → 프로젝트 등록
628
+ # → roster/model/executor 해소 → run-context/worktree → stage 예약 →
629
+ # instruction-set/manifest 렌더 → central record 까지 8개 독립 책임을 순차로
630
+ # 수행한다. 각 책임을 명시적 입력/반환을 갖는 헬퍼로 빼내면 (a) 개별 단위
631
+ # 테스트가 가능해지고 (b) prepare_task_bundle 본문이 "무엇을 어떤 순서로
632
+ # 엮는가" 만 드러내는 thin orchestrator 로 남는다. ctx dict 조립은 의도적으로
633
+ # orchestrator 에 남겨 데이터 흐름을 한눈에 보이게 한다.
634
+ # ---------------------------------------------------------------------------
635
+
636
+ _INSTALL_HINT = (
637
+ " This file ships with the okstra package; its absence usually means a stale "
638
+ "or partial install. Run 'okstra ensure-installed' (or 'okstra install' again) "
639
+ "to repair the runtime. If the problem persists, run 'okstra doctor' for a "
640
+ "fuller diagnostic."
641
+ )
553
642
 
554
- # ---- validate inputs ----
555
- # Hint suffix added to every "okstra runtime asset missing" PrepareError below.
556
- # These files ship with the package and live under runtime/ — if any are
557
- # missing the install is incomplete or stale, not a user-content issue.
558
- _INSTALL_HINT = (
559
- " This file ships with the okstra package; its absence usually means a stale "
560
- "or partial install. Run 'okstra ensure-installed' (or 'okstra install' again) "
561
- "to repair the runtime. If the problem persists, run 'okstra doctor' for a "
562
- "fuller diagnostic."
563
- )
564
- profile_dir = workspace_root / "prompts" / "profiles"
565
- profile_file = profile_dir / f"{inp.task_type}.md"
643
+
644
+ @dataclass
645
+ class _ResolvedAssets:
646
+ """런타임 에셋 경로 묶음. 모든 파일은 패키지와 함께 배포되며, 누락은
647
+ user-content 문제가 아니라 설치 불완전을 뜻한다 (_INSTALL_HINT)."""
648
+
649
+ profile_file: Path
650
+ prompt_template: Path
651
+ task_index_template: Path
652
+ final_report_template: Path
653
+ source_skill: Path
654
+ run_validator: Path
655
+
656
+
657
+ def _resolve_runtime_assets(workspace_root: Path, inp: PrepareInputs) -> _ResolvedAssets:
658
+ """task-type 별 profile + 공통 템플릿/스킬/validator 경로를 해소하고 존재를 확인한다."""
659
+ profile_file = workspace_root / "prompts" / "profiles" / f"{inp.task_type}.md"
566
660
  if not profile_file.is_file():
567
661
  raise PrepareError(
568
662
  f"analysis profile file not found for task-type {inp.task_type}: "
@@ -586,10 +680,24 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
586
680
  raise PrepareError(
587
681
  f"required okstra template or source skill missing: {required}.{_INSTALL_HINT}"
588
682
  )
683
+ return _ResolvedAssets(
684
+ profile_file=profile_file,
685
+ prompt_template=prompt_template,
686
+ task_index_template=task_index_template,
687
+ final_report_template=final_report_template,
688
+ source_skill=source_skill,
689
+ run_validator=run_validator,
690
+ )
691
+
692
+
693
+ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
694
+ """project_root/brief 존재와 task-type 별 입력 의미(plan 승인·stage·clarification)
695
+ 를 검증하고, implementation 일 때 stage map 을 파싱해 돌려준다 (그 외엔 빈 리스트)."""
589
696
  if not project_root.is_dir():
590
697
  raise PrepareError(f"project root not found: {project_root}")
591
698
  if not inp.brief_path.is_file():
592
699
  raise PrepareError(f"task brief not found: {inp.brief_path}")
700
+ ctx_stage_map: list = []
593
701
  if inp.task_type == "implementation":
594
702
  if not inp.approved_plan_path:
595
703
  raise PrepareError(
@@ -600,6 +708,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
600
708
  # 파일의 frontmatter approved 를 true 로 toggle 한 뒤 동일한 검증
601
709
  # 경로(`_validate_approved_plan`) 를 그대로 통과시킨다.
602
710
  _apply_cli_approval(inp.approved_plan_path)
711
+ if inp.implementation_option:
712
+ # 유저가 고른 Option Candidate 이름을 approved-plan frontmatter 의
713
+ # `implementation-option:` 라인에 기록한다. 빈 값이면 implementation 이
714
+ # plan 의 `Recommended Option` 으로 폴백하므로 호출하지 않는다.
715
+ _apply_cli_implementation_option(
716
+ inp.approved_plan_path, inp.implementation_option
717
+ )
603
718
  _validate_approved_plan(inp.approved_plan_path)
604
719
  _validate_stage_structure(inp.approved_plan_path)
605
720
  ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
@@ -611,6 +726,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
611
726
  "--approve is only meaningful with --task-type implementation "
612
727
  "and --approved-plan <path>"
613
728
  )
729
+ if inp.implementation_option:
730
+ # implementation 외 task-type 에서 `--implementation-option` 도 의미가
731
+ # 없다 (`--approve` 와 동일). 조용히 무시하지 않고 즉시 거부한다.
732
+ raise PrepareError(
733
+ "--implementation-option is only meaningful with --task-type "
734
+ "implementation and --approved-plan <path>"
735
+ )
614
736
  if inp.stage != "auto":
615
737
  raise PrepareError(
616
738
  f"--stage is only meaningful with --task-type implementation; "
@@ -620,11 +742,11 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
620
742
  raise PrepareError(
621
743
  f"clarification response file not found: {inp.clarification_response_path}"
622
744
  )
745
+ return ctx_stage_map
623
746
 
624
- # ---- installation check ----
625
- verify_installation(workspace_root)
626
747
 
627
- # ---- project.json upsert (self-registration) ----
748
+ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
749
+ """project.json self-registration + (implementation 한정) qaCommands gate 검증."""
628
750
  from okstra_project import ResolverError
629
751
 
630
752
  try:
@@ -652,95 +774,375 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
652
774
  if qa_errors:
653
775
  raise PrepareError(_format_qa_errors(qa_errors))
654
776
 
655
- # ---- workers resolution ----
656
- # release-handoff is intentionally single-lead (no worker dispatch, no
657
- # TeamCreate, no convergence). The profile has no `- Required workers:`
658
- # block; force an empty roster regardless of any user override so the
659
- # rendered task bundle stays consistent with the profile contract.
660
- if inp.task_type == "release-handoff":
661
- workers: list[str] = []
662
- else:
663
- profile_workers = resolve_profile_workers(profile_file)
664
- profile_workers_csv = ",".join(profile_workers)
665
- workers = normalize_workers(inp.workers_override or profile_workers_csv)
666
- if inp.workers_override.strip():
667
- validate_workers_against_profile(workers, profile_workers)
668
- if not workers:
669
- raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
670
- selected_reviewers = ",".join(workers)
671
-
672
- # ---- PR template resolution (release-handoff only) ----
673
- pr_template_path_str = ""
674
- pr_template_source = ""
777
+
778
+ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str], str]:
779
+ """profile `Required workers:` 블록을 권위로 roster 해소한다.
780
+
781
+ release-handoff 의도적으로 single-lead (worker dispatch / TeamCreate /
782
+ convergence 없음). profile 에 `- Required workers:` 블록이 없으므로 어떤
783
+ override 와도 빈 roster 를 강제해 profile 계약과 일관성을 유지한다."""
675
784
  if inp.task_type == "release-handoff":
676
- try:
677
- resolved_tpl = resolve_pr_template_path(
678
- Path(inp.project_root), inp.pr_template_path
679
- )
680
- except PrTemplateError as exc:
681
- raise PrepareError(f"PR template resolution failed: {exc}") from exc
682
- pr_template_path_str = str(resolved_tpl.path)
683
- pr_template_source = resolved_tpl.source
785
+ return [], ""
786
+ profile_workers = resolve_profile_workers(profile_file)
787
+ profile_workers_csv = ",".join(profile_workers)
788
+ workers = normalize_workers(inp.workers_override or profile_workers_csv)
789
+ if inp.workers_override.strip():
790
+ validate_workers_against_profile(workers, profile_workers)
791
+ if not workers:
792
+ raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
793
+ return workers, ",".join(workers)
794
+
795
+
796
+ def _resolve_pr_template(inp: PrepareInputs) -> tuple[str, str]:
797
+ """release-handoff 전용 PR 본문 템플릿 경로 + 출처를 해소한다 (그 외엔 빈 값)."""
798
+ if inp.task_type != "release-handoff":
799
+ return "", ""
800
+ try:
801
+ resolved_tpl = resolve_pr_template_path(Path(inp.project_root), inp.pr_template_path)
802
+ except PrTemplateError as exc:
803
+ raise PrepareError(f"PR template resolution failed: {exc}") from exc
804
+ return str(resolved_tpl.path), resolved_tpl.source
684
805
 
685
- # ---- model assignments ----
806
+
807
+ @dataclass
808
+ class _ModelBindings:
809
+ """이 run 의 lead/worker 모델 + critic + executor 바인딩."""
810
+
811
+ lead: ModelAssignment
812
+ cw: ModelAssignment
813
+ co: ModelAssignment
814
+ ge: ModelAssignment
815
+ rw: ModelAssignment
816
+ critic_choice: str
817
+ executor_provider: str
818
+ executor_display_name: str
819
+ executor_worker_agent: str
820
+ executor_model_meta: ModelAssignment
821
+
822
+
823
+ def _resolve_worker_models(inp: PrepareInputs) -> dict:
824
+ """lead/claude/codex/gemini/report-writer 모델을 alias → (display, execution) 로 해소."""
686
825
  lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
687
826
  claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "opus")
688
827
  codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
689
828
  gemini_default = _default("OKSTRA_DEFAULT_GEMINI_MODEL", "auto")
690
829
  report_writer_default = _default("OKSTRA_DEFAULT_REPORT_WRITER_MODEL", lead_default)
691
- lead = resolve_model_metadata(
692
- provider="claude", raw_value=inp.lead_model,
693
- default_display=lead_default, default_execution=lead_default,
694
- )
695
- cw = resolve_model_metadata(
696
- provider="claude", raw_value=inp.claude_model,
697
- default_display=claude_default, default_execution=claude_default,
698
- )
699
- co = resolve_model_metadata(
700
- provider="codex", raw_value=inp.codex_model,
701
- default_display=codex_default, default_execution=codex_default,
702
- )
703
- ge = resolve_model_metadata(
704
- provider="gemini", raw_value=inp.gemini_model,
705
- default_display=gemini_default, default_execution=gemini_default,
706
- )
707
- rw = resolve_model_metadata(
708
- provider="claude", raw_value=inp.report_writer_model,
709
- default_display=report_writer_default, default_execution=report_writer_default,
710
- )
830
+ return {
831
+ "lead": resolve_model_metadata(
832
+ provider="claude", raw_value=inp.lead_model,
833
+ default_display=lead_default, default_execution=lead_default,
834
+ ),
835
+ "cw": resolve_model_metadata(
836
+ provider="claude", raw_value=inp.claude_model,
837
+ default_display=claude_default, default_execution=claude_default,
838
+ ),
839
+ "co": resolve_model_metadata(
840
+ provider="codex", raw_value=inp.codex_model,
841
+ default_display=codex_default, default_execution=codex_default,
842
+ ),
843
+ "ge": resolve_model_metadata(
844
+ provider="gemini", raw_value=inp.gemini_model,
845
+ default_display=gemini_default, default_execution=gemini_default,
846
+ ),
847
+ "rw": resolve_model_metadata(
848
+ provider="claude", raw_value=inp.report_writer_model,
849
+ default_display=report_writer_default, default_execution=report_writer_default,
850
+ ),
851
+ }
852
+
711
853
 
712
- # ---- coverage critic choice (validated; phase-gating happens in render) ----
854
+ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBindings:
855
+ """worker 모델 + critic 선택 + executor 바인딩을 한 묶음으로 해소·검증한다."""
856
+ m = _resolve_worker_models(inp)
713
857
  critic_choice = (inp.critic or "").strip().lower()
714
858
  if critic_choice not in ("", "off", "claude", "codex", "gemini"):
715
859
  raise PrepareError(
716
860
  f"--critic must be one of: off, claude, codex, gemini (got: {critic_choice!r})"
717
861
  )
718
-
719
- # ---- executor binding (implementation phase only; recorded universally for manifest consistency) ----
720
862
  executor_default = _default("OKSTRA_DEFAULT_EXECUTOR", "claude")
721
863
  executor_provider = (inp.executor or executor_default).strip().lower()
722
864
  if executor_provider not in ("claude", "codex", "gemini"):
723
865
  raise PrepareError(
724
866
  f"--executor must be one of: claude, codex, gemini (got: {executor_provider!r})"
725
867
  )
726
- if (
727
- inp.task_type == "implementation"
728
- and executor_provider not in workers
729
- ):
868
+ if inp.task_type == "implementation" and executor_provider not in workers:
730
869
  raise PrepareError(
731
870
  f"--executor {executor_provider} requires {executor_provider!r} in "
732
871
  f"--workers, but resolved roster is {workers!r}. "
733
872
  f"Add it explicitly, e.g. --workers {','.join(sorted(set(workers + [executor_provider])))}."
734
873
  )
735
- executor_provider_to_meta = {
736
- "claude": ("Claude executor", "claude-worker", cw),
737
- "codex": ("Codex executor", "codex-worker", co),
738
- "gemini": ("Gemini executor", "gemini-worker", ge),
874
+ provider_to_meta = {
875
+ "claude": ("Claude executor", "claude-worker", m["cw"]),
876
+ "codex": ("Codex executor", "codex-worker", m["co"]),
877
+ "gemini": ("Gemini executor", "gemini-worker", m["ge"]),
739
878
  }
740
- executor_display_name, executor_worker_agent, executor_model_meta = (
741
- executor_provider_to_meta[executor_provider]
879
+ display_name, worker_agent, model_meta = provider_to_meta[executor_provider]
880
+ return _ModelBindings(
881
+ lead=m["lead"], cw=m["cw"], co=m["co"], ge=m["ge"], rw=m["rw"],
882
+ critic_choice=critic_choice,
883
+ executor_provider=executor_provider,
884
+ executor_display_name=display_name,
885
+ executor_worker_agent=worker_agent,
886
+ executor_model_meta=model_meta,
887
+ )
888
+
889
+
890
+ def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map: list) -> None:
891
+ """implementation run 의 ready-set 배치를 선택하고 consumers.jsonl 에 stage 별
892
+ `started` 행을 예약한다. ctx 에 effective_stages / STAGE_BATCH_DIRECTIVE 를 채우고
893
+ inp.stage 를 확정된 배치 CSV 로 덮어쓴다."""
894
+ from .consumers import read_consumers, append_consumer
895
+ import datetime as _dt
896
+
897
+ ctx["parsed_stage_map"] = ctx_stage_map
898
+ plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
899
+ consumed = read_consumers(plan_run_root)
900
+ done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
901
+ effective = _resolve_effective_stages(ctx["parsed_stage_map"], done_stages, inp.stage)
902
+ ctx["effective_stages"] = effective
903
+ csv = ",".join(str(n) for n in effective)
904
+ ctx["EFFECTIVE_STAGES"] = csv
905
+ ctx["STAGE_BATCH_DIRECTIVE"] = (
906
+ f"- **Stage batch for this implementation run:** `{csv}` "
907
+ "(comma-separated stage numbers, ascending). Execute exactly these "
908
+ "Stage Map stages in this order — this is the authoritative scope. "
909
+ "Do NOT recompute the start stage from `consumers.jsonl`; the runtime "
910
+ "already selected and reserved this batch."
911
+ )
912
+ inp.stage = csv
913
+ print(f"selected stages: {csv}", file=sys.stdout)
914
+ head_proc = _subprocess.run(
915
+ ["git", "rev-parse", "HEAD"],
916
+ cwd=inp.project_root, capture_output=True, text=True,
917
+ )
918
+ head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
919
+ now = _dt.datetime.now(_dt.timezone.utc).isoformat()
920
+ for stage_n in effective:
921
+ append_consumer(
922
+ plan_run_root,
923
+ impl_task_key=ctx["TASK_KEY"],
924
+ stage=stage_n,
925
+ status="started",
926
+ started_at=now,
927
+ head_commit=head_sha,
928
+ )
929
+
930
+
931
+ def _write_instruction_set_sources(
932
+ inp: PrepareInputs, ctx: dict, profile_content: str, review_material: str
933
+ ) -> Path:
934
+ """instruction-set 디렉터리에 profile/material/brief/clarification/directive 와
935
+ reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
936
+ instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
937
+ instruction_set.mkdir(parents=True, exist_ok=True)
938
+ profile_rendered = profile_content
939
+ for key in (
940
+ "EXECUTOR_PROVIDER",
941
+ "EXECUTOR_DISPLAY_NAME",
942
+ "EXECUTOR_WORKER_AGENT",
943
+ "EXECUTOR_MODEL_DISPLAY",
944
+ "EXECUTOR_MODEL_EXECUTION_VALUE",
945
+ "EXECUTOR_WORKTREE_PATH",
946
+ "EXECUTOR_WORKTREE_BRANCH",
947
+ "EXECUTOR_WORKTREE_BASE_REF",
948
+ "EXECUTOR_WORKTREE_STATUS",
949
+ "EXECUTOR_WORKTREE_NOTE",
950
+ ):
951
+ profile_rendered = profile_rendered.replace("{{" + key + "}}", ctx.get(key, ""))
952
+ (instruction_set / "analysis-profile.md").write_text(profile_rendered, encoding="utf-8")
953
+ (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
954
+ shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
955
+ if inp.clarification_response_path:
956
+ shutil.copyfile(
957
+ inp.clarification_response_path,
958
+ instruction_set / "clarification-response.md",
959
+ )
960
+ if inp.directive:
961
+ (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
962
+ render_reference_expectations(
963
+ str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
964
+ )
965
+ packet = build_analysis_packet(
966
+ task_key=ctx["TASK_KEY"],
967
+ task_type=ctx["TASK_TYPE"],
968
+ task_brief_path=instruction_set / "task-brief.md",
969
+ analysis_profile_path=instruction_set / "analysis-profile.md",
970
+ reference_expectations_path=instruction_set / "reference-expectations.md",
971
+ clarification_response_path=(
972
+ instruction_set / "clarification-response.md"
973
+ if inp.clarification_response_path else None
974
+ ),
975
+ directive=inp.directive,
976
+ instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
977
+ )
978
+ (instruction_set / "analysis-packet.md").write_text(packet, encoding="utf-8")
979
+ return instruction_set
980
+
981
+
982
+ def _render_lead_prompt_and_snapshot(
983
+ inp: PrepareInputs,
984
+ ctx: dict,
985
+ instruction_set: Path,
986
+ final_report_template: Path,
987
+ prompt_template: Path,
988
+ ) -> str:
989
+ """compute/default 토큰을 ctx 에 주입한 뒤 final-report 템플릿 사본과 lead 실행
990
+ 프롬프트를 렌더하고, 프롬프트 스냅샷을 기록한 후 prompt_text 를 돌려준다."""
991
+ # inject populates ctx with compute + default tokens consumed by the lead
992
+ # prompt render below (claude-execution-prompt.md). The final-report
993
+ # template render is effectively a copy (Jinja2 `{{ var }}` syntax does
994
+ # not match `_TOKEN_RE`); routed through render_template_with_ctx for SOT
995
+ # consistency.
996
+ inject_lead_prompt_computed_tokens(ctx)
997
+ apply_lead_prompt_defaults(ctx)
998
+ render_template_with_ctx(
999
+ str(final_report_template), ctx["FINAL_REPORT_TEMPLATE_PATH"], ctx,
1000
+ )
1001
+ # Per-task-type schema excerpt for the report-writer worker. The full
1002
+ # schema validates the data.json post-hoc (load_schema); the worker only
1003
+ # needs the common structure + this run's task-type block, so we write a
1004
+ # scoped excerpt into the instruction-set rather than make the worker read
1005
+ # the whole 44 KB / all-task-types schema (whose repo `schemas/...` path is
1006
+ # not resolvable from a consumer project's task bundle anyway).
1007
+ #
1008
+ # Guarded: a missing/unreadable schema must NOT break bundle preparation.
1009
+ # If the excerpt cannot be produced (e.g. an older install that predates
1010
+ # the schemas/ copy step), prep proceeds without it — the report-writer
1011
+ # still has the phase-stripped template + skill structure guide, and
1012
+ # validation runs against the full schema regardless.
1013
+ try:
1014
+ _excerpt = build_schema_excerpt(load_schema(), inp.task_type)
1015
+ Path(ctx["FINAL_REPORT_SCHEMA_PATH"]).write_text(
1016
+ json.dumps(_excerpt, indent=2, ensure_ascii=False) + "\n",
1017
+ encoding="utf-8",
1018
+ )
1019
+ except Exception: # noqa: BLE001 — advisory artifact; never fail prep over it
1020
+ pass
1021
+ render_template_with_ctx(
1022
+ str(prompt_template), str(instruction_set / "claude-execution-prompt.md"), ctx,
1023
+ )
1024
+ prompt_text = (instruction_set / "claude-execution-prompt.md").read_text(encoding="utf-8")
1025
+ Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).parent.mkdir(parents=True, exist_ok=True)
1026
+ Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).write_text(prompt_text, encoding="utf-8")
1027
+ return prompt_text
1028
+
1029
+
1030
+ def _persist_run_inputs(
1031
+ inp: PrepareInputs,
1032
+ ctx: dict,
1033
+ models: _ModelBindings,
1034
+ selected_reviewers: str,
1035
+ brief_relative: str,
1036
+ ) -> None:
1037
+ """이 run 의 입력 스냅샷(run-inputs-*.json)을 run-manifests 디렉터리에 기록."""
1038
+ run_inputs_path = write_run_inputs(
1039
+ project_root=Path(inp.project_root),
1040
+ run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
1041
+ task_type_segment=ctx["TASK_TYPE_SEGMENT"],
1042
+ seq=ctx["RUN_MANIFESTS_SEQ"],
1043
+ inputs={
1044
+ "taskBriefPath": brief_relative,
1045
+ "taskBriefAbsolutePath": str(inp.brief_path),
1046
+ "directive": inp.directive,
1047
+ "workers": selected_reviewers,
1048
+ "leadModel": models.lead.display,
1049
+ "claudeModel": models.cw.display,
1050
+ "codexModel": models.co.display,
1051
+ "geminiModel": models.ge.display,
1052
+ "reportWriterModel": models.rw.display,
1053
+ "executor": models.executor_provider,
1054
+ "relatedTasks": inp.related_tasks_raw,
1055
+ "approvedPlanPath": inp.approved_plan_path,
1056
+ "clarificationResponsePath": inp.clarification_response_path,
1057
+ "renderOnly": inp.render_only,
1058
+ },
1059
+ )
1060
+ ctx["RUN_INPUTS_PATH"] = str(run_inputs_path)
1061
+ ctx["RUN_INPUTS_RELATIVE_PATH"] = relative_to_project_root(
1062
+ run_inputs_path, Path(inp.project_root)
742
1063
  )
743
1064
 
1065
+
1066
+ def _finalize_status_and_render_manifests(
1067
+ inp: PrepareInputs, ctx: dict, task_index_template: Path
1068
+ ) -> None:
1069
+ """최종 task/run status 를 확정하고 workflow state 를 재계산한 뒤 team-state,
1070
+ task-manifest, task-index, run-manifest, timeline, discovery 산출물을 렌더한다."""
1071
+ if inp.render_only:
1072
+ ctx["CURRENT_TASK_STATUS"] = "instruction-set-generated"
1073
+ ctx["CURRENT_RUN_STATUS"] = "prepared"
1074
+ else:
1075
+ ctx["CURRENT_TASK_STATUS"] = "claude-session-started"
1076
+ ctx["CURRENT_RUN_STATUS"] = "in-progress"
1077
+ ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_PATH"]
1078
+ ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
1079
+ ctx.update(compute_workflow_state(
1080
+ task_type=inp.task_type,
1081
+ current_run_status=ctx["CURRENT_RUN_STATUS"],
1082
+ current_task_status=ctx["CURRENT_TASK_STATUS"],
1083
+ render_only=inp.render_only,
1084
+ work_category=inp.work_category,
1085
+ ))
1086
+ render_team_state(ctx["TEAM_STATE_PATH"], ctx)
1087
+ render_active_run_context(ctx["ACTIVE_RUN_CONTEXT_PATH"], ctx)
1088
+ render_task_manifest(ctx["TASK_MANIFEST_PATH"], ctx)
1089
+ render_task_index(str(task_index_template), ctx["TASK_INDEX_PATH"], ctx)
1090
+ render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
1091
+ render_timeline(ctx["TIMELINE_PATH"], ctx)
1092
+ render_task_catalog_discovery(ctx["OKSTRA_TASK_CATALOG_FILE"], ctx)
1093
+ render_latest_task_discovery(ctx["OKSTRA_LATEST_TASK_FILE"], ctx)
1094
+
1095
+
1096
+ def _provision_settings_symlink(inp: PrepareInputs) -> None:
1097
+ """non-render-only run 에서 project settings symlink 를 idempotent 하게 보장한다.
1098
+ 실패해도 prepare 를 막지 않고 경고만 출력한다 (worker dispatch 권한 이슈)."""
1099
+ try:
1100
+ link = ensure_project_settings_symlink(project_root=Path(inp.project_root))
1101
+ except SettingsLinkError as exc:
1102
+ print(
1103
+ f"okstra-settings: failed to provision project settings symlink — "
1104
+ f"worker dispatch may be blocked by Claude Code permissions. ({exc})",
1105
+ file=sys.stderr,
1106
+ )
1107
+ else:
1108
+ if link is None:
1109
+ print(
1110
+ "okstra-settings: ~/.okstra/templates/settings.local.json missing — "
1111
+ "re-run 'npx okstra@latest install' (0.14.0+) to provision the symlink target.",
1112
+ file=sys.stderr,
1113
+ )
1114
+
1115
+
1116
+ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1117
+ """Produce a complete okstra task bundle on disk. See module docstring."""
1118
+ workspace_root = Path(inp.workspace_root)
1119
+ project_root = Path(inp.project_root)
1120
+
1121
+ # ---- input validation + asset resolution + roster/model 해소 ----
1122
+ # 각 단계는 명시적 입력/반환을 갖는 헬퍼로 분리되어 있다 (상단 phase 헬퍼
1123
+ # 블록 참조). 아래 orchestrator 는 그 결과를 지역 변수로 언팩해 이후 ctx
1124
+ # 조립 코드가 단일 흐름으로 읽히도록 유지한다.
1125
+ assets = _resolve_runtime_assets(workspace_root, inp)
1126
+ profile_file = assets.profile_file
1127
+ prompt_template = assets.prompt_template
1128
+ task_index_template = assets.task_index_template
1129
+ final_report_template = assets.final_report_template
1130
+ ctx_stage_map = _validate_prepare_inputs(project_root, inp)
1131
+
1132
+ verify_installation(workspace_root)
1133
+ _register_and_check_project(project_root, inp)
1134
+
1135
+ workers, selected_reviewers = _resolve_roster(inp, profile_file)
1136
+ pr_template_path_str, pr_template_source = _resolve_pr_template(inp)
1137
+
1138
+ models = _resolve_model_bindings(inp, workers)
1139
+ lead, cw, co, ge, rw = models.lead, models.cw, models.co, models.ge, models.rw
1140
+ critic_choice = models.critic_choice
1141
+ executor_provider = models.executor_provider
1142
+ executor_display_name = models.executor_display_name
1143
+ executor_worker_agent = models.executor_worker_agent
1144
+ executor_model_meta = models.executor_model_meta
1145
+
744
1146
  # ---- paths under per-task mutex (writes run-context-*.json) ----
745
1147
  # OKSTRA_RUN_SEQ_OVERRIDE: okstra-ctl rerun / 테스트 hook 이 미리 reserve
746
1148
  # 한 seq 를 강제하는 user-knob 환경 변수.
@@ -867,43 +1269,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
867
1269
  **workflow_state,
868
1270
  })
869
1271
  if inp.task_type == "implementation":
870
- ctx["parsed_stage_map"] = ctx_stage_map
871
- # Resolve the ready-set batch and append a `started` row per batched stage.
872
- from .consumers import read_consumers, append_consumer
873
- import datetime as _dt
874
- plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
875
- consumed = read_consumers(plan_run_root)
876
- done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
877
- effective = _resolve_effective_stages(
878
- ctx["parsed_stage_map"], done_stages, inp.stage
879
- )
880
- ctx["effective_stages"] = effective
881
- csv = ",".join(str(n) for n in effective)
882
- ctx["EFFECTIVE_STAGES"] = csv
883
- ctx["STAGE_BATCH_DIRECTIVE"] = (
884
- f"- **Stage batch for this implementation run:** `{csv}` "
885
- "(comma-separated stage numbers, ascending). Execute exactly these "
886
- "Stage Map stages in this order — this is the authoritative scope. "
887
- "Do NOT recompute the start stage from `consumers.jsonl`; the runtime "
888
- "already selected and reserved this batch."
889
- )
890
- inp.stage = csv
891
- print(f"selected stages: {csv}", file=sys.stdout)
892
- head_proc = _subprocess.run(
893
- ["git", "rev-parse", "HEAD"],
894
- cwd=inp.project_root, capture_output=True, text=True,
895
- )
896
- head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
897
- now = _dt.datetime.now(_dt.timezone.utc).isoformat()
898
- for stage_n in effective:
899
- append_consumer(
900
- plan_run_root,
901
- impl_task_key=ctx["TASK_KEY"],
902
- stage=stage_n,
903
- status="started",
904
- started_at=now,
905
- head_commit=head_sha,
906
- )
1272
+ _reserve_implementation_stages(inp, ctx, ctx_stage_map)
907
1273
 
908
1274
  # ---- prepare directories + cleanup ----
909
1275
  _ensure_task_directories(ctx)
@@ -927,122 +1293,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
927
1293
  prompt_seq=ctx["RUN_PROMPTS_SEQ"],
928
1294
  )
929
1295
 
930
- # ---- write instruction-set scaffolding ----
931
- instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
932
- instruction_set.mkdir(parents=True, exist_ok=True)
933
- profile_rendered = profile_content
934
- for key in (
935
- "EXECUTOR_PROVIDER",
936
- "EXECUTOR_DISPLAY_NAME",
937
- "EXECUTOR_WORKER_AGENT",
938
- "EXECUTOR_MODEL_DISPLAY",
939
- "EXECUTOR_MODEL_EXECUTION_VALUE",
940
- "EXECUTOR_WORKTREE_PATH",
941
- "EXECUTOR_WORKTREE_BRANCH",
942
- "EXECUTOR_WORKTREE_BASE_REF",
943
- "EXECUTOR_WORKTREE_STATUS",
944
- "EXECUTOR_WORKTREE_NOTE",
945
- ):
946
- profile_rendered = profile_rendered.replace("{{" + key + "}}", ctx.get(key, ""))
947
- (instruction_set / "analysis-profile.md").write_text(profile_rendered, encoding="utf-8")
948
- (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
949
- shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
950
- if inp.clarification_response_path:
951
- shutil.copyfile(
952
- inp.clarification_response_path,
953
- instruction_set / "clarification-response.md",
954
- )
955
- if inp.directive:
956
- (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
957
- render_reference_expectations(
958
- str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
959
- )
960
- # inject populates ctx with compute + default tokens consumed by the lead
961
- # prompt render below (claude-execution-prompt.md). The final-report
962
- # template render is effectively a copy (Jinja2 `{{ var }}` syntax does
963
- # not match `_TOKEN_RE`); routed through render_template_with_ctx for SOT
964
- # consistency.
965
- inject_lead_prompt_computed_tokens(ctx)
966
- apply_lead_prompt_defaults(ctx)
967
- render_template_with_ctx(
968
- str(final_report_template), ctx["FINAL_REPORT_TEMPLATE_PATH"], ctx,
1296
+ # ---- write instruction-set scaffolding + lead prompt ----
1297
+ instruction_set = _write_instruction_set_sources(
1298
+ inp, ctx, profile_content, review_material
969
1299
  )
970
- # Per-task-type schema excerpt for the report-writer worker. The full
971
- # schema validates the data.json post-hoc (load_schema); the worker only
972
- # needs the common structure + this run's task-type block, so we write a
973
- # scoped excerpt into the instruction-set rather than make the worker read
974
- # the whole 44 KB / all-task-types schema (whose repo `schemas/...` path is
975
- # not resolvable from a consumer project's task bundle anyway).
976
- #
977
- # Guarded: a missing/unreadable schema must NOT break bundle preparation.
978
- # If the excerpt cannot be produced (e.g. an older install that predates
979
- # the schemas/ copy step), prep proceeds without it — the report-writer
980
- # still has the phase-stripped template + skill structure guide, and
981
- # validation runs against the full schema regardless.
982
- try:
983
- _excerpt = build_schema_excerpt(load_schema(), inp.task_type)
984
- Path(ctx["FINAL_REPORT_SCHEMA_PATH"]).write_text(
985
- json.dumps(_excerpt, indent=2, ensure_ascii=False) + "\n",
986
- encoding="utf-8",
987
- )
988
- except Exception: # noqa: BLE001 — advisory artifact; never fail prep over it
989
- pass
990
- render_template_with_ctx(
991
- str(prompt_template), str(instruction_set / "claude-execution-prompt.md"), ctx,
1300
+ prompt_text = _render_lead_prompt_and_snapshot(
1301
+ inp, ctx, instruction_set, final_report_template, prompt_template
992
1302
  )
993
- prompt_text = (instruction_set / "claude-execution-prompt.md").read_text(encoding="utf-8")
994
- Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).parent.mkdir(parents=True, exist_ok=True)
995
- Path(ctx["RUN_PROMPT_SNAPSHOT_FILE"]).write_text(prompt_text, encoding="utf-8")
996
1303
 
997
1304
  # ---- run-inputs persistence ----
998
- write_run_inputs(
999
- project_root=project_root,
1000
- run_manifests_dir=Path(ctx["RUN_MANIFESTS_DIR"]),
1001
- task_type_segment=ctx["TASK_TYPE_SEGMENT"],
1002
- seq=ctx["RUN_MANIFESTS_SEQ"],
1003
- inputs={
1004
- "taskBriefPath": brief_relative,
1005
- "taskBriefAbsolutePath": str(inp.brief_path),
1006
- "directive": inp.directive,
1007
- "workers": selected_reviewers,
1008
- "leadModel": lead.display,
1009
- "claudeModel": cw.display,
1010
- "codexModel": co.display,
1011
- "geminiModel": ge.display,
1012
- "reportWriterModel": rw.display,
1013
- "executor": executor_provider,
1014
- "relatedTasks": inp.related_tasks_raw,
1015
- "approvedPlanPath": inp.approved_plan_path,
1016
- "clarificationResponsePath": inp.clarification_response_path,
1017
- "renderOnly": inp.render_only,
1018
- },
1019
- )
1305
+ _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
1020
1306
 
1021
- # ---- final status before manifest writes ----
1022
- if inp.render_only:
1023
- ctx["CURRENT_TASK_STATUS"] = "instruction-set-generated"
1024
- ctx["CURRENT_RUN_STATUS"] = "prepared"
1025
- ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_PATH"]
1026
- ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
1027
- else:
1028
- ctx["CURRENT_TASK_STATUS"] = "claude-session-started"
1029
- ctx["CURRENT_RUN_STATUS"] = "in-progress"
1030
- ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_PATH"]
1031
- ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
1032
- ctx.update(compute_workflow_state(
1033
- task_type=inp.task_type,
1034
- current_run_status=ctx["CURRENT_RUN_STATUS"],
1035
- current_task_status=ctx["CURRENT_TASK_STATUS"],
1036
- render_only=inp.render_only,
1037
- work_category=inp.work_category,
1038
- ))
1039
- render_team_state(ctx["TEAM_STATE_PATH"], ctx)
1040
- render_task_manifest(ctx["TASK_MANIFEST_PATH"], ctx)
1041
- render_task_index(str(task_index_template), ctx["TASK_INDEX_PATH"], ctx)
1042
- render_run_manifest(ctx["RUN_MANIFEST_PATH"], ctx)
1043
- render_timeline(ctx["TIMELINE_PATH"], ctx)
1044
- render_task_catalog_discovery(ctx["OKSTRA_TASK_CATALOG_FILE"], ctx)
1045
- render_latest_task_discovery(ctx["OKSTRA_LATEST_TASK_FILE"], ctx)
1307
+ # ---- final status + manifest/discovery renders ----
1308
+ _finalize_status_and_render_manifests(inp, ctx, task_index_template)
1046
1309
 
1047
1310
  # ---- central index ----
1048
1311
  initial_status = "prepared" if inp.render_only else "running"
@@ -1063,21 +1326,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1063
1326
  )
1064
1327
 
1065
1328
  if not inp.render_only:
1066
- try:
1067
- link = ensure_project_settings_symlink(project_root=Path(inp.project_root))
1068
- except SettingsLinkError as exc:
1069
- print(
1070
- f"okstra-settings: failed to provision project settings symlink — "
1071
- f"worker dispatch may be blocked by Claude Code permissions. ({exc})",
1072
- file=__import__("sys").stderr,
1073
- )
1074
- else:
1075
- if link is None:
1076
- print(
1077
- "okstra-settings: ~/.okstra/templates/settings.local.json missing — "
1078
- "re-run 'npx okstra@latest install' (0.14.0+) to provision the symlink target.",
1079
- file=__import__("sys").stderr,
1080
- )
1329
+ _provision_settings_symlink(inp)
1081
1330
 
1082
1331
  return PrepareOutputs(
1083
1332
  ctx=ctx,
@@ -1129,6 +1378,18 @@ def main(argv: list[str]) -> int:
1129
1378
  "YAML frontmatter and appends an audit line."
1130
1379
  ),
1131
1380
  )
1381
+ p.add_argument(
1382
+ "--implementation-option",
1383
+ default="",
1384
+ dest="implementation_option",
1385
+ help=(
1386
+ "implementation task only. Name of the Option Candidate the user "
1387
+ "chose from the implementation-planning final-report. Written into "
1388
+ "the --approved-plan file's `implementation-option:` frontmatter "
1389
+ "line. When omitted, implementation falls back to the plan's "
1390
+ "`Recommended Option`."
1391
+ ),
1392
+ )
1132
1393
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
1133
1394
  p.add_argument(
1134
1395
  "--pr-template-path",
@@ -1219,6 +1480,7 @@ def main(argv: list[str]) -> int:
1219
1480
  pr_template_path=args.pr_template_path,
1220
1481
  render_only=args.render_only,
1221
1482
  approve_plan_ack=args.approve_plan_ack,
1483
+ implementation_option=args.implementation_option,
1222
1484
  plan_verification_enabled=args.plan_verification_enabled,
1223
1485
  )
1224
1486
  try: