okstra 0.34.0 → 0.36.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 (101) hide show
  1. package/README.kr.md +26 -16
  2. package/README.md +26 -16
  3. package/docs/kr/architecture.md +59 -45
  4. package/docs/kr/cli.md +61 -18
  5. package/docs/pr-template-usage.md +65 -0
  6. package/docs/project-structure-overview.md +358 -354
  7. package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +1 -1
  8. package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1 -1
  9. package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +1 -1
  10. package/docs/superpowers/plans/2026-05-20-final-report-language.md +1501 -0
  11. package/docs/superpowers/plans/2026-05-20-implementation-planning-multi-stage.md +1267 -0
  12. package/docs/superpowers/plans/2026-05-20-okstra-run-prompt-sot-b1.md +1007 -0
  13. package/docs/superpowers/plans/2026-05-20-wizard-messages-json-sot.md +720 -0
  14. package/docs/superpowers/plans/2026-05-20-wizard-prompt-json-sot-a1.md +681 -0
  15. package/docs/superpowers/plans/2026-05-21-improvement-discovery-task-type.md +1691 -0
  16. package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +383 -0
  17. package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +320 -0
  18. package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +299 -0
  19. package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +335 -0
  20. package/docs/task-process/README.md +74 -0
  21. package/docs/task-process/common-flow.md +166 -0
  22. package/docs/task-process/error-analysis.md +101 -0
  23. package/docs/task-process/final-verification.md +167 -0
  24. package/docs/task-process/implementation-planning.md +128 -0
  25. package/docs/task-process/implementation.md +149 -0
  26. package/docs/task-process/release-handoff.md +206 -0
  27. package/docs/task-process/requirements-discovery.md +115 -0
  28. package/package.json +1 -1
  29. package/runtime/BUILD.json +2 -2
  30. package/runtime/agents/SKILL.md +29 -13
  31. package/runtime/agents/workers/claude-worker.md +26 -0
  32. package/runtime/agents/workers/codex-worker.md +27 -1
  33. package/runtime/agents/workers/gemini-worker.md +27 -1
  34. package/runtime/agents/workers/report-writer-worker.md +8 -1
  35. package/runtime/bin/okstra-central.sh +6 -6
  36. package/runtime/bin/okstra-codex-exec.sh +49 -28
  37. package/runtime/bin/okstra-gemini-exec.sh +39 -21
  38. package/runtime/bin/okstra-render-final-report.py +13 -2
  39. package/runtime/bin/okstra-wrapper-status.py +155 -0
  40. package/runtime/bin/okstra.sh +2 -2
  41. package/runtime/prompts/profiles/_common-contract.md +11 -6
  42. package/runtime/prompts/profiles/error-analysis.md +3 -7
  43. package/runtime/prompts/profiles/implementation-planning.md +22 -21
  44. package/runtime/prompts/profiles/implementation.md +28 -11
  45. package/runtime/prompts/profiles/improvement-discovery.md +42 -0
  46. package/runtime/prompts/profiles/kr/_common-contract.md +92 -0
  47. package/runtime/prompts/profiles/kr/error-analysis.md +36 -0
  48. package/runtime/prompts/profiles/kr/final-verification.md +48 -0
  49. package/runtime/prompts/profiles/kr/implementation-planning.md +90 -0
  50. package/runtime/prompts/profiles/kr/implementation.md +144 -0
  51. package/runtime/prompts/profiles/kr/improvement-discovery.md +42 -0
  52. package/runtime/prompts/profiles/kr/release-handoff.md +104 -0
  53. package/runtime/prompts/profiles/kr/requirements-discovery.md +42 -0
  54. package/runtime/prompts/profiles/release-handoff.md +1 -1
  55. package/runtime/prompts/profiles/requirements-discovery.md +8 -12
  56. package/runtime/prompts/wizard/prompts.ko.json +230 -0
  57. package/runtime/python/lib/okstra/cli.sh +2 -49
  58. package/runtime/python/lib/okstra/globals.sh +21 -21
  59. package/runtime/python/lib/okstra/interactive.sh +7 -7
  60. package/runtime/python/okstra_ctl/clarification_items.py +3 -9
  61. package/runtime/python/okstra_ctl/consumers.py +53 -0
  62. package/runtime/python/okstra_ctl/final_report_schema.py +0 -7
  63. package/runtime/python/okstra_ctl/i18n.py +73 -0
  64. package/runtime/python/okstra_ctl/improvement_lenses.py +44 -0
  65. package/runtime/python/okstra_ctl/index.py +1 -1
  66. package/runtime/python/okstra_ctl/paths.py +23 -20
  67. package/runtime/python/okstra_ctl/render.py +147 -202
  68. package/runtime/python/okstra_ctl/render_final_report.py +53 -10
  69. package/runtime/python/okstra_ctl/run.py +292 -107
  70. package/runtime/python/okstra_ctl/run_context.py +22 -0
  71. package/runtime/python/okstra_ctl/seeding.py +186 -0
  72. package/runtime/python/okstra_ctl/wizard.py +348 -127
  73. package/runtime/python/okstra_ctl/workflow.py +21 -2
  74. package/runtime/python/okstra_ctl/worktree.py +54 -1
  75. package/runtime/python/okstra_project/resolver.py +4 -3
  76. package/runtime/python/okstra_token_usage/report.py +2 -2
  77. package/runtime/schemas/final-report-v1.0.schema.json +22 -16
  78. package/runtime/skills/okstra-brief/SKILL.md +124 -31
  79. package/runtime/skills/okstra-convergence/SKILL.md +2 -3
  80. package/runtime/skills/okstra-report-writer/SKILL.md +35 -15
  81. package/runtime/skills/okstra-run/SKILL.md +5 -4
  82. package/runtime/skills/okstra-schedule/SKILL.md +4 -4
  83. package/runtime/skills/okstra-setup/SKILL.md +27 -0
  84. package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
  85. package/runtime/templates/okstra.CLAUDE.md +104 -0
  86. package/runtime/templates/reports/final-report.template.md +93 -98
  87. package/runtime/templates/reports/i18n/en.json +135 -0
  88. package/runtime/templates/reports/i18n/ko.json +135 -0
  89. package/runtime/templates/reports/implementation-planning-input.template.md +18 -0
  90. package/runtime/templates/reports/improvement-discovery-input.template.md +78 -0
  91. package/runtime/templates/reports/task-brief.template.md +2 -2
  92. package/runtime/validators/lib/fixtures.sh +30 -0
  93. package/runtime/validators/lib/runners.sh +1 -1
  94. package/runtime/validators/validate-implementation-plan-stages.py +211 -0
  95. package/runtime/validators/validate-run.py +121 -26
  96. package/runtime/validators/validate-workflow.sh +2 -2
  97. package/runtime/validators/validate_improvement_report.py +275 -0
  98. package/src/config.mjs +18 -0
  99. package/src/install.mjs +41 -14
  100. package/src/setup.mjs +133 -1
  101. package/src/uninstall.mjs +21 -1
