okstra 0.75.0 → 0.77.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 (46) hide show
  1. package/README.kr.md +5 -3
  2. package/README.md +5 -3
  3. package/bin/okstra +10 -2
  4. package/docs/kr/architecture.md +1 -1
  5. package/docs/kr/cli.md +20 -4
  6. package/docs/pr-template-usage.md +3 -3
  7. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +1 -1
  8. package/docs/superpowers/specs/2026-06-12-codex-lead-adapter-design.md +358 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/codex-worker.md +1 -1
  12. package/runtime/agents/workers/gemini-worker.md +1 -1
  13. package/runtime/bin/lib/okstra/cli.sh +5 -1
  14. package/runtime/bin/lib/okstra/globals.sh +1 -0
  15. package/runtime/bin/lib/okstra/usage.sh +6 -4
  16. package/runtime/bin/okstra.sh +1 -0
  17. package/runtime/prompts/launch.template.md +4 -13
  18. package/runtime/prompts/profiles/_coding-conventions-preflight.md +21 -0
  19. package/runtime/prompts/profiles/_implementation-executor.md +3 -6
  20. package/runtime/prompts/profiles/error-analysis.md +6 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +5 -0
  22. package/runtime/prompts/profiles/release-handoff.md +2 -2
  23. package/runtime/prompts/wizard/prompts.ko.json +12 -4
  24. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  25. package/runtime/python/okstra_ctl/codex_dispatch.py +1484 -0
  26. package/runtime/python/okstra_ctl/lead_events.py +129 -0
  27. package/runtime/python/okstra_ctl/paths.py +3 -0
  28. package/runtime/python/okstra_ctl/pr_template.py +1 -1
  29. package/runtime/python/okstra_ctl/render.py +160 -29
  30. package/runtime/python/okstra_ctl/run.py +78 -15
  31. package/runtime/python/okstra_ctl/wizard.py +23 -9
  32. package/runtime/python/okstra_token_usage/codex.py +12 -7
  33. package/runtime/python/okstra_token_usage/collect.py +243 -54
  34. package/runtime/python/okstra_token_usage/gemini.py +12 -7
  35. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  36. package/runtime/validators/validate-run.py +56 -22
  37. package/runtime/validators/validate_session_conformance.py +121 -4
  38. package/src/codex-dispatch.mjs +70 -0
  39. package/src/codex-run.mjs +68 -0
  40. package/src/doctor.mjs +40 -4
  41. package/src/install.mjs +122 -26
  42. package/src/paths.mjs +17 -3
  43. package/src/render-bundle.mjs +2 -0
  44. package/src/runtime-manifest.mjs +26 -0
  45. package/src/uninstall.mjs +26 -4
  46. /package/runtime/{skills/okstra-run/templates → templates/prd}/pr-body.template.md +0 -0
