okstra 0.76.0 → 0.78.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 (65) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +1 -117
  4. package/docs/contributor-change-matrix.md +12 -0
  5. package/docs/kr/architecture.md +1 -1
  6. package/docs/kr/cli.md +22 -5
  7. package/docs/pr-template-usage.md +3 -3
  8. package/docs/project-structure-overview.md +1 -1
  9. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  10. package/docs/superpowers/plans/2026-06-13-repo-risk-hardening.md +493 -0
  11. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  12. package/docs/superpowers/specs/2026-06-13-forbidden-actions-ssot-design.md +134 -0
  13. package/docs/superpowers/specs/2026-06-13-neutral-tmux-lead-adapter-design.md +284 -0
  14. package/package.json +5 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/DO_NOT_EDIT.md +21 -0
  17. package/runtime/agents/SKILL.md +3 -0
  18. package/runtime/bin/lib/okstra/cli.sh +5 -1
  19. package/runtime/bin/lib/okstra/globals.sh +1 -0
  20. package/runtime/bin/lib/okstra/usage.sh +6 -4
  21. package/runtime/bin/okstra-trace-cleanup.sh +16 -12
  22. package/runtime/bin/okstra.sh +1 -0
  23. package/runtime/prompts/launch.template.md +4 -13
  24. package/runtime/prompts/profiles/error-analysis.md +6 -0
  25. package/runtime/prompts/profiles/forbidden-actions.json +69 -0
  26. package/runtime/prompts/profiles/implementation.md +1 -8
  27. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  28. package/runtime/prompts/profiles/release-handoff.md +3 -17
  29. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +1552 -0
  31. package/runtime/python/okstra_ctl/context_cost.py +1 -1
  32. package/runtime/python/okstra_ctl/dispatch_core.py +897 -0
  33. package/runtime/python/okstra_ctl/doctor.py +292 -0
  34. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  35. package/runtime/python/okstra_ctl/lead_runtime.py +72 -0
  36. package/runtime/python/okstra_ctl/paths.py +3 -0
  37. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  38. package/runtime/python/okstra_ctl/render.py +211 -29
  39. package/runtime/python/okstra_ctl/run.py +89 -18
  40. package/runtime/python/okstra_ctl/team.py +267 -0
  41. package/runtime/python/okstra_ctl/tmux.py +181 -10
  42. package/runtime/python/okstra_ctl/workflow.py +30 -71
  43. package/runtime/python/okstra_ctl/wrapper_status.py +55 -0
  44. package/runtime/python/okstra_token_usage/codex.py +12 -7
  45. package/runtime/python/okstra_token_usage/collect.py +243 -54
  46. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  47. package/runtime/schemas/final-report-v1.0.schema.json +3 -1
  48. package/runtime/skills/okstra-convergence/SKILL.md +3 -0
  49. package/runtime/skills/okstra-report-writer/SKILL.md +3 -0
  50. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +12 -0
  52. package/runtime/validators/forbidden_actions.py +135 -0
  53. package/runtime/validators/validate-run.py +112 -22
  54. package/runtime/validators/validate_session_conformance.py +127 -5
  55. package/src/cli-registry.mjs +277 -0
  56. package/src/codex-dispatch.mjs +70 -0
  57. package/src/codex-run.mjs +68 -0
  58. package/src/doctor.mjs +130 -5
  59. package/src/install.mjs +123 -26
  60. package/src/paths.mjs +17 -3
  61. package/src/render-bundle.mjs +2 -0
  62. package/src/runtime-manifest.mjs +29 -0
  63. package/src/team.mjs +63 -0
  64. package/src/uninstall.mjs +27 -4
  65. /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
@@ -28,6 +28,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
28
28
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
29
29
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
30
30
  from . import fix_cycles
31
+ from .lead_runtime import lead_runtime_info
31
32
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
32
33
 
33
34
 
@@ -66,6 +67,30 @@ def _write_json(path: Path, payload: dict) -> None:
66
67
  _write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
67
68
 
68
69
 