@@ -22,6 +22,16 @@ import re
22
22
  import sys
23
23
  from pathlib import Path
24
24
 
25
+
26
+ class TokenRenderError(Exception):
27
+ """Raised when a template references a `{{TOKEN}}` not present in ctx.
28
+
29
+ Specific to the pure-token renderer in this module. Distinct from
30
+ `okstra_ctl.render_final_report.FinalReportRenderError` which wraps
31
+ jinja2 / IO failures during final-report rendering.
32
+ """
33
+
34
+
25
35
  # --------------------------------------------------------------------------- #
26
36
  # helpers
27
37
  # --------------------------------------------------------------------------- #
@@ -152,9 +162,7 @@ def _frontmatter_mapping(ctx: dict) -> dict:
152
162
  task_key = (ctx.get("TASK_KEY") or "").strip()
153
163
  task_date = (ctx.get("TASK_DATE") or "").strip()
154
164
  doc_type = (ctx.get("DOC_TYPE") or "").strip()
155
- # task_type ctx 키가 두 곳에 분포 — 직접 키(`TASK_TYPE`), 또는
156
- # `ANALYSIS_TYPE` fallback (workflow 의 render mapping 과 동일 우선순위).
157
- task_type = (ctx.get("TASK_TYPE") or ctx.get("ANALYSIS_TYPE") or "").strip()
165
+ task_type = (ctx.get("TASK_TYPE") or "").strip()
158
166
 
159
167
  fm_id = _frontmatter_id_from_task_key(task_key)
160
168
  fm_id_scalar = f'"{fm_id}"' if fm_id else f'"{_FM_DEFAULT}"'
@@ -179,7 +187,7 @@ def _frontmatter_mapping(ctx: dict) -> dict:
179
187
 
180
188
  def _resolve_workers(ctx: dict) -> list[str]:
181
189
  return [
182
- w.strip() for w in ctx.get("SELECTED_REVIEWERS", "").split(",") if w.strip()
190
+ w.strip() for w in ctx.get("RECOMMENDED_ANALYSERS", "").split(",") if w.strip()
183
191
  ]
184
192
 
185
193
 
@@ -190,7 +198,7 @@ def _worker_catalog(ctx: dict) -> dict:
190
198
  "role": "Claude worker",
191
199
  "agent": "claude",
192
200
  "agentLabel": "Claude Code",
193
- "model": ctx.get("CLAUDE_WORKER_MODEL_DISPLAY", ""),
201
+ "model": ctx.get("CLAUDE_WORKER_MODEL", ""),
194
202
  "modelExecutionValue": ctx.get("CLAUDE_WORKER_MODEL_EXECUTION_VALUE", ""),
195
203
  "resultPath": ctx.get("CLAUDE_WORKER_RESULT_RELATIVE_PATH", ""),
196
204
  "promptPath": ctx.get("CLAUDE_WORKER_PROMPT_RELATIVE_PATH", ""),
@@ -200,7 +208,7 @@ def _worker_catalog(ctx: dict) -> dict:
200
208
  "role": "Codex worker",
201
209
  "agent": "codex",
202
210
  "agentLabel": "Codex",
203
- "model": ctx.get("CODEX_WORKER_MODEL_DISPLAY", ""),
211
+ "model": ctx.get("CODEX_WORKER_MODEL", ""),
204
212
  "modelExecutionValue": ctx.get("CODEX_WORKER_MODEL_EXECUTION_VALUE", ""),
205
213
  "resultPath": ctx.get("CODEX_WORKER_RESULT_RELATIVE_PATH", ""),
206
214
  "promptPath": ctx.get("CODEX_WORKER_PROMPT_RELATIVE_PATH", ""),
@@ -210,7 +218,7 @@ def _worker_catalog(ctx: dict) -> dict:
210
218
  "role": "Gemini worker",
211
219
  "agent": "gemini",
212
220
  "agentLabel": "Gemini",
213
- "model": ctx.get("GEMINI_WORKER_MODEL_DISPLAY", ""),
221
+ "model": ctx.get("GEMINI_WORKER_MODEL", ""),
214
222
  "modelExecutionValue": ctx.get("GEMINI_WORKER_MODEL_EXECUTION_VALUE", ""),
215
223
  "resultPath": ctx.get("GEMINI_WORKER_RESULT_RELATIVE_PATH", ""),
216
224
  "promptPath": ctx.get("GEMINI_WORKER_PROMPT_RELATIVE_PATH", ""),
@@ -220,7 +228,7 @@ def _worker_catalog(ctx: dict) -> dict:
220
228
  "role": "Report writer worker",
221
229
  "agent": "claude",
222
230
  "agentLabel": "Claude Code",
223
- "model": ctx.get("REPORT_WRITER_MODEL_DISPLAY", ""),
231
+ "model": ctx.get("REPORT_WRITER_MODEL", ""),
224
232
  "modelExecutionValue": ctx.get("REPORT_WRITER_MODEL_EXECUTION_VALUE", ""),