@@ -0,0 +1,129 @@
1
+ """Structured JSONL events emitted by non-Claude lead runtimes."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+
10
+ REQUIRED_FIELDS = (
11
+ "eventType",
12
+ "leadRuntime",
13
+ "taskKey",
14
+ "taskType",
15
+ "runSeq",
16
+ "timestamp",
17
+ "details",
18
+ )
19
+
20
+
21
+ class LeadEventParseError(ValueError):
22
+ """Raised when a lead-events JSONL file contains an invalid row."""
23
+
24
+ def __init__(self, path: Path, line_number: int, message: str) -> None:
25
+ super().__init__(f"{path}:{line_number}: {message}")
26
+ self.path = path
27
+ self.line_number = line_number
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LeadEvent:
32
+ event_type: str
33
+ lead_runtime: str
34
+ task_key: str
35
+ task_type: str
36
+ run_seq: str
37
+ timestamp: str
38
+ details: Mapping[str, Any] = field(default_factory=dict)
39
+
40
+ def to_record(self) -> dict[str, Any]:
41
+ return {
42
+ "eventType": self.event_type,
43
+ "leadRuntime": self.lead_runtime,
44
+ "taskKey": self.task_key,
45
+ "taskType": self.task_type,
46
+ "runSeq": self.run_seq,
47
+ "timestamp": self.timestamp,
48
+ "details": _stable_json_value(dict(self.details)),
49
+ }
50
+
51
+ @classmethod
52
+ def from_record(cls, record: Mapping[str, Any]) -> "LeadEvent":
53
+ missing = [field_name for field_name in REQUIRED_FIELDS
54
+ if field_name not in record]
55
+ if missing:
56
+ raise ValueError(
57
+ "missing required lead event field(s): " + ", ".join(missing)
58
+ )
59
+ details = record["details"]
60
+ if not isinstance(details, Mapping):
61
+ raise ValueError("lead event details must be a JSON object")
62
+ return cls(
63
+ event_type=_require_string(record, "eventType"),
64
+ lead_runtime=_require_string(record, "leadRuntime"),
65
+ task_key=_require_string(record, "taskKey"),
66
+ task_type=_require_string(record, "taskType"),
67
+ run_seq=_require_string(record, "runSeq"),
68
+ timestamp=_require_string(record, "timestamp"),
69
+ details=dict(details),
70
+ )
71
+
72
+
73
+ def append_lead_event(path: Path, event: LeadEvent) -> None:
74
+ """Append one lead event as compact JSONL, creating parent directories."""
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ with path.open("a", encoding="utf-8") as f:
77
+ f.write(_event_json(event) + "\n")
78
+
79
+
80
+ def read_lead_events(path: Path) -> list[LeadEvent]:
81
+ """Parse a lead-events JSONL file.
82
+
83
+ Missing files read as an empty log. Blank lines are ignored. Malformed JSON
84
+ and malformed event objects fail loudly with path and line number context.
85
+ """
86
+ if not path.is_file():
87
+ return []
88
+ events: list[LeadEvent] = []
89
+ for line_number, line in enumerate(
90
+ path.read_text(encoding="utf-8").splitlines(), start=1
91
+ ):
92
+ stripped = line.strip()
93
+ if not stripped:
94
+ continue
95
+ try:
96
+ record = json.loads(stripped)
97
+ except json.JSONDecodeError as exc:
98
+ raise LeadEventParseError(path, line_number, "invalid JSON") from exc
99
+ if not isinstance(record, Mapping):
100
+ raise LeadEventParseError(
101
+ path, line_number, "lead event row must be a JSON object"
102
+ )
103
+ try:
104
+ events.append(LeadEvent.from_record(record))
105
+ except ValueError as exc:
106
+ raise LeadEventParseError(path, line_number, str(exc)) from exc
107
+ return events
108
+
109
+
110
+ def _event_json(event: LeadEvent) -> str:
111
+ return json.dumps(event.to_record(), ensure_ascii=False, separators=(",", ":"))
112
+
113
+
114
+ def _require_string(record: Mapping[str, Any], field_name: str) -> str:
115
+ value = record[field_name]
116
+ if not isinstance(value, str) or not value:
117
+ raise ValueError(f"lead event {field_name} must be a non-empty string")
118
+ return value
119
+
120
+
121
+ def _stable_json_value(value: Any) -> Any:
122
+ if isinstance(value, Mapping):
123
+ return {
124
+ key: _stable_json_value(value[key])
125
+ for key in sorted(value)
126
+ }
127
+ if isinstance(value, list):
128
+ return [_stable_json_value(item) for item in value]
129
+ return value
@@ -175,6 +175,7 @@ def compute_run_paths(
175
175
  final_status = run_status / f"final{suffixes['status']}.status"
176
176
  team_state = run_state / f"team-state{suffixes['state']}.json"
177
177
  active_run_context = run_state / f"active-run-context{suffixes['state']}.json"
178
+ lead_events = run_state / f"lead-events-{task_type_segment}-{seqs['state']}.jsonl"
178
179
  final_report_template = instruction_set / "final-report-template.md"
179
180
  final_report_schema = instruction_set / "final-report-schema.json"
180
181
  reference_expectations = instruction_set / "reference-expectations.md"
@@ -241,6 +242,7 @@ def compute_run_paths(
241
242
  "FINAL_STATUS_PATH": str(final_status),
242
243
  "TEAM_STATE_PATH": str(team_state),
243
244
  "ACTIVE_RUN_CONTEXT_PATH": str(active_run_context),
245
+ "LEAD_EVENTS_PATH": str(lead_events),
244
246
  "FINAL_REPORT_TEMPLATE_PATH": str(final_report_template),
245
247
  "FINAL_REPORT_SCHEMA_PATH": str(final_report_schema),
246
248
  "REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
@@ -301,6 +303,7 @@ def compute_run_paths(
301
303
  ("FINAL_STATUS_RELATIVE_PATH", final_status),
302
304
  ("TEAM_STATE_RELATIVE_PATH", team_state),
303
305
  ("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", active_run_context),
306
+ ("LEAD_EVENTS_RELATIVE_PATH", lead_events),
304
307
  ("WORKER_RESULTS_RELATIVE_PATH", worker_results),
305
308
  ("RUN_CARRY_RELATIVE_PATH", run_carry),
306
309
  ("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
@@ -6,7 +6,7 @@ release-handoff 단계에서 lead 가 PR 본문을 작성할 때 사용하는
6
6
  1. per-run override (okstra-run Step 6 에서 입력)
7
7
  2. project: <project_root>/.okstra/project.json 의 ``prTemplatePath``
8
8
  3. global: ~/.okstra/config.json 의 ``prTemplatePath``
9
- 4. default: 스킬 설치 디렉터리의 ``okstra-run/templates/pr-body.template.md``
9
+ 4. default: 스킬 설치 디렉터리의 ``templates/prd/pr-body.template.md``
10
10
 
11
11
  경로는 절대경로 또는 ``~`` 시작 경로를 권장한다. 상대경로일 경우 project
12
12
  스코프는 ``project_root`` 기준, override 는 호출자 cwd 기준으로 해석한다.
@@ -66,6 +66,37 @@ def _write_json(path: Path, payload: dict) -> None:
66
66
  _write_text(path, json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
67
67
 
68
68
 
69
+ def _lead_runtime(ctx: dict) -> str:
70
+ return ctx.get("LEAD_RUNTIME", "") or "claude-code"
71
+
72
+
73
+ def _lead_agent(ctx: dict) -> str:
74
+ return "codex" if _lead_runtime(ctx) == "codex" else "claude"
75
+
76
+
77
+ def _lead_agent_label(ctx: dict) -> str:
78
+ return "Codex CLI" if _lead_runtime(ctx) == "codex" else "Claude Code"
79
+
80
+
81
+ def _lead_role(ctx: dict) -> str:
82
+ return "Codex lead" if _lead_runtime(ctx) == "codex" else "Claude lead"
83
+
84
+
85
+ def _lead_adapter(ctx: dict) -> dict:
86
+ runtime = _lead_runtime(ctx)
87
+ if runtime == "codex":
88
+ return {
89
+ "name": "codex",
90
+ "dispatchMode": "render-only",
91
+ "sessionAccounting": "artifact-only",
92
+ }
93
+ return {
94
+ "name": "claude-code",
95
+ "dispatchMode": "team",
96
+ "sessionAccounting": "claude-jsonl",
97
+ }
98
+
99
+
69
100
  _PHASE_BLOCK_RE = re.compile(
70
101
  r"\{% if header\.taskType == '(implementation-planning|release-handoff|implementation|final-verification)' %\}\n(.*?)\{% endif %\}\n",
71
102
  re.DOTALL,
@@ -415,11 +446,14 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
415
446
  "schemaVersion": "1.0",
416
447
  "taskKey": ctx.get("TASK_KEY", ""),
417
448
  "taskType": ctx.get("TASK_TYPE", ""),
449
+ "leadRuntime": _lead_runtime(ctx),
450
+ "leadAdapter": _lead_adapter(ctx),
451
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
418
452
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
419
453
  "workflowState": ctx.get("CURRENT_RUN_STATUS", ""),
420
454
  "lead": {
421
- "role": "Claude lead",
422
- "agent": "claude",
455
+ "role": _lead_role(ctx),
456
+ "agent": _lead_agent(ctx),
423
457
  "model": ctx.get("LEAD_MODEL", ""),
424
458
  "modelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
425
459
  "status": ctx.get("CURRENT_RUN_STATUS", ""),
@@ -876,7 +910,9 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
876
910
  catalog = _worker_catalog(ctx)
877
911
  required_worker_roles = _required_worker_roles(ctx, reviewers)
878
912
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
879
- required_agent_status_entries = ["Claude lead"] + [
913
+ lead_agent = _lead_agent(ctx)
914
+ lead_role = _lead_role(ctx)
915
+ required_agent_status_entries = [lead_role] + [
880
916
  catalog[item]["role"] for item in reviewers
881
917
  ]
882
918
  related_tasks = json.loads(ctx.get("RELATED_TASKS_JSON", "[]"))
@@ -925,6 +961,8 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
925
961
  "taskIdPathSegment": ctx.get("TASK_ID_SEGMENT", ""),
926
962
  "projectRoot": ctx.get("PROJECT_ROOT", ""),
927
963
  "taskType": ctx.get("TASK_TYPE", ""),
964
+ "leadRuntime": _lead_runtime(ctx),
965
+ "leadAdapter": _lead_adapter(ctx),
928
966
  "workCategory": work_category,
929
967
  "taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
930
968
  "recommendedWorkers": reviewers,
@@ -984,16 +1022,17 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
984
1022
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
985
1023
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
986
1024
  "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
1025
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
987
1026
  "workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
988
1027
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
989
1028
  },
990
1029
  "resultContract": {
991
- "leadAgent": "claude",
992
- "leadRole": "Claude lead",
1030
+ "leadAgent": lead_agent,
1031
+ "leadRole": lead_role,
993
1032
  "leadModel": ctx.get("LEAD_MODEL", ""),
994
1033
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
995
1034
  "leadExecutionMode": "synthesis-only",
996
- "finalSynthesisOwner": "Claude lead",
1035
+ "finalSynthesisOwner": lead_role,
997
1036
  "artifactFirst": True,
998
1037
  "resultCollectionMode": "claude-managed",
999
1038
  "finalReportFormat": "markdown",
@@ -1127,6 +1166,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1127
1166
  catalog = _worker_catalog(ctx)
1128
1167
  required_worker_roles = _required_worker_roles(ctx, reviewers)
1129
1168
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
1169
+ lead_agent = _lead_agent(ctx)
1170
+ lead_role = _lead_role(ctx)
1130
1171
  related_tasks = json.loads(ctx.get("RELATED_TASKS_JSON", "[]"))
1131
1172
  workflow = (
1132
1173
  task_manifest.get("workflow", {})
@@ -1149,6 +1190,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1149
1190
  "taskId": ctx.get("TASK_ID", ""),
1150
1191
  "taskKey": ctx.get("TASK_KEY", ""),
1151
1192
  "taskType": ctx.get("TASK_TYPE", ""),
1193
+ "leadRuntime": _lead_runtime(ctx),
1194
+ "leadAdapter": _lead_adapter(ctx),
1152
1195
  "workCategory": task_manifest.get(
1153
1196
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1154
1197
  ),
@@ -1174,6 +1217,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1174
1217
  "workerResults": ctx.get("WORKER_RESULTS_SEQ", ""),
1175
1218
  },
1176
1219
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
1220
+ "leadEventsPath": ctx.get("LEAD_EVENTS_RELATIVE_PATH", ""),
1177
1221
  "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
1178
1222
  "workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
1179
1223
  "workerPromptPathByWorkerId": worker_prompt_paths,
@@ -1216,15 +1260,15 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1216
1260
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
1217
1261
  },
1218
1262
  "teamContract": {
1219
- "leadAgent": "claude",
1220
- "leadRole": "Claude lead",
1263
+ "leadAgent": lead_agent,
1264
+ "leadRole": lead_role,
1221
1265
  "leadModel": ctx.get("LEAD_MODEL", ""),
1222
1266
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
1223
1267
  "leadExecutionMode": "synthesis-only",
1224
- "finalSynthesisOwner": "Claude lead",
1268
+ "finalSynthesisOwner": lead_role,
1225
1269
  "requiredWorkerAttempts": reviewers,
1226
1270
  "requiredWorkerRoles": required_worker_roles,
1227
- "requiredAgentStatusEntries": ["Claude lead"]
1271
+ "requiredAgentStatusEntries": [lead_role]
1228
1272
  + [catalog[item]["role"] for item in reviewers],
1229
1273
  "requireDistinctLeadFromClaudeWorker": True,
1230
1274
  "requireAllRequiredWorkerAttempts": True,
@@ -1425,6 +1469,12 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1425
1469
  if isinstance(task_manifest.get("artifacts"), dict)
1426
1470
  else {}
1427
1471
  )
1472
+ lead_role = rc.get("leadRole", _lead_role(ctx))
1473
+ latest_resume_command = (
1474
+ task_manifest.get("latestResumeCommandPath")
1475
+ or ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", "")
1476
+ or "--"
1477
+ )
1428
1478
  mapping = {
1429
1479
  "{{TASK_KEY}}": task_manifest.get("taskKey", ctx.get("TASK_KEY", "")),
1430
1480
  "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("TASK_TYPE", "")),
@@ -1456,13 +1506,10 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1456
1506
  "{{VALIDATION_STATUS}}": cv.get(
1457
1507
  "status", ctx.get("VALIDATION_STATUS", "not-run")
1458
1508
  ),
1459
- "{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}": task_manifest.get(
1460
- "latestResumeCommandPath",
1461
- ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
1462
- ),
1509
+ "{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}": latest_resume_command,
1463
1510
  "{{MODEL_ASSIGNMENT_LINES}}": "\n".join(
1464
1511
  [
1465
- f"- `Claude lead`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1512
+ f"- `{lead_role}`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1466
1513
  f"- `Claude worker`: `{ctx.get('CLAUDE_WORKER_MODEL', '')}`",
1467
1514
  f"- `Codex worker`: `{ctx.get('CODEX_WORKER_MODEL', '')}`",
1468
1515
  f"- `Gemini worker`: `{ctx.get('GEMINI_WORKER_MODEL', '')}`",
@@ -1597,22 +1644,22 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
1597
1644
 
1598
1645
 
1599
1646
  def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1600
- """Populate ctx in-place with the 9 derived lead-prompt tokens.
1647
+ """Populate ctx in-place with derived lead-prompt tokens.
1601
1648
 
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.
1649
+ Tokens that are not 1:1 with a ctx key (TEAM_CREATION_GATE, lead identity
1650
+ blocks, worker dispatch guidance, worker result/status summaries, etc.) are
1651
+ computed deterministically from ctx so the pure-lookup renderer
1652
+ (`render_template_with_ctx`) can resolve them via plain `ctx[token]` lookup.
1609
1653
 
1610
- Always overwrites — caller-supplied values for these 9 keys are replaced
1654
+ Always overwrites — caller-supplied values for these computed keys are replaced
1611
1655
  on every call. For optional defaults (VALIDATION_STATUS etc.) use the
1612
1656
  companion `apply_lead_prompt_defaults` which preserves caller values.
1613
1657
  """