70
+ def _lead_runtime(ctx: dict) -> str:
71
+ return ctx.get("LEAD_RUNTIME", "") or "claude-code"
72
+
73
+
74
+ def _lead_info(ctx: dict):
75
+ return lead_runtime_info(_lead_runtime(ctx))
76
+
77
+
78
+ def _lead_agent(ctx: dict) -> str:
79
+ return _lead_info(ctx).agent
80
+
81
+
82
+ def _lead_agent_label(ctx: dict) -> str:
83
+ return _lead_info(ctx).agent_label
84
+
85
+
86
+ def _lead_role(ctx: dict) -> str:
87
+ return _lead_info(ctx).role
88
+
89
+
90
+ def _lead_adapter(ctx: dict) -> dict:
91
+ return _lead_info(ctx).adapter_payload()
92
+
93
+
69
94
  _PHASE_BLOCK_RE = re.compile(
70
95
  r"\{% if header\.taskType == '(implementation-planning|release-handoff|implementation|final-verification)' %\}\n(.*?)\{% endif %\}\n",
71
96
  re.DOTALL,
@@ -415,11 +440,14 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
415
440
  "schemaVersion": "1.0",
416
441
  "taskKey": ctx.get("TASK_KEY", ""),
417
442
  "taskType": ctx.get("TASK_TYPE", ""),
443
+ "leadRuntime": _lead_runtime(ctx),
444
+ "leadAdapter": _lead_adapter(ctx),
445
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
418
446
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
419
447
  "workflowState": ctx.get("CURRENT_RUN_STATUS", ""),
420
448
  "lead": {
421
- "role": "Claude lead",
422
- "agent": "claude",
449
+ "role": _lead_role(ctx),
450
+ "agent": _lead_agent(ctx),
423
451
  "model": ctx.get("LEAD_MODEL", ""),
424
452
  "modelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
425
453
  "status": ctx.get("CURRENT_RUN_STATUS", ""),
@@ -876,7 +904,9 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
876
904
  catalog = _worker_catalog(ctx)
877
905
  required_worker_roles = _required_worker_roles(ctx, reviewers)
878
906
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
879
- required_agent_status_entries = ["Claude lead"] + [
907
+ lead_agent = _lead_agent(ctx)
908
+ lead_role = _lead_role(ctx)
909
+ required_agent_status_entries = [lead_role] + [
880
910
  catalog[item]["role"] for item in reviewers
881
911
  ]
882
912
  related_tasks = json.loads(ctx.get("RELATED_TASKS_JSON", "[]"))
@@ -925,6 +955,8 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
925
955
  "taskIdPathSegment": ctx.get("TASK_ID_SEGMENT", ""),
926
956
  "projectRoot": ctx.get("PROJECT_ROOT", ""),
927
957
  "taskType": ctx.get("TASK_TYPE", ""),
958
+ "leadRuntime": _lead_runtime(ctx),
959
+ "leadAdapter": _lead_adapter(ctx),
928
960
  "workCategory": work_category,
929
961
  "taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
930
962
  "recommendedWorkers": reviewers,
@@ -984,16 +1016,17 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
984
1016
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
985
1017
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
986
1018
  "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
1019
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
987
1020
  "workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
988
1021
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
989
1022
  },
990
1023
  "resultContract": {
991
- "leadAgent": "claude",
992
- "leadRole": "Claude lead",
1024
+ "leadAgent": lead_agent,
1025
+ "leadRole": lead_role,
993
1026
  "leadModel": ctx.get("LEAD_MODEL", ""),
994
1027
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
995
1028
  "leadExecutionMode": "synthesis-only",
996
- "finalSynthesisOwner": "Claude lead",
1029
+ "finalSynthesisOwner": lead_role,
997
1030
  "artifactFirst": True,
998
1031
  "resultCollectionMode": "claude-managed",
999
1032
  "finalReportFormat": "markdown",
@@ -1127,6 +1160,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1127
1160
  catalog = _worker_catalog(ctx)
1128
1161
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1129
1162
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
1163
+ lead_agent = _lead_agent(ctx)
1164
+ lead_role = _lead_role(ctx)
1130
1165
  related_tasks = json.loads(ctx.get("RELATED_TASKS_JSON", "[]"))
1131
1166
  workflow = (
1132
1167
  task_manifest.get("workflow", {})
@@ -1149,6 +1184,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1149
1184
  "taskId": ctx.get("TASK_ID", ""),
1150
1185
  "taskKey": ctx.get("TASK_KEY", ""),
1151
1186
  "taskType": ctx.get("TASK_TYPE", ""),
1187
+ "leadRuntime": _lead_runtime(ctx),
1188
+ "leadAdapter": _lead_adapter(ctx),
1152
1189
  "workCategory": task_manifest.get(
1153
1190
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1154
1191
  ),
@@ -1174,6 +1211,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1174
1211
  "workerResults": ctx.get("WORKER_RESULTS_SEQ", ""),
1175
1212
  },
1176
1213
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
1214
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
1177
1215
  "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
1178
1216
  "workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
1179
1217
  "workerPromptPathByWorkerId": worker_prompt_paths,
@@ -1216,15 +1254,15 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1216
1254
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
1217
1255
  },
1218
1256
  "teamContract": {
1219
- "leadAgent": "claude",
1220
- "leadRole": "Claude lead",
1257
+ "leadAgent": lead_agent,
1258
+ "leadRole": lead_role,
1221
1259
  "leadModel": ctx.get("LEAD_MODEL", ""),
1222
1260
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
1223
1261
  "leadExecutionMode": "synthesis-only",
1224
- "finalSynthesisOwner": "Claude lead",
1262
+ "finalSynthesisOwner": lead_role,
1225
1263
  "requiredWorkerAttempts": reviewers,
1226
1264
  "requiredWorkerRoles": required_worker_roles,
1227
- "requiredAgentStatusEntries": ["Claude lead"]
1265
+ "requiredAgentStatusEntries": [lead_role]
1228
1266
  + [catalog[item]["role"] for item in reviewers],
1229
1267
  "requireDistinctLeadFromClaudeWorker": True,
1230
1268
  "requireAllRequiredWorkerAttempts": True,
@@ -1425,6 +1463,12 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1425
1463
  if isinstance(task_manifest.get("artifacts"), dict)
1426
1464
  else {}
1427
1465
  )
1466
+ lead_role = rc.get("leadRole", _lead_role(ctx))
1467
+ latest_resume_command = (
1468
+ task_manifest.get("latestResumeCommandPath")
1469
+ or ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", "")
1470
+ or "--"
1471
+ )
1428
1472
  mapping = {
1429
1473
  "{{TASK_KEY}}": task_manifest.get("taskKey", ctx.get("TASK_KEY", "")),
1430
1474
  "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("TASK_TYPE", "")),
@@ -1456,13 +1500,10 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1456
1500
  "{{VALIDATION_STATUS}}": cv.get(
1457
1501
  "status", ctx.get("VALIDATION_STATUS", "not-run")
1458
1502
  ),
1459
- "{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}": task_manifest.get(
1460
- "latestResumeCommandPath",
1461
- ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
1462
- ),
1503
+ "{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}": latest_resume_command,
1463
1504
  "{{MODEL_ASSIGNMENT_LINES}}": "\n".join(
1464
1505
  [
1465
- f"- `Claude lead`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1506
+ f"- `{lead_role}`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1466
1507
  f"- `Claude worker`: `{ctx.get('CLAUDE_WORKER_MODEL', '')}`",
1467
1508
  f"- `Codex worker`: `{ctx.get('CODEX_WORKER_MODEL', '')}`",
1468
1509
  f"- `Gemini worker`: `{ctx.get('GEMINI_WORKER_MODEL', '')}`",
@@ -1597,22 +1638,22 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
1597
1638
 
1598
1639
 
1599
1640
  def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1600
- """Populate ctx in-place with the 9 derived lead-prompt tokens.
1641
+ """Populate ctx in-place with derived lead-prompt tokens.
1601
1642
 
1602
- Tokens that are not 1:1 with a ctx key (TEAM_CREATION_GATE,
1603
- WORKER_RESULT_PATH_LINES, TEAM_ROLE_LINES, MODEL_ASSIGNMENT_LINES,
1604
- REQUIRED_WORKER_ROLE_SENTENCE, GEMINI_ATTEMPT_SENTENCE,
1605
- PREFERRED_WORKER_RESULTS_SENTENCE, EXECUTION_STATUS_EXACT_ENTRIES,
1606
- EXECUTION_STATUS_TABLE_ROWS) are computed deterministically from ctx
1607
- so the pure-lookup renderer (`render_template_with_ctx`) can resolve
1608
- them via plain `ctx[token]` lookup.
1643
+ Tokens that are not 1:1 with a ctx key (TEAM_CREATION_GATE, lead identity
1644
+ blocks, worker dispatch guidance, worker result/status summaries, etc.) are
1645
+ computed deterministically from ctx so the pure-lookup renderer
1646
+ (`render_template_with_ctx`) can resolve them via plain `ctx[token]` lookup.
1609
1647
 
1610
- Always overwrites — caller-supplied values for these 9 keys are replaced
1648
+ Always overwrites — caller-supplied values for these computed keys are replaced
1611
1649
  on every call. For optional defaults (VALIDATION_STATUS etc.) use the
1612
1650
  companion `apply_lead_prompt_defaults` which preserves caller values.
1613
1651
  """
1614
1652
  selected = _resolve_workers(ctx)
1615
1653
  catalog = _worker_catalog(ctx)
1654
+ lead_runtime = _lead_runtime(ctx)
1655
+ lead_role = _lead_role(ctx)
1656
+ lead_agent_label = _lead_agent_label(ctx)
1616
1657
  lead_model = ctx.get("LEAD_MODEL", "")
1617
1658
  lead_model_execution = ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
1618
1659
 
@@ -1622,16 +1663,16 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1622
1663
  return f"- `{role}`: `{model}`"
1623
1664
 
1624
1665
  worker_result_lines: list[str] = []
1625
- team_role_lines = [f" 1. `Claude lead` (assigned model: `{lead_model}`)"]
1666
+ team_role_lines = [f" 1. `{lead_role}` (assigned model: `{lead_model}`)"]
1626
1667
  model_assignment_lines = [
1627
- fmt_assignment("Claude lead", lead_model, lead_model_execution)
1668
+ fmt_assignment(lead_role, lead_model, lead_model_execution)
1628
1669
  ]
1629
1670
  worker_role_labels: list[str] = []
1630
- execution_status_entries = ["`Claude lead`"]
1671
+ execution_status_entries = [f"`{lead_role}`"]
1631
1672
  execution_status_table_lines = [
1632
1673
  "| 에이전트 | 역할 | 모델 | 상태 | 핵심 발견 요약 |",
1633
1674
  "|----------|------|------|------|----------------|",
1634
- f"| Claude Code | Claude lead | {lead_model} | completed / timeout / error / not-run | 최종 synthesis 작성 상태와 핵심 판단 |",
1675
+ f"| {lead_agent_label} | {lead_role} | {lead_model} | completed / timeout / error / not-run | 최종 synthesis 작성 상태와 핵심 판단 |",
1635
1676
  ]
1636
1677
  for index, worker in enumerate(selected, start=2):
1637
1678
  m = catalog[worker]
@@ -1682,18 +1723,81 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1682
1723
  # - All other phases keep the full team-creation contract.
1683
1724
  task_type = ctx.get("TASK_TYPE", "")
1684
1725
  concurrent_stages = str(ctx.get("CONCURRENT_RUN_STAGES", "") or "").strip()
1726
+ codex_dispatch_command = (
1727
+ "okstra codex-dispatch "
1728
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1729
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1730
+ )
1731
+ team_dispatch_command = (
1732
+ "okstra team dispatch "
1733
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1734
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1735
+ )
1736
+ team_await_command = (
1737
+ "okstra team await "
1738
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1739
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1740
+ )
1685
1741
  if task_type == "release-handoff" or not selected:
1686
1742
  team_creation_gate_block = (
1687
1743
  "## Single-Lead Phase (no team creation)\n"
1688
1744
  "\n"
1689
1745
  "This run is single-lead. There is no worker roster, no\n"
1690
1746
  "`TeamCreate` call, no `Agent(...)` worker dispatch, and no\n"
1691
- "convergence loop. The Claude lead performs every step inline\n"
1747
+ f"convergence loop. The {lead_role} performs every step inline\n"
1692
1748
  "(reading inputs, drafting commit / PR text when applicable,\n"
1693
1749
  "asking the user, running git / gh, and writing the final\n"
1694
1750
  "report). Do NOT call `TeamCreate` or dispatch any sub-agent\n"
1695
1751
  "from this run — that would be a contract violation."
1696
1752
  )
1753
+ elif lead_runtime == "codex":
1754
+ team_creation_gate_block = (
1755
+ "## Codex Dispatch Gate (BLOCKING)\n"
1756
+ "\n"
1757
+ "This run was prepared for `leadRuntime=codex`. Do NOT call\n"
1758
+ "`TeamCreate`, do NOT call Claude Code `Agent(...)`, and do NOT\n"
1759
+ "invoke Claude slash skills. Codex dispatch is artifact-first and\n"
1760
+ "uses the prepared run manifest plus CLI-backed worker wrappers.\n"
1761
+ "\n"
1762
+ "Required actions, in order:\n"
1763
+ "\n"
1764
+ "1. Inspect the run manifest and team-state paths listed below.\n"
1765
+ "2. Emit `PROGRESS: phase-3-team-create skipped (codex-dispatch)`;\n"
1766
+ " there is no Claude Teams surface in this adapter.\n"
1767
+ "3. Plan worker execution with:\n"
1768
+ f" `{codex_dispatch_command} --dry-run`\n"
1769
+ "4. Dispatch supported Codex-side workers with:\n"
1770
+ f" `{codex_dispatch_command}`\n"
1771
+ " When `--workers` is omitted, unsupported roster entries such as\n"
1772
+ " `claude` are skipped with explicit `not-run` reasons.\n"
1773
+ "5. Run `report-writer` only with explicit opt-in:\n"
1774
+ f" `{codex_dispatch_command} --enable-codex-report-writer --report-writer-codex-model <model>`\n"
1775
+ "\n"
1776
+ "`okstra codex-dispatch` records `team-state.dispatchMode`, writes\n"
1777
+ "worker prompt histories when missing, persists worker results, and\n"
1778
+ "runs post-report validation for the Codex report-writer path."
1779
+ )
1780
+ elif lead_runtime == "external":
1781
+ team_creation_gate_block = (
1782
+ "## Tmux Worker Dispatch Gate (BLOCKING)\n"
1783
+ "\n"
1784
+ "This run was prepared for `leadRuntime=external`. Do NOT call "
1785
+ "`TeamCreate`, do NOT call Claude Code `Agent(...)`, and do NOT call "
1786
+ "`okstra codex-dispatch`. This adapter dispatches workers through "
1787
+ "okstra-owned tmux panes.\n"
1788
+ "\n"
1789
+ "Required actions, in order:\n"
1790
+ "1. Inspect the run manifest and team-state paths listed below.\n"
1791
+ "2. Emit `PROGRESS: phase-3-team-create skipped (tmux-pane)`; there is no Claude Teams surface in this adapter.\n"
1792
+ "3. Plan worker execution with:\n"
1793
+ f" `{team_dispatch_command} --dry-run`\n"
1794
+ "4. Dispatch workers with:\n"
1795
+ f" `{team_dispatch_command}`\n"
1796
+ "5. Arm one background wait using this harness's async shell primitive:\n"
1797
+ f" `{team_await_command}`\n"
1798
+ "6. On wake, read team-state. Worker completion is valid only from "
1799
+ "`workerDispatches[]` plus result files; pane creation alone is not completion."
1800
+ )
1697
1801
  elif task_type == "implementation" and concurrent_stages:
1698
1802
  team_creation_gate_block = (
1699
1803
  "## Concurrent-run: no-team background (BLOCKING)\n"
@@ -1773,8 +1877,86 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1773
1877
  "response is to go back to step 2 — NOT to strip `team_name` and retry."
1774
1878
  )
1775
1879
 
1880
+ if lead_runtime == "external":
1881
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1882
+ lead_bootstrap_instruction = (
1883
+ "Read the manifests below for all task metadata, paths, model "
1884
+ "assignments, and worker roster. Use the tmux worker dispatch gate "
1885
+ "in this prompt; do not invoke Claude Code skills."
1886
+ )
1887
+ lead_session_block = (
1888
+ "## Session\n"
1889
+ "\n"
1890
+ "- Lead runtime: `external`\n"
1891
+ "- Lead session accounting: artifact-only; this harness does not expose Claude Code jsonl session IDs."
1892
+ )
1893
+ worker_dispatch_guidance = (
1894
+ "## Tmux Worker Dispatch\n"
1895
+ "\n"
1896
+ "- Worker prompt paths in `task-manifest.json` are assigned history "
1897
+ "locations. `okstra team dispatch` materializes missing prompt files "
1898
+ "from the run manifest and active-run-context; existing prompt files "
1899
+ "are never overwritten.\n"
1900
+ "- Do not call `TeamCreate`, `Agent(...)`, or `okstra codex-dispatch`. "
1901
+ "Use `okstra team dispatch` and `okstra team await` from the gate "
1902
+ "above for worker execution.\n"
1903
+ "- File presence is not a signal to skip. Worker selection and skipped "
1904
+ "statuses come from `team-state`, never from the prompts directory."
1905
+ )
1906
+ elif lead_runtime == "codex":
1907
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1908
+ lead_bootstrap_instruction = (
1909
+ "Read the manifests below for all task metadata, paths, model "
1910
+ "assignments, and worker roster. Use the Codex dispatch gate in this "
1911
+ "prompt; do not invoke Claude Code skills."
1912
+ )
1913
+ lead_session_block = (
1914
+ "## Session\n"
1915
+ "\n"
1916
+ "- Lead runtime: `codex`\n"
1917
+ "- Codex run resume is artifact/checkpoint based; `okstra codex-run` "
1918
+ "does not create a Claude session id."
1919
+ )
1920
+ worker_dispatch_guidance = (
1921
+ "## Codex Worker Dispatch\n"
1922
+ "\n"
1923
+ "- Worker prompt paths in `task-manifest.json` are assigned history "
1924
+ "locations. `okstra codex-dispatch` materializes missing prompt files "
1925
+ "from the run manifest and active-run-context; existing prompt files "
1926
+ "are never overwritten.\n"
1927
+ "- Do not call `TeamCreate` or `Agent(...)`. Use the command in the "
1928
+ "Codex Dispatch Gate above for worker execution.\n"
1929
+ "- File presence is not a signal to skip. Worker selection and skipped "
1930
+ "statuses come from `team-state`, never from the prompts directory."
1931
+ )
1932
+ else:
1933
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1934
+ lead_bootstrap_instruction = (
1935
+ "Invoke the `okstra` skill now. Read the manifests below for all task "
1936
+ "metadata, paths, model assignments, and worker roster."
1937
+ )
1938
+ lead_session_block = (
1939
+ "## Session\n"
1940
+ "\n"
1941
+ f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
1942
+ f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
1943
+ )
1944
+ worker_dispatch_guidance = (
1945
+ "## Worker Prompt Files (not pre-rendered)\n"
1946
+ "\n"
1947
+ "- The `runs/<phase>/prompts/<worker>-worker-prompt-*.md` files referenced in `task-manifest.json → artifacts.workerPromptPathByWorkerId` are **NOT** rendered by the okstra runtime. Those paths are the *assigned* locations where the prompt history MUST be written at dispatch time.\n"
1948
+ "- Therefore: at the start of every phase the prompts/ directory is normally empty (or contains only previously-dispatched workers' files). This is expected. Do NOT narrate it as \"missing\", \"누락\", or \"not yet rendered\" — it just means dispatch has not happened yet.\n"
1949
+ "- Before dispatching any required worker, **you (the lead) construct the worker prompt and persist it to the assigned absolute path using `Write`** (per `okstra-team-contract` rule 6). Only after persisting do you call the worker subagent (Agent tool / Codex / Gemini wrapper). The wrapper subagents will also re-write the same file on their end; the double-write is intentional and idempotent.\n"
1950
+ "- Do not \"check if the file exists and skip dispatch\" — file presence is not a signal to skip. Worker selection and skipping rules come from team-state, never from prompts/ directory contents.\n"
1951
+ f"- **Worker Preamble Path (single anchor — replaces inlined `[Required reading]` and `[Error reporting]` blocks).** Every worker prompt you construct MUST include the anchor header `**Worker Preamble Path:** {ctx.get('WORKER_PROMPT_PREAMBLE_PATH', str(Path.home() / '.okstra' / 'templates' / 'worker-prompt-preamble.md'))}`. The file at that path is the canonical SSOT for Required Reading + Error Reporting + Output sections; workers Read it end-to-end before producing output. Do NOT re-inline those blocks into worker prompt bodies — that is the legacy ~80-line dispatch boilerplate that this anchor is designed to replace."
1952
+ )
1953
+
1776
1954
  # Compute results (deterministic from ctx, 덮어쓰기)
1955
+ ctx["LEAD_INTRO_LINE"] = lead_intro_line
1956
+ ctx["LEAD_BOOTSTRAP_INSTRUCTION"] = lead_bootstrap_instruction
1957
+ ctx["LEAD_SESSION_BLOCK"] = lead_session_block
1777
1958
  ctx["TEAM_CREATION_GATE"] = team_creation_gate_block
1959
+ ctx["WORKER_DISPATCH_GUIDANCE"] = worker_dispatch_guidance
1778
1960
  ctx["WORKER_RESULT_PATH_LINES"] = "\n".join(worker_result_lines)
1779
1961
  ctx["MODEL_ASSIGNMENT_LINES"] = "\n".join(model_assignment_lines)
1780
1962
  ctx["TEAM_ROLE_LINES"] = "\n".join(team_role_lines)
@@ -39,6 +39,8 @@ from .material import (
39
39
  resolve_related_tasks,
40
40
  )
41
41
  from .final_report_schema import load_schema
42
+ from .lead_events import LeadEvent, append_lead_event
43
+ from .lead_runtime import ALLOWED_LEAD_RUNTIMES
42
44
  from .models import ModelAssignment, resolve_model_metadata
43
45
  from .schema_excerpt import build_schema_excerpt
44
46
  from .path_resolve import relative_to_project_root, resolve_user_file
@@ -80,7 +82,7 @@ from .workers import (
80
82
  resolve_profile_workers,
81
83
  validate_workers_against_profile,
82
84
  )
83
- from .workflow import compute_workflow_state
85
+ from .workflow import compute_workflow_state, load_phase_forbidden
84
86
  from .locks import worktree_provision_mutex
85
87
  from .worktree import (
86
88
  WorktreeProvision,
@@ -266,6 +268,17 @@ class PrepareError(Exception):
266
268
  """surface to caller — task bundle prepare failed."""
267
269
 
268
270
 
271
+ _ALLOWED_LEAD_RUNTIMES = ALLOWED_LEAD_RUNTIMES
272
+
273
+
274
+ def _normalize_lead_runtime(value: str) -> str:
275
+ runtime = (value or "claude-code").strip()
276
+ if runtime not in _ALLOWED_LEAD_RUNTIMES:
277
+ allowed = ", ".join(_ALLOWED_LEAD_RUNTIMES)
278
+ raise PrepareError(f"unsupported lead runtime: {runtime} (allowed: {allowed})")
279
+ return runtime
280
+
281
+
269
282
  @dataclass
270
283
  class PrepareInputs:
271
284
  workspace_root: Path
@@ -282,6 +295,7 @@ class PrepareInputs:
282
295
  codex_model: str = ""
283
296
  gemini_model: str = ""
284
297
  report_writer_model: str = ""
298
+ lead_runtime: str = "claude-code"
285
299
  executor: str = ""
286
300
  critic: str = ""
287
301
  related_tasks_raw: str = ""
@@ -932,6 +946,29 @@ def _brief_sha256(path: Path) -> str:
932
946
  return ""
933
947
 
934
948
 
949
+ def _record_artifact_runtime_render_only_event(inp: PrepareInputs, ctx: dict) -> None:
950
+ if inp.lead_runtime not in ("codex", "external") or not inp.render_only:
951
+ return
952
+ append_lead_event(
953
+ Path(ctx["LEAD_EVENTS_PATH"]),
954
+ LeadEvent(
955
+ event_type="bundle-prepared",
956
+ lead_runtime=inp.lead_runtime,
957
+ task_key=ctx["TASK_KEY"],
958
+ task_type=ctx["TASK_TYPE"],
959
+ run_seq=ctx["RUN_MANIFESTS_SEQ"],
960
+ timestamp=ctx["RUN_TIMESTAMP_ISO"],
961
+ details={
962
+ "dispatchMode": "render-only",
963
+ "workerDispatch": "not-started",
964
+ "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
965
+ "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
966
+ "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
967
+ },
968
+ ),
969
+ )
970
+
971
+
935
972
  def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
936
973
  """rerun 충실 재현을 위한 canonical argv 재구성."""
937
974
  workers = inp.workers_override or ctx.get("RECOMMENDED_ANALYSERS", "")
@@ -951,6 +988,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
951
988
  ("--codex-model", inp.codex_model or ctx.get("CODEX_WORKER_MODEL", "")),
952
989
  ("--gemini-model", inp.gemini_model or ctx.get("GEMINI_WORKER_MODEL", "")),
953
990
  ("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL", "")),
991
+ ("--lead-runtime", inp.lead_runtime if inp.lead_runtime != "claude-code" else ""),
954
992
  ("--executor", inp.executor or ctx.get("EXECUTOR_PROVIDER", "")),
955
993
  ("--critic", inp.critic or ctx.get("CRITIC_CHOICE", "")),
956
994
  ("--related-tasks", inp.related_tasks_raw),
@@ -1770,6 +1808,7 @@ def _write_instruction_set_sources(
1770
1808
  "EXECUTOR_WORKTREE_BASE_REF",
1771
1809
  "EXECUTOR_WORKTREE_STATUS",
1772
1810
  "EXECUTOR_WORKTREE_NOTE",
1811
+ "PHASE_FORBIDDEN_ACTIONS",
1773
1812
  ):
1774
1813
  profile_rendered = profile_rendered.replace("{{" + key + "}}", ctx.get(key, ""))
1775
1814
  (instruction_set / "analysis-profile.md").write_text(profile_rendered, encoding="utf-8")
@@ -1960,7 +1999,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
1960
1999
 
1961
2000
 
1962
2001
  def _finalize_status_and_render_manifests(
1963
- inp: PrepareInputs, ctx: dict, task_index_template: Path
2002
+ inp: PrepareInputs, ctx: dict, task_index_template: Path,
2003
+ forbidden_by_phase: dict[str, str],
1964
2004
  ) -> None:
1965
2005
  """최종 task/run status 를 확정하고 workflow state 를 재계산한 뒤 team-state,
1966
2006
  task-manifest, task-index, run-manifest, timeline, discovery 산출물을 렌더한다."""
@@ -1977,6 +2017,7 @@ def _finalize_status_and_render_manifests(
1977
2017
  current_run_status=ctx["CURRENT_RUN_STATUS"],
1978
2018
  current_task_status=ctx["CURRENT_TASK_STATUS"],
1979
2019
  render_only=inp.render_only,
2020
+ forbidden_by_phase=forbidden_by_phase,
1980
2021
  work_category=inp.work_category,
1981
2022
  ))
1982
2023
  render_team_state(ctx["TEAM_STATE_PATH"], ctx)
@@ -2013,6 +2054,12 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2013
2054
  """Produce a complete okstra task bundle on disk. See module docstring."""
2014
2055
  workspace_root = Path(inp.workspace_root)
2015
2056
  project_root = Path(inp.project_root)
2057
+ lead_runtime = _normalize_lead_runtime(inp.lead_runtime)
2058
+ if lead_runtime != "claude-code" and not inp.render_only:
2059
+ raise PrepareError(
2060
+ f"lead runtime `{lead_runtime}` is currently render-only; "
2061
+ "use --render-only until a dispatch backend is enabled for this lead runtime."
2062
+ )
2016
2063
 
2017
2064
  # ---- input validation + asset resolution + roster/model 해소 ----
2018
2065
  # 각 단계는 명시적 입력/반환을 갖는 헬퍼로 분리되어 있다 (상단 phase 헬퍼
@@ -2186,17 +2233,20 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2186
2233
  # ---- initial workflow state (current_run_status not yet known) ----
2187
2234
  initial_task_status = "ready-for-claude"
2188
2235
  initial_run_status = "not-run"
2236
+ forbidden_by_phase = load_phase_forbidden(workspace_root)
2189
2237
  workflow_state = compute_workflow_state(
2190
2238
  task_type=inp.task_type,
2191
2239
  current_run_status=initial_run_status,
2192
2240
  current_task_status=initial_task_status,
2193
2241
  render_only=inp.render_only,
2242
+ forbidden_by_phase=forbidden_by_phase,
2194
2243
  )
2195
2244
 
2196
2245
  # ---- assemble full ctx (the values render functions expect) ----
2197
2246
  ctx.update({
2198
2247
  "ANALYSIS_PROFILE": inp.task_type,
2199
2248
  "RECOMMENDED_ANALYSERS": selected_reviewers,
2249
+ "LEAD_RUNTIME": lead_runtime,
2200
2250
  "PR_TEMPLATE_PATH": pr_template_path_str,
2201
2251
  "PR_TEMPLATE_SOURCE": pr_template_source,
2202
2252
  **handoff_tokens,
@@ -2235,6 +2285,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2235
2285
  "OKSTRA_VERSION": installed_version(),
2236
2286
  **workflow_state,
2237
2287
  })
2288
+ if lead_runtime == "codex":
2289
+ ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
2290
+ ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
2291
+ ctx["CLAUDE_RESUME_COMMAND_FILENAME"] = ""
2238
2292
  if inp.task_type == "final-verification":
2239
2293
  _reserve_final_verification_target(inp, ctx, ctx_stage_map)
2240
2294
 
@@ -2244,21 +2298,23 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2244
2298
  cleanup_obsolete_generated_docs(
2245
2299
  project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
2246
2300
  )
2247
- # Always materialise the resume command script. Even in --render-only
2248
- # preparation flows the user (or a later non-interactive runner) may
2249
- # invoke it manually; deferring its creation until interactive launch
2250
- # leaves runs/<phase>/sessions/ empty and the manifest pointing at a
2251
- # path that does not exist.
2252
- write_claude_resume_command_file(
2253
- resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
2254
- project_root=project_root,
2255
- claude_session_id=claude_session_id,
2256
- task_key=ctx["TASK_KEY"],
2257
- task_type=ctx["TASK_TYPE"],
2258
- phase_state=ctx["CURRENT_RUN_STATUS"],
2259
- worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
2260
- prompt_seq=ctx["RUN_PROMPTS_SEQ"],
2261
- )
2301
+ if lead_runtime == "claude-code":
2302
+ # Always materialise the resume command script for Claude Code. Even in
2303
+ # --render-only preparation flows the user (or a later non-interactive
2304
+ # runner) may invoke it manually; deferring its creation until
2305
+ # interactive launch leaves runs/<phase>/sessions/ empty and the
2306
+ # manifest pointing at a path that does not exist. Codex runs resume via
2307
+ # artifacts/checkpoints instead, so they intentionally leave this empty.
2308
+ write_claude_resume_command_file(
2309
+ resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_PATH"]),
2310
+ project_root=project_root,
2311
+ claude_session_id=claude_session_id,
2312
+ task_key=ctx["TASK_KEY"],
2313
+ task_type=ctx["TASK_TYPE"],
2314
+ phase_state=ctx["CURRENT_RUN_STATUS"],
2315
+ worker_prompts_dir_relative=ctx["RUN_PROMPTS_RELATIVE_PATH"],
2316
+ prompt_seq=ctx["RUN_PROMPTS_SEQ"],
2317
+ )
2262
2318
 
2263
2319
  # ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
2264
2320
  # instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
@@ -2279,7 +2335,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2279
2335
  _persist_run_inputs(inp, ctx, models, selected_reviewers, brief_relative)
2280
2336
 
2281
2337
  # ---- final status + manifest/discovery renders ----
2282
- _finalize_status_and_render_manifests(inp, ctx, task_index_template)
2338
+ _finalize_status_and_render_manifests(
2339
+ inp, ctx, task_index_template, forbidden_by_phase
2340
+ )
2341
+ _record_artifact_runtime_render_only_event(inp, ctx)
2283
2342
 
2284
2343
  # ---- central index ----
2285
2344
  initial_status = "prepared" if inp.render_only else "running"
@@ -2354,6 +2413,17 @@ def main(argv: list[str]) -> int:
2354
2413
  p.add_argument("--codex-model", default="")
2355
2414
  p.add_argument("--gemini-model", default="")
2356
2415
  p.add_argument("--report-writer-model", default="")
2416
+ p.add_argument(
2417
+ "--lead-runtime",
2418
+ default="claude-code",
2419
+ choices=list(_ALLOWED_LEAD_RUNTIMES),
2420
+ dest="lead_runtime",
2421
+ help=(
2422
+ "Lead runtime adapter. Default: claude-code. `codex` renders "
2423
+ "Codex CLI artifact bundles; `external` renders neutral bootstrap "
2424
+ "bundles for manifest inspection until a dispatch backend is enabled."
2425
+ ),
2426
+ )
2357
2427
  p.add_argument("--executor", default="")
2358
2428
  p.add_argument("--critic", default="")
2359
2429
  p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
@@ -2490,6 +2560,7 @@ def main(argv: list[str]) -> int:
2490
2560
  codex_model=args.codex_model,
2491
2561
  gemini_model=args.gemini_model,
2492
2562
  report_writer_model=args.report_writer_model,
2563
+ lead_runtime=args.lead_runtime,
2493
2564
  executor=args.executor,
2494
2565
  critic=args.critic,
2495
2566
  related_tasks_raw=args.related_tasks_raw,