225
233
  "resultPath": ctx.get("REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH", ""),
226
234
  "promptPath": ctx.get("REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH", ""),
@@ -255,13 +263,13 @@ def render_team_state(team_state_path: str, ctx: dict) -> None:
255
263
  payload = {
256
264
  "schemaVersion": "1.0",
257
265
  "taskKey": ctx.get("TASK_KEY", ""),
258
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
266
+ "taskType": ctx.get("TASK_TYPE", ""),
259
267
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
260
268
  "workflowState": ctx.get("CURRENT_RUN_STATUS", ""),
261
269
  "lead": {
262
270
  "role": "Claude lead",
263
271
  "agent": "claude",
264
- "model": ctx.get("LEAD_MODEL_DISPLAY", ""),
272
+ "model": ctx.get("LEAD_MODEL", ""),
265
273
  "modelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
266
274
  "status": ctx.get("CURRENT_RUN_STATUS", ""),
267
275
  "sessionId": ctx.get("CLAUDE_SESSION_ID", ""),
@@ -311,7 +319,7 @@ def render_reference_expectations(brief_path: str, output_path: str, ctx: dict)
311
319
  "# Task Reference Expectations",
312
320
  "",
313
321
  f"- Task Key: `{ctx.get('TASK_KEY', '')}`",
314
- f"- Task Type: `{ctx.get('ANALYSIS_TYPE', '')}`",
322
+ f"- Task Type: `{ctx.get('TASK_TYPE', '')}`",
315
323
  f"- Source brief snapshot: `{brief_relative}`",
316
324
  "",
317
325
  "## Usage Rules",
@@ -457,7 +465,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
457
465
 
458
466
 
459
467
  def render_latest_task_discovery(output_path: str, ctx: dict) -> None:
460
- task_manifest_path = Path(ctx.get("TASK_MANIFEST_FILE", ""))
468
+ task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
461
469
  task_manifest = {}
462
470
  if task_manifest_path.exists():
463
471
  try:
@@ -473,7 +481,7 @@ def render_latest_task_discovery(output_path: str, ctx: dict) -> None:
473
481
  "schemaVersion": "1.0",
474
482
  "updatedAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
475
483
  "taskKey": ctx.get("TASK_KEY", ""),
476
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
484
+ "taskType": ctx.get("TASK_TYPE", ""),
477
485
  "workCategory": task_manifest.get(
478
486
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
479
487
  ),
@@ -633,7 +641,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
633
641
  if isinstance(workflow.get("phaseStates"), dict)
634
642
  else {}
635
643
  )
636
- current_phase = ctx.get("WORKFLOW_CURRENT_PHASE", ctx.get("ANALYSIS_TYPE", ""))
644
+ current_phase = ctx.get("WORKFLOW_CURRENT_PHASE", ctx.get("TASK_TYPE", ""))
637
645
  current_phase_state = ctx.get("WORKFLOW_CURRENT_PHASE_STATE", "not-started")
638
646
  for phase in phase_sequence:
639
647
  phase_states.setdefault(phase, "not-started")
@@ -746,7 +754,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
746
754
  "taskGroupPathSegment": ctx.get("TASK_GROUP_SEGMENT", ""),
747
755
  "taskIdPathSegment": ctx.get("TASK_ID_SEGMENT", ""),
748
756
  "projectRoot": ctx.get("PROJECT_ROOT", ""),
749
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
757
+ "taskType": ctx.get("TASK_TYPE", ""),
750
758
  "workCategory": work_category,
751
759
  "taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
752
760
  "recommendedWorkers": reviewers,
@@ -807,7 +815,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
807
815
  "resultContract": {
808
816
  "leadAgent": "claude",
809
817
  "leadRole": "Claude lead",
810
- "leadModel": ctx.get("LEAD_MODEL_DISPLAY", ""),
818
+ "leadModel": ctx.get("LEAD_MODEL", ""),
811
819
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
812
820
  "leadExecutionMode": "synthesis-only",
813
821
  "finalSynthesisOwner": "Claude lead",
@@ -890,7 +898,7 @@ def _build_convergence_block(ctx: dict) -> dict:
890
898
  - `OKSTRA_PLAN_VERIFICATION`: "true" | "false" | "" (empty → default True).
891
899
  Wired from CLI `--no-plan-verification` (sets "false").
892
900
  """
893
- task_type = ctx.get("ANALYSIS_TYPE", "")
901
+ task_type = ctx.get("TASK_TYPE", "")
894
902
  default_max_rounds = 1 if task_type == "requirements-discovery" else 2
895
903
  raw_plan_verify = (ctx.get("OKSTRA_PLAN_VERIFICATION", "") or "").strip().lower()
896
904
  plan_verify_enabled = raw_plan_verify != "false"
@@ -907,7 +915,7 @@ def _build_convergence_block(ctx: dict) -> dict:
907
915
 
908
916
 
909
917
  def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
910
- task_manifest_path = Path(ctx.get("TASK_MANIFEST_FILE", ""))
918
+ task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
911
919
  task_manifest = {}
912
920
  if task_manifest_path.exists():
913
921
  try:
@@ -931,7 +939,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
931
939
  "taskGroup": ctx.get("TASK_GROUP", ""),
932
940
  "taskId": ctx.get("TASK_ID", ""),
933
941
  "taskKey": ctx.get("TASK_KEY", ""),
934
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
942
+ "taskType": ctx.get("TASK_TYPE", ""),
935
943
  "workCategory": task_manifest.get(
936
944
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
937
945
  ),
@@ -995,7 +1003,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
995
1003
  "teamContract": {
996
1004
  "leadAgent": "claude",
997
1005
  "leadRole": "Claude lead",
998
- "leadModel": ctx.get("LEAD_MODEL_DISPLAY", ""),
1006
+ "leadModel": ctx.get("LEAD_MODEL", ""),
999
1007
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
1000
1008
  "leadExecutionMode": "synthesis-only",
1001
1009
  "finalSynthesisOwner": "Claude lead",
@@ -1034,7 +1042,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1034
1042
 
1035
1043
 
1036
1044
  def render_timeline(timeline_path: str, ctx: dict) -> None:
1037
- task_manifest_path = Path(ctx.get("TASK_MANIFEST_FILE", ""))
1045
+ task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
1038
1046
  task_manifest = {}
1039
1047
  if task_manifest_path.exists():
1040
1048
  try:
@@ -1056,7 +1064,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1056
1064
  except Exception:
1057
1065
  existing = {}
1058
1066
  runs = existing.get("runs", [])
1059
- current_run_manifest_path = ctx.get("RUN_MANIFEST_FILE", "")
1067
+ current_run_manifest_path = ctx.get("RUN_MANIFEST_PATH", "")
1060
1068
  current_run_manifest_relative_path = ctx.get("RUN_MANIFEST_RELATIVE_PATH", "")
1061
1069
  filtered = [
1062
1070
  item
@@ -1085,7 +1093,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1085
1093
  "sessions": ctx.get("RUN_SESSIONS_SEQ", ""),
1086
1094
  "workerResults": ctx.get("WORKER_RESULTS_SEQ", ""),
1087
1095
  },
1088
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
1096
+ "taskType": ctx.get("TASK_TYPE", ""),
1089
1097
  "workCategory": task_manifest.get(
1090
1098
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1091
1099
  ),
@@ -1127,7 +1135,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1127
1135
 
1128
1136
  def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1129
1137
  template = Path(template_path).read_text(encoding="utf-8")
1130
- task_manifest_path = Path(ctx["TASK_MANIFEST_FILE"])
1138
+ task_manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
1131
1139
  task_manifest = {}
1132
1140
  if task_manifest_path.exists():
1133
1141
  try:
@@ -1187,7 +1195,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1187
1195
  )
1188
1196
  mapping = {
1189
1197
  "{{TASK_KEY}}": task_manifest.get("taskKey", ctx.get("TASK_KEY", "")),
1190
- "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("ANALYSIS_TYPE", "")),
1198
+ "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("TASK_TYPE", "")),
1191
1199
  "{{TASK_DATE}}": ctx.get("TASK_DATE", ""),
1192
1200
  "{{PROJECT_ID}}": ctx.get("PROJECT_ID", ""),
1193
1201
  "{{TASK_GROUP}}": ctx.get("TASK_GROUP", ""),
@@ -1202,7 +1210,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1202
1210
  "{{RECOMMENDED_ANALYSERS}}": ", ".join(
1203
1211
  task_manifest.get("recommendedWorkers", [])
1204
1212
  ),
1205
- "{{LEAD_MODEL}}": rc.get("leadModel", ctx.get("LEAD_MODEL_DISPLAY", "")),
1213
+ "{{LEAD_MODEL}}": rc.get("leadModel", ctx.get("LEAD_MODEL", "")),
1206
1214
  "{{OKSTRA_VERSION}}": ctx.get("OKSTRA_VERSION", ""),
1207
1215
  "{{LATEST_RUN_RELATIVE_PATH}}": task_manifest.get(
1208
1216
  "latestRunPath", ctx.get("LATEST_RUN_RELATIVE_PATH", "")
@@ -1222,11 +1230,11 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1222
1230
  ),
1223
1231
  "{{MODEL_ASSIGNMENT_LINES}}": "\n".join(
1224
1232
  [
1225
- f"- `Claude lead`: `{rc.get('leadModel', ctx.get('LEAD_MODEL_DISPLAY', ''))}`",
1226
- f"- `Claude worker`: `{ctx.get('CLAUDE_WORKER_MODEL_DISPLAY', '')}`",
1227
- f"- `Codex worker`: `{ctx.get('CODEX_WORKER_MODEL_DISPLAY', '')}`",
1228
- f"- `Gemini worker`: `{ctx.get('GEMINI_WORKER_MODEL_DISPLAY', '')}`",
1229
- f"- `Report writer worker`: `{ctx.get('REPORT_WRITER_MODEL_DISPLAY', '')}`",
1233
+ f"- `Claude lead`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1234
+ f"- `Claude worker`: `{ctx.get('CLAUDE_WORKER_MODEL', '')}`",
1235
+ f"- `Codex worker`: `{ctx.get('CODEX_WORKER_MODEL', '')}`",
1236
+ f"- `Gemini worker`: `{ctx.get('GEMINI_WORKER_MODEL', '')}`",
1237
+ f"- `Report writer worker`: `{ctx.get('REPORT_WRITER_MODEL', '')}`",
1230
1238
  ]
1231
1239
  ),
1232
1240
  "{{TASK_MANIFEST_RELATIVE_PATH}}": task_manifest.get(
@@ -1291,7 +1299,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1291
1299
  rendered = template
1292
1300
  for k, v in mapping.items():
1293
1301
  rendered = rendered.replace(k, v)
1294
- rendered = _strip_phase_blocks(rendered, ctx.get("ANALYSIS_TYPE", ""))
1302
+ rendered = _strip_phase_blocks(rendered, ctx.get("TASK_TYPE", ""))
1295
1303
  _write_text(Path(output_path), rendered.rstrip() + "\n")
1296
1304
 
1297
1305
 
@@ -1355,11 +1363,24 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
1355
1363
  # --------------------------------------------------------------------------- #
1356
1364
 
1357
1365
 
1358
- def render_template_file(template_path: str, output_path: str, ctx: dict) -> None:
1359
- template = Path(template_path).read_text(encoding="utf-8")
1366
+ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1367
+ """Populate ctx in-place with the 9 derived lead-prompt tokens.
1368
+
1369
+ Tokens that are not 1:1 with a ctx key (TEAM_CREATION_GATE,
1370
+ WORKER_RESULT_PATH_LINES, TEAM_ROLE_LINES, MODEL_ASSIGNMENT_LINES,
1371
+ REQUIRED_WORKER_ROLE_SENTENCE, GEMINI_ATTEMPT_SENTENCE,
1372
+ PREFERRED_WORKER_RESULTS_SENTENCE, EXECUTION_STATUS_EXACT_ENTRIES,
1373
+ EXECUTION_STATUS_TABLE_ROWS) are computed deterministically from ctx
1374
+ so the pure-lookup renderer (`render_template_with_ctx`) can resolve
1375
+ them via plain `ctx[token]` lookup.
1376
+
1377
+ Always overwrites — caller-supplied values for these 9 keys are replaced
1378
+ on every call. For optional defaults (VALIDATION_STATUS etc.) use the
1379
+ companion `apply_lead_prompt_defaults` which preserves caller values.
1380
+ """
1360
1381
  selected = _resolve_workers(ctx)
1361
1382
  catalog = _worker_catalog(ctx)
1362
- lead_model = ctx.get("LEAD_MODEL_DISPLAY", "")
1383
+ lead_model = ctx.get("LEAD_MODEL", "")
1363
1384
  lead_model_execution = ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
1364
1385
 
1365
1386
  def fmt_assignment(role: str, model: str, execution: str) -> str:
@@ -1367,12 +1388,12 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1367
1388
  return f"- `{role}`: `{model}` (launch value: `{execution}`)"
1368
1389
  return f"- `{role}`: `{model}`"
1369
1390
 
1370
- worker_result_lines = []
1391
+ worker_result_lines: list[str] = []
1371
1392
  team_role_lines = [f" 1. `Claude lead` (assigned model: `{lead_model}`)"]
1372
1393
  model_assignment_lines = [
1373
1394
  fmt_assignment("Claude lead", lead_model, lead_model_execution)
1374
1395
  ]
1375
- worker_role_labels = []
1396
+ worker_role_labels: list[str] = []
1376
1397
  execution_status_entries = ["`Claude lead`"]
1377
1398
  execution_status_table_lines = [
1378
1399
  "| 에이전트 | 역할 | 모델 | 상태 | 핵심 발견 요약 |",
@@ -1424,7 +1445,7 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1424
1445
  # selected) is single-lead and MUST NOT call `TeamCreate`. Emit a
1425
1446
  # short notice instead of the BLOCKING gate.
1426
1447
  # - All other phases keep the full team-creation contract.
1427
- task_type = ctx.get("ANALYSIS_TYPE", "")
1448
+ task_type = ctx.get("TASK_TYPE", "")
1428
1449
  if task_type == "release-handoff" or not selected:
1429
1450
  team_creation_gate_block = (
1430
1451
  "## Single-Lead Phase (no team creation)\n"
@@ -1466,172 +1487,93 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1466
1487
  "response is to go back to step 2 — NOT to strip `team_name` and retry."
1467
1488
  )
1468
1489
 
1469
- mapping = {
1470
- "{{TEAM_CREATION_GATE}}": team_creation_gate_block,
1471
- "{{TASK_TYPE}}": ctx.get("ANALYSIS_TYPE", ""),
1472
- "{{ANALYSIS_PROFILE}}": ctx.get("REVIEW_PROFILE", ""),
1473
- "{{ANALYSIS_TYPE}}": ctx.get("ANALYSIS_TYPE", ""),
1474
- "{{RECOMMENDED_ANALYSERS}}": ctx.get("SELECTED_REVIEWERS", ""),
1475
- "{{PROJECT_ROOT}}": ctx.get("PROJECT_ROOT", ""),
1476
- "{{BRIEF_RELATIVE_PATH}}": ctx.get("BRIEF_RELATIVE_PATH", ""),
1477
- "{{BRIEF_FILE_PATH}}": ctx.get("BRIEF_FILE_PATH", ""),
1478
- "{{CLARIFICATION_RESPONSE_PATH}}": ctx.get("CLARIFICATION_RESPONSE_FILE", ""),
1479
- "{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}": ctx.get(
1480
- "CLARIFICATION_RESPONSE_RELATIVE_PATH", ""
1481
- ),
1482
- "{{RUN_DIR}}": ctx.get("RUN_DIR", ""),
1483
- "{{RUN_DIR_RELATIVE_PATH}}": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
1484
- "{{RUN_MANIFESTS_RELATIVE_PATH}}": ctx.get("RUN_MANIFESTS_RELATIVE_PATH", ""),
1485
- "{{RUN_STATE_RELATIVE_PATH}}": ctx.get("RUN_STATE_RELATIVE_PATH", ""),
1486
- "{{RUN_PROMPTS_RELATIVE_PATH}}": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
1487
- "{{RUN_REPORTS_RELATIVE_PATH}}": ctx.get("RUN_REPORTS_RELATIVE_PATH", ""),
1488
- "{{RUN_STATUS_RELATIVE_PATH}}": ctx.get("RUN_STATUS_RELATIVE_PATH", ""),
1489
- "{{RUN_SESSIONS_RELATIVE_PATH}}": ctx.get("RUN_SESSIONS_RELATIVE_PATH", ""),
1490
- "{{TASK_ROOT}}": ctx.get("TASK_ROOT", ""),
1491
- "{{TASK_MANIFEST_PATH}}": ctx.get("TASK_MANIFEST_FILE", ""),
1492
- "{{TASK_INDEX_PATH}}": ctx.get("TASK_INDEX_FILE", ""),
1493
- "{{INSTRUCTION_SET_PATH}}": ctx.get("INSTRUCTION_SET_DIR", ""),
1494
- "{{RUN_MANIFEST_PATH}}": ctx.get("RUN_MANIFEST_FILE", ""),
1495
- "{{RUN_MANIFEST_RELATIVE_PATH}}": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
1496
- "{{TIMELINE_PATH}}": ctx.get("TIMELINE_FILE", ""),
1497
- "{{RUN_TIMESTAMP_ISO}}": ctx.get("RUN_TIMESTAMP_ISO", ""),
1498
- "{{FINAL_REPORT_PATH}}": ctx.get("FINAL_REPORT_FILE", ""),
1499
- "{{FINAL_REPORT_RELATIVE_PATH}}": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
1500
- "{{FINAL_STATUS_PATH}}": ctx.get("FINAL_STATUS_FILE", ""),
1501
- "{{FINAL_STATUS_RELATIVE_PATH}}": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
1502
- "{{TEAM_STATE_PATH}}": ctx.get("TEAM_STATE_FILE", ""),
1503
- "{{TEAM_STATE_RELATIVE_PATH}}": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
1504
- "{{WORKER_RESULTS_PATH}}": ctx.get("WORKER_RESULTS_DIR", ""),
1505
- "{{WORKER_RESULTS_RELATIVE_PATH}}": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
1506
- "{{RUN_VALIDATOR_PATH}}": ctx.get("RUN_VALIDATOR_SCRIPT", ""),
1507
- "{{RUN_VALIDATOR_RELATIVE_PATH}}": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
1508
- "{{CLAUDE_WORKER_RESULT_RELATIVE_PATH}}": ctx.get(
1509
- "CLAUDE_WORKER_RESULT_RELATIVE_PATH", ""
1510
- ),
1511
- "{{CODEX_WORKER_RESULT_RELATIVE_PATH}}": ctx.get(
1512
- "CODEX_WORKER_RESULT_RELATIVE_PATH", ""
1513
- ),
1514
- "{{GEMINI_WORKER_RESULT_RELATIVE_PATH}}": ctx.get(
1515
- "GEMINI_WORKER_RESULT_RELATIVE_PATH", ""
1516
- ),
1517
- "{{REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH}}": ctx.get(
1518
- "REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH", ""
1519
- ),
1520
- "{{RUN_ERRORS_LOG_PATH}}": ctx.get("RUN_ERRORS_LOG_FILE", ""),
1521
- "{{RUN_ERRORS_LOG_RELATIVE_PATH}}": ctx.get("RUN_ERRORS_LOG_RELATIVE_PATH", ""),
1522
- "{{CLAUDE_WORKER_ERRORS_SIDECAR_PATH}}": ctx.get(
1523
- "CLAUDE_WORKER_ERRORS_SIDECAR_FILE", ""
1524
- ),
1525
- "{{CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH}}": ctx.get(
1526
- "CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""
1527
- ),
1528
- "{{CODEX_WORKER_ERRORS_SIDECAR_PATH}}": ctx.get(
1529
- "CODEX_WORKER_ERRORS_SIDECAR_FILE", ""
1530
- ),
1531
- "{{CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH}}": ctx.get(
1532
- "CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""
1533
- ),
1534
- "{{GEMINI_WORKER_ERRORS_SIDECAR_PATH}}": ctx.get(
1535
- "GEMINI_WORKER_ERRORS_SIDECAR_FILE", ""
1536
- ),
1537
- "{{GEMINI_WORKER_ERRORS_SIDECAR_RELATIVE_PATH}}": ctx.get(
1538
- "GEMINI_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""
1539
- ),
1540
- "{{REPORT_WRITER_WORKER_ERRORS_SIDECAR_PATH}}": ctx.get(
1541
- "REPORT_WRITER_WORKER_ERRORS_SIDECAR_FILE", ""
1542
- ),
1543
- "{{REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH}}": ctx.get(
1544
- "REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""
1545
- ),
1546
- "{{LEAD_MODEL}}": lead_model,
1547
- "{{LEAD_MODEL_EXECUTION_VALUE}}": lead_model_execution,
1548
- "{{OKSTRA_VERSION}}": ctx.get("OKSTRA_VERSION", ""),
1549
- "{{CLAUDE_WORKER_MODEL}}": ctx.get("CLAUDE_WORKER_MODEL_DISPLAY", ""),
1550
- "{{CLAUDE_WORKER_MODEL_EXECUTION_VALUE}}": ctx.get(
1551
- "CLAUDE_WORKER_MODEL_EXECUTION_VALUE", ""
1552
- ),
1553
- "{{CODEX_WORKER_MODEL}}": ctx.get("CODEX_WORKER_MODEL_DISPLAY", ""),
1554
- "{{CODEX_WORKER_MODEL_EXECUTION_VALUE}}": ctx.get(
1555
- "CODEX_WORKER_MODEL_EXECUTION_VALUE", ""
1556
- ),
1557
- "{{GEMINI_WORKER_MODEL}}": ctx.get("GEMINI_WORKER_MODEL_DISPLAY", ""),
1558
- "{{GEMINI_WORKER_MODEL_EXECUTION_VALUE}}": ctx.get(
1559
- "GEMINI_WORKER_MODEL_EXECUTION_VALUE", ""
1560
- ),
1561
- "{{REPORT_WRITER_MODEL}}": ctx.get("REPORT_WRITER_MODEL_DISPLAY", ""),
1562
- "{{REPORT_WRITER_MODEL_EXECUTION_VALUE}}": ctx.get(
1563
- "REPORT_WRITER_MODEL_EXECUTION_VALUE", ""
1564
- ),
1565
- "{{WORKER_RESULT_PATH_LINES}}": "\n".join(worker_result_lines),
1566
- "{{MODEL_ASSIGNMENT_LINES}}": "\n".join(model_assignment_lines),
1567
- "{{TEAM_ROLE_LINES}}": "\n".join(team_role_lines),
1568
- "{{REQUIRED_WORKER_ROLE_SENTENCE}}": worker_role_sentence,
1569
- "{{GEMINI_ATTEMPT_SENTENCE}}": worker_attempt_sentence,
1570
- "{{PREFERRED_WORKER_RESULTS_SENTENCE}}": preferred_results_sentence,
1571
- "{{EXECUTION_STATUS_EXACT_ENTRIES}}": ", ".join(execution_status_entries),
1572
- "{{EXECUTION_STATUS_TABLE_ROWS}}": "\n".join(execution_status_table_lines),
1573
- "{{FINAL_REPORT_TEMPLATE_PATH}}": ctx.get("FINAL_REPORT_TEMPLATE_FILE", ""),
1574
- "{{FINAL_REPORT_TEMPLATE_RELATIVE_PATH}}": ctx.get(
1575
- "FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""
1576
- ),
1577
- "{{REFERENCE_EXPECTATIONS_RELATIVE_PATH}}": ctx.get(
1578
- "REFERENCE_EXPECTATIONS_RELATIVE_PATH", ""
1579
- ),
1580
- "{{CLAUDE_SESSION_ID}}": ctx.get("CLAUDE_SESSION_ID", ""),
1581
- "{{CLAUDE_RESUME_COMMAND_PATH}}": ctx.get("CLAUDE_RESUME_COMMAND_FILE", ""),
1582
- "{{CLAUDE_RESUME_COMMAND_RELATIVE_PATH}}": ctx.get(
1583
- "CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""
1584
- ),
1585
- "{{TASK_ROOT_RELATIVE_PATH}}": ctx.get("TASK_ROOT_RELATIVE_PATH", ""),
1586
- "{{TASK_MANIFEST_RELATIVE_PATH}}": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
1587
- "{{TASK_INDEX_RELATIVE_PATH}}": ctx.get("TASK_INDEX_RELATIVE_PATH", ""),
1588
- "{{INSTRUCTION_SET_RELATIVE_PATH}}": ctx.get(
1589
- "INSTRUCTION_SET_RELATIVE_PATH", ""
1590
- ),
1591
- "{{RUNS_RELATIVE_PATH}}": ctx.get("RUNS_RELATIVE_PATH", ""),
1592
- "{{HISTORY_RELATIVE_PATH}}": ctx.get("HISTORY_RELATIVE_PATH", ""),
1593
- "{{OKSTRA_DISCOVERY_RELATIVE_PATH}}": ctx.get(
1594
- "OKSTRA_DISCOVERY_RELATIVE_PATH", ""
1595
- ),
1596
- "{{OKSTRA_LATEST_TASK_RELATIVE_PATH}}": ctx.get(
1597
- "OKSTRA_LATEST_TASK_RELATIVE_PATH", ""
1598
- ),
1599
- "{{OKSTRA_TASK_CATALOG_RELATIVE_PATH}}": ctx.get(
1600
- "OKSTRA_TASK_CATALOG_RELATIVE_PATH", ""
1601
- ),
1602
- "{{LATEST_RUN_RELATIVE_PATH}}": ctx.get("LATEST_RUN_RELATIVE_PATH", ""),
1603
- "{{LATEST_REPORT_RELATIVE_PATH}}": ctx.get("LATEST_REPORT_RELATIVE_PATH", ""),
1604
- "{{TIMELINE_RELATIVE_PATH}}": ctx.get("TIMELINE_RELATIVE_PATH", ""),
1605
- "{{CURRENT_TASK_STATUS}}": ctx.get("CURRENT_TASK_STATUS", ""),
1606
- "{{CURRENT_RUN_STATUS}}": ctx.get("CURRENT_RUN_STATUS", ""),
1607
- "{{VALIDATION_STATUS}}": ctx.get("VALIDATION_STATUS", "not-run"),
1608
- "{{RELATED_TASKS_BULLETS}}": ctx.get(
1609
- "RELATED_TASKS_BULLETS", "- None recorded"
1610
- ),
1611
- "{{RELATED_TASKS_INLINE}}": ctx.get("RELATED_TASKS_INLINE", "None"),
1612
- "{{WORKFLOW_CURRENT_PHASE}}": ctx.get("WORKFLOW_CURRENT_PHASE", ""),
1613
- "{{WORKFLOW_NEXT_RECOMMENDED_PHASE}}": ctx.get(
1614
- "WORKFLOW_NEXT_RECOMMENDED_PHASE", ""
1615
- ),
1616
- "{{PHASE_ALLOWED_OUTPUTS}}": ctx.get("PHASE_ALLOWED_OUTPUTS", ""),
1617
- "{{PHASE_FORBIDDEN_ACTIONS}}": ctx.get("PHASE_FORBIDDEN_ACTIONS", ""),
1618
- "{{AVAILABLE_MCP_SERVERS}}": ctx.get(
1619
- "AVAILABLE_MCP_SERVERS",
1620
- build_available_mcp_servers_block(Path(ctx.get("PROJECT_ROOT", "."))),
1621
- ),
1622
- "{{EXECUTOR_WORKTREE_PATH}}": ctx.get("EXECUTOR_WORKTREE_PATH", ""),
1623
- "{{EXECUTOR_WORKTREE_BRANCH}}": ctx.get("EXECUTOR_WORKTREE_BRANCH", ""),
1624
- "{{EXECUTOR_WORKTREE_BASE_REF}}": ctx.get("EXECUTOR_WORKTREE_BASE_REF", ""),
1625
- "{{EXECUTOR_WORKTREE_STATUS}}": ctx.get("EXECUTOR_WORKTREE_STATUS", ""),
1626
- "{{EXECUTOR_WORKTREE_NOTE}}": ctx.get("EXECUTOR_WORKTREE_NOTE", ""),
1627
- }
1490
+ # Compute results (deterministic from ctx, 덮어쓰기)
1491
+ ctx["TEAM_CREATION_GATE"] = team_creation_gate_block
1492
+ ctx["WORKER_RESULT_PATH_LINES"] = "\n".join(worker_result_lines)
1493
+ ctx["MODEL_ASSIGNMENT_LINES"] = "\n".join(model_assignment_lines)
1494
+ ctx["TEAM_ROLE_LINES"] = "\n".join(team_role_lines)
1495
+ ctx["REQUIRED_WORKER_ROLE_SENTENCE"] = worker_role_sentence
1496
+ ctx["GEMINI_ATTEMPT_SENTENCE"] = worker_attempt_sentence
1497
+ ctx["PREFERRED_WORKER_RESULTS_SENTENCE"] = preferred_results_sentence
1498
+ ctx["EXECUTION_STATUS_EXACT_ENTRIES"] = ", ".join(execution_status_entries)
1499
+ ctx["EXECUTION_STATUS_TABLE_ROWS"] = "\n".join(execution_status_table_lines)
1500
+
1501
+
1502
+ def apply_lead_prompt_defaults(ctx: dict) -> None:
1503
+ """Apply default values for optional lead-prompt ctx fields.
1504
+
1505
+ Sets four optional tokens that the lead prompt template references but
1506
+ which callers may legitimately leave unset (e.g., no validation has run
1507
+ yet, no related tasks were declared). Caller-supplied values are
1508
+ preserved via `setdefault` / `if-not-in` semantics — this function only
1509
+ fills gaps, never overwrites.
1510
+
1511
+ Companion to `inject_lead_prompt_computed_tokens` (which always
1512
+ overwrites with deterministically-derived values). The two functions
1513
+ are kept separate so each has a single clear responsibility:
1514
+ inject = compute-and-overwrite, apply_defaults = fill-if-missing.
1515
+ """
1516
+ ctx.setdefault("VALIDATION_STATUS", "not-run")
1517
+ ctx.setdefault("RELATED_TASKS_BULLETS", "- None recorded")
1518
+ ctx.setdefault("RELATED_TASKS_INLINE", "None")
1519
+ if "AVAILABLE_MCP_SERVERS" not in ctx:
1520
+ ctx["AVAILABLE_MCP_SERVERS"] = build_available_mcp_servers_block(
1521
+ Path(ctx.get("PROJECT_ROOT", "."))
1522
+ )
1523
+
1524
+
1525
+ _TOKEN_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
1526
+
1527
+
1528
+ def render_template_with_ctx(template_path: str, output_path: str, ctx: dict) -> None:
1529
+ """Render a `{{TOKEN}}` template with pure ctx[token] lookup.
1530
+
1531
+ - Tokens match regex `_TOKEN_RE` (uppercase snake).
1532
+ - Each token MUST exist in ctx. Missing → `TokenRenderError` (fail-fast).
1533
+ - Phase block stripping (`{% if header.taskType == 'X' %} ... {% endif %}`)
1534
+ is applied per `ctx['TASK_TYPE']`.
1535
+ - Frontmatter mapping (`_frontmatter_mapping`) is overlaid (same as legacy
1536
+ renderer).
1537
+
1538
+ Callers that need computed tokens (team_creation_gate etc.) MUST call
1539
+ `inject_lead_prompt_computed_tokens(ctx)` BEFORE invoking this function.
1540
+ Optional defaults (VALIDATION_STATUS etc.) should be filled by calling
1541
+ `apply_lead_prompt_defaults(ctx)` in the same setup step.
1542
+ """
1543
+ template = Path(template_path).read_text(encoding="utf-8")
1544
+
1628
1545
  fm_ctx = dict(ctx)
1629
1546
  fm_ctx.setdefault("DOC_TYPE", _doc_type_from_template_path(template_path))
1630
- mapping.update(_frontmatter_mapping(fm_ctx))
1547
+ fm_overlay = _frontmatter_mapping(fm_ctx) # {"{{DOC_TITLE}}": "...", ...}
1548
+
1549
+ # frontmatter overlay 가 채우는 키들도 lookup 대상 — 단일 lookup 으로 통일
1550
+ lookup: dict[str, str] = {}
1551
+ for tok_with_braces, value in fm_overlay.items():
1552
+ key = tok_with_braces[2:-2] # "{{X}}" -> "X"
1553
+ lookup[key] = value
1554
+
1631
1555
  rendered = template
1632
- for k, v in mapping.items():
1633
- rendered = rendered.replace(k, v)
1634
- rendered = _strip_phase_blocks(rendered, ctx.get("ANALYSIS_TYPE", ""))
1556
+ missing: list[str] = []
1557
+ for match in _TOKEN_RE.finditer(template):
1558
+ token = match.group(1)
1559
+ if token in lookup:
1560
+ value = lookup[token]
1561
+ elif token in ctx:
1562
+ value = str(ctx[token])
1563
+ else:
1564
+ missing.append(token)
1565
+ continue
1566
+ rendered = rendered.replace("{{" + token + "}}", value)
1567
+
1568
+ if missing:
1569
+ names = ", ".join(sorted(set(missing)))
1570
+ raise TokenRenderError(
1571
+ f"undefined lead-prompt token(s): {names} (template={template_path}). "
1572
+ f"Add the key(s) to ctx in run.py / "
1573
+ f"inject_lead_prompt_computed_tokens() / apply_lead_prompt_defaults()."
1574
+ )
1575
+
1576
+ rendered = _strip_phase_blocks(rendered, ctx.get("TASK_TYPE", ""))
1635
1577
  _write_text(Path(output_path), rendered.rstrip() + "\n")
1636
1578
 
1637
1579
 
@@ -1681,7 +1623,10 @@ def main(argv: list[str]) -> int:
1681
1623
  render_task_index(template_path, output_path, _load_ctx(ctx_path))
1682
1624
  elif sub == "template":
1683
1625
  ctx_path, template_path, output_path = rest
1684
- render_template_file(template_path, output_path, _load_ctx(ctx_path))
1626
+ ctx = _load_ctx(ctx_path)
1627
+ inject_lead_prompt_computed_tokens(ctx)
1628
+ apply_lead_prompt_defaults(ctx)
1629
+ render_template_with_ctx(template_path, output_path, ctx)
1685
1630
  else:
1686
1631
  print(f"unknown subcommand: {sub}", file=sys.stderr)
1687
1632
  return 2