1614
1658
  selected = _resolve_workers(ctx)
1615
1659
  catalog = _worker_catalog(ctx)
1660
+ lead_runtime = _lead_runtime(ctx)
1661
+ lead_role = _lead_role(ctx)
1662
+ lead_agent_label = _lead_agent_label(ctx)
1616
1663
  lead_model = ctx.get("LEAD_MODEL", "")
1617
1664
  lead_model_execution = ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
1618
1665
 
@@ -1622,16 +1669,16 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1622
1669
  return f"- `{role}`: `{model}`"
1623
1670
 
1624
1671
  worker_result_lines: list[str] = []
1625
- team_role_lines = [f" 1. `Claude lead` (assigned model: `{lead_model}`)"]
1672
+ team_role_lines = [f" 1. `{lead_role}` (assigned model: `{lead_model}`)"]
1626
1673
  model_assignment_lines = [
1627
- fmt_assignment("Claude lead", lead_model, lead_model_execution)
1674
+ fmt_assignment(lead_role, lead_model, lead_model_execution)
1628
1675
  ]
1629
1676
  worker_role_labels: list[str] = []
1630
- execution_status_entries = ["`Claude lead`"]
1677
+ execution_status_entries = [f"`{lead_role}`"]
1631
1678
  execution_status_table_lines = [
1632
1679
  "| 에이전트 | 역할 | 모델 | 상태 | 핵심 발견 요약 |",
1633
1680
  "|----------|------|------|------|----------------|",
1634
- f"| Claude Code | Claude lead | {lead_model} | completed / timeout / error / not-run | 최종 synthesis 작성 상태와 핵심 판단 |",
1681
+ f"| {lead_agent_label} | {lead_role} | {lead_model} | completed / timeout / error / not-run | 최종 synthesis 작성 상태와 핵심 판단 |",
1635
1682
  ]
1636
1683
  for index, worker in enumerate(selected, start=2):
1637
1684
  m = catalog[worker]
@@ -1682,18 +1729,50 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1682
1729
  # - All other phases keep the full team-creation contract.
1683
1730
  task_type = ctx.get("TASK_TYPE", "")
1684
1731
  concurrent_stages = str(ctx.get("CONCURRENT_RUN_STAGES", "") or "").strip()
1732
+ codex_dispatch_command = (
1733
+ "okstra codex-dispatch "
1734
+ f"--project-root {ctx.get('PROJECT_ROOT', '')} "
1735
+ f"--run-manifest {ctx.get('RUN_MANIFEST_RELATIVE_PATH', '')}"
1736
+ )
1685
1737
  if task_type == "release-handoff" or not selected:
1686
1738
  team_creation_gate_block = (
1687
1739
  "## Single-Lead Phase (no team creation)\n"
1688
1740
  "\n"
1689
1741
  "This run is single-lead. There is no worker roster, no\n"
1690
1742
  "`TeamCreate` call, no `Agent(...)` worker dispatch, and no\n"
1691
- "convergence loop. The Claude lead performs every step inline\n"
1743
+ f"convergence loop. The {lead_role} performs every step inline\n"
1692
1744
  "(reading inputs, drafting commit / PR text when applicable,\n"
1693
1745
  "asking the user, running git / gh, and writing the final\n"
1694
1746
  "report). Do NOT call `TeamCreate` or dispatch any sub-agent\n"
1695
1747
  "from this run — that would be a contract violation."
1696
1748
  )
1749
+ elif lead_runtime == "codex":
1750
+ team_creation_gate_block = (
1751
+ "## Codex Dispatch Gate (BLOCKING)\n"
1752
+ "\n"
1753
+ "This run was prepared for `leadRuntime=codex`. Do NOT call\n"
1754
+ "`TeamCreate`, do NOT call Claude Code `Agent(...)`, and do NOT\n"
1755
+ "invoke Claude slash skills. Codex dispatch is artifact-first and\n"
1756
+ "uses the prepared run manifest plus CLI-backed worker wrappers.\n"
1757
+ "\n"
1758
+ "Required actions, in order:\n"
1759
+ "\n"
1760
+ "1. Inspect the run manifest and team-state paths listed below.\n"
1761
+ "2. Emit `PROGRESS: phase-3-team-create skipped (codex-dispatch)`;\n"
1762
+ " there is no Claude Teams surface in this adapter.\n"
1763
+ "3. Plan worker execution with:\n"
1764
+ f" `{codex_dispatch_command} --dry-run`\n"
1765
+ "4. Dispatch supported Codex-side workers with:\n"
1766
+ f" `{codex_dispatch_command}`\n"
1767
+ " When `--workers` is omitted, unsupported roster entries such as\n"
1768
+ " `claude` are skipped with explicit `not-run` reasons.\n"
1769
+ "5. Run `report-writer` only with explicit opt-in:\n"
1770
+ f" `{codex_dispatch_command} --enable-codex-report-writer --report-writer-codex-model <model>`\n"
1771
+ "\n"
1772
+ "`okstra codex-dispatch` records `team-state.dispatchMode`, writes\n"
1773
+ "worker prompt histories when missing, persists worker results, and\n"
1774
+ "runs post-report validation for the Codex report-writer path."
1775
+ )
1697
1776
  elif task_type == "implementation" and concurrent_stages:
1698
1777
  team_creation_gate_block = (
1699
1778
  "## Concurrent-run: no-team background (BLOCKING)\n"
@@ -1773,8 +1852,60 @@ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1773
1852
  "response is to go back to step 2 — NOT to strip `team_name` and retry."
1774
1853
  )
1775
1854
 
1855
+ if lead_runtime == "codex":
1856
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1857
+ lead_bootstrap_instruction = (
1858
+ "Read the manifests below for all task metadata, paths, model "
1859
+ "assignments, and worker roster. Use the Codex dispatch gate in this "
1860
+ "prompt; do not invoke Claude Code skills."
1861
+ )
1862
+ lead_session_block = (
1863
+ "## Session\n"
1864
+ "\n"
1865
+ "- Lead runtime: `codex`\n"
1866
+ "- Codex run resume is artifact/checkpoint based; `okstra codex-run` "
1867
+ "does not create a Claude session id."
1868
+ )
1869
+ worker_dispatch_guidance = (
1870
+ "## Codex Worker Dispatch\n"
1871
+ "\n"
1872
+ "- Worker prompt paths in `task-manifest.json` are assigned history "
1873
+ "locations. `okstra codex-dispatch` materializes missing prompt files "
1874
+ "from the run manifest and active-run-context; existing prompt files "
1875
+ "are never overwritten.\n"
1876
+ "- Do not call `TeamCreate` or `Agent(...)`. Use the command in the "
1877
+ "Codex Dispatch Gate above for worker execution.\n"
1878
+ "- File presence is not a signal to skip. Worker selection and skipped "
1879
+ "statuses come from `team-state`, never from the prompts directory."
1880
+ )
1881
+ else:
1882
+ lead_intro_line = f"You are `{lead_role}` for project `{ctx.get('PROJECT_ID', '')}`."
1883
+ lead_bootstrap_instruction = (
1884
+ "Invoke the `okstra` skill now. Read the manifests below for all task "
1885
+ "metadata, paths, model assignments, and worker roster."
1886
+ )
1887
+ lead_session_block = (
1888
+ "## Session\n"
1889
+ "\n"
1890
+ f"- Session ID: `{ctx.get('CLAUDE_SESSION_ID', '')}`\n"
1891
+ f"- Resume: `{ctx.get('CLAUDE_RESUME_COMMAND_RELATIVE_PATH', '')}`"
1892
+ )
1893
+ worker_dispatch_guidance = (
1894
+ "## Worker Prompt Files (not pre-rendered)\n"
1895
+ "\n"
1896
+ "- 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"
1897
+ "- 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"
1898
+ "- 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"
1899
+ "- 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"
1900
+ 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."
1901
+ )
1902
+
1776
1903
  # Compute results (deterministic from ctx, 덮어쓰기)
1904
+ ctx["LEAD_INTRO_LINE"] = lead_intro_line
1905
+ ctx["LEAD_BOOTSTRAP_INSTRUCTION"] = lead_bootstrap_instruction
1906
+ ctx["LEAD_SESSION_BLOCK"] = lead_session_block
1777
1907
  ctx["TEAM_CREATION_GATE"] = team_creation_gate_block
1908
+ ctx["WORKER_DISPATCH_GUIDANCE"] = worker_dispatch_guidance
1778
1909
  ctx["WORKER_RESULT_PATH_LINES"] = "\n".join(worker_result_lines)
1779
1910
  ctx["MODEL_ASSIGNMENT_LINES"] = "\n".join(model_assignment_lines)
1780
1911
  ctx["TEAM_ROLE_LINES"] = "\n".join(team_role_lines)