okstra 0.34.1 → 0.36.1

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 (108) hide show
  1. package/README.kr.md +27 -19
  2. package/README.md +27 -19
  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 +353 -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/plans/2026-05-24-implementation-lead-context-slimming.md +1700 -0
  17. package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +383 -0
  18. package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +320 -0
  19. package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +299 -0
  20. package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +335 -0
  21. package/docs/task-process/README.md +74 -0
  22. package/docs/task-process/common-flow.md +166 -0
  23. package/docs/task-process/error-analysis.md +101 -0
  24. package/docs/task-process/final-verification.md +167 -0
  25. package/docs/task-process/implementation-planning.md +128 -0
  26. package/docs/task-process/implementation.md +149 -0
  27. package/docs/task-process/release-handoff.md +206 -0
  28. package/docs/task-process/requirements-discovery.md +115 -0
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/SKILL.md +30 -7
  32. package/runtime/agents/workers/claude-worker.md +31 -6
  33. package/runtime/agents/workers/codex-worker.md +37 -10
  34. package/runtime/agents/workers/gemini-worker.md +34 -7
  35. package/runtime/agents/workers/report-writer-worker.md +19 -10
  36. package/runtime/bin/okstra-central.sh +6 -6
  37. package/runtime/bin/okstra-codex-exec.sh +49 -28
  38. package/runtime/bin/okstra-gemini-exec.sh +39 -21
  39. package/runtime/bin/okstra-render-final-report.py +13 -2
  40. package/runtime/bin/okstra-wrapper-status.py +155 -0
  41. package/runtime/bin/okstra.sh +2 -2
  42. package/runtime/prompts/launch.template.md +1 -0
  43. package/runtime/prompts/profiles/_common-contract.md +11 -6
  44. package/runtime/prompts/profiles/_implementation-deliverable.md +53 -0
  45. package/runtime/prompts/profiles/_implementation-executor.md +60 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +76 -0
  47. package/runtime/prompts/profiles/error-analysis.md +3 -7
  48. package/runtime/prompts/profiles/implementation-planning.md +22 -21
  49. package/runtime/prompts/profiles/implementation.md +28 -118
  50. package/runtime/prompts/profiles/improvement-discovery.md +42 -0
  51. package/runtime/prompts/profiles/release-handoff.md +1 -1
  52. package/runtime/prompts/profiles/requirements-discovery.md +8 -12
  53. package/runtime/prompts/wizard/prompts.ko.json +230 -0
  54. package/runtime/python/lib/okstra/cli.sh +2 -49
  55. package/runtime/python/lib/okstra/globals.sh +21 -21
  56. package/runtime/python/lib/okstra/interactive.sh +7 -7
  57. package/runtime/python/okstra_ctl/clarification_items.py +3 -9
  58. package/runtime/python/okstra_ctl/consumers.py +53 -0
  59. package/runtime/python/okstra_ctl/final_report_schema.py +0 -7
  60. package/runtime/python/okstra_ctl/i18n.py +73 -0
  61. package/runtime/python/okstra_ctl/improvement_lenses.py +44 -0
  62. package/runtime/python/okstra_ctl/index.py +1 -1
  63. package/runtime/python/okstra_ctl/paths.py +26 -20
  64. package/runtime/python/okstra_ctl/render.py +166 -207
  65. package/runtime/python/okstra_ctl/render_final_report.py +53 -10
  66. package/runtime/python/okstra_ctl/run.py +299 -108
  67. package/runtime/python/okstra_ctl/run_context.py +22 -0
  68. package/runtime/python/okstra_ctl/seeding.py +186 -0
  69. package/runtime/python/okstra_ctl/session.py +65 -7
  70. package/runtime/python/okstra_ctl/wizard.py +348 -127
  71. package/runtime/python/okstra_ctl/workflow.py +21 -2
  72. package/runtime/python/okstra_ctl/worktree.py +54 -1
  73. package/runtime/python/okstra_project/resolver.py +4 -3
  74. package/runtime/python/okstra_token_usage/report.py +2 -2
  75. package/runtime/schemas/final-report-v1.0.schema.json +22 -16
  76. package/runtime/skills/okstra-brief/SKILL.md +102 -218
  77. package/runtime/skills/okstra-convergence/SKILL.md +2 -3
  78. package/runtime/skills/okstra-inspect/SKILL.md +581 -0
  79. package/runtime/skills/okstra-report-writer/SKILL.md +35 -15
  80. package/runtime/skills/okstra-run/SKILL.md +8 -7
  81. package/runtime/skills/okstra-schedule/SKILL.md +14 -157
  82. package/runtime/skills/okstra-setup/SKILL.md +28 -1
  83. package/runtime/skills/okstra-team-contract/SKILL.md +16 -107
  84. package/runtime/templates/okstra.CLAUDE.md +104 -0
  85. package/runtime/templates/reports/brief.template.md +204 -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/schedule.template.md +12 -3
  92. package/runtime/templates/reports/task-brief.template.md +2 -2
  93. package/runtime/templates/worker-prompt-preamble.md +108 -0
  94. package/runtime/validators/lib/fixtures.sh +30 -0
  95. package/runtime/validators/lib/runners.sh +1 -1
  96. package/runtime/validators/validate-implementation-plan-stages.py +211 -0
  97. package/runtime/validators/validate-run.py +121 -26
  98. package/runtime/validators/validate-workflow.sh +2 -2
  99. package/runtime/validators/validate_improvement_report.py +275 -0
  100. package/src/config.mjs +18 -0
  101. package/src/install.mjs +41 -14
  102. package/src/setup.mjs +133 -1
  103. package/src/uninstall.mjs +27 -3
  104. package/runtime/skills/okstra-history/SKILL.md +0 -165
  105. package/runtime/skills/okstra-logs/SKILL.md +0 -173
  106. package/runtime/skills/okstra-report-finder/SKILL.md +0 -111
  107. package/runtime/skills/okstra-status/SKILL.md +0 -246
  108. package/runtime/skills/okstra-time-summary/SKILL.md +0 -172
@@ -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")
@@ -643,15 +651,25 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
643
651
  "WORKFLOW_WORK_CATEGORY", "unknown"
644
652
  )
645
653
  # Compute the canonical next phase from current_phase deterministically.
646
- # Only preserve `existing.workflow.nextRecommendedPhase` when it is a
647
- # legitimate forward pointer i.e. NOT equal to `current_phase` itself
648
- # (which would mean the lifecycle pointer has stalled on the current
649
- # phase and would loop forever).
654
+ # Only advance past `current_phase` when its state is terminal-success
655
+ # (`completed`). For `prepared` / `in-progress` / `blocked` / `error` /
656
+ # `contract-violated` states the recommendation MUST stay on
657
+ # `current_phase` advancing prematurely makes wizards (and humans)
658
+ # think the current phase is done when it has merely been provisioned.
659
+ # Historical bug: implementation provisioned in `prepared` state caused
660
+ # the wizard to recommend `final-verification` as the default task_type.
650
661
  canonical_next = default_next_phase.get(
651
662
  current_phase, ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown")
652
663
  )
653
664
  existing_next = workflow.get("nextRecommendedPhase") or ""
654
- if existing_next and existing_next != current_phase:
665
+ terminal_success_states = {"completed"}
666
+ if current_phase_state not in terminal_success_states:
667
+ # Current phase has not finished — recommend staying on it. Suppress
668
+ # any stale `existing_next` that points further forward in the
669
+ # sequence; only honour a stale value if it points to the SAME
670
+ # current_phase (i.e. encodes "re-enter current phase").
671
+ next_recommended_phase = current_phase
672
+ elif existing_next and existing_next != current_phase:
655
673
  next_recommended_phase = existing_next
656
674
  else:
657
675
  next_recommended_phase = canonical_next
@@ -746,7 +764,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
746
764
  "taskGroupPathSegment": ctx.get("TASK_GROUP_SEGMENT", ""),
747
765
  "taskIdPathSegment": ctx.get("TASK_ID_SEGMENT", ""),
748
766
  "projectRoot": ctx.get("PROJECT_ROOT", ""),
749
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
767
+ "taskType": ctx.get("TASK_TYPE", ""),
750
768
  "workCategory": work_category,
751
769
  "taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
752
770
  "recommendedWorkers": reviewers,
@@ -807,7 +825,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
807
825
  "resultContract": {
808
826
  "leadAgent": "claude",
809
827
  "leadRole": "Claude lead",
810
- "leadModel": ctx.get("LEAD_MODEL_DISPLAY", ""),
828
+ "leadModel": ctx.get("LEAD_MODEL", ""),
811
829
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
812
830
  "leadExecutionMode": "synthesis-only",
813
831
  "finalSynthesisOwner": "Claude lead",
@@ -890,7 +908,7 @@ def _build_convergence_block(ctx: dict) -> dict:
890
908
  - `OKSTRA_PLAN_VERIFICATION`: "true" | "false" | "" (empty → default True).
891
909
  Wired from CLI `--no-plan-verification` (sets "false").
892
910
  """
893
- task_type = ctx.get("ANALYSIS_TYPE", "")
911
+ task_type = ctx.get("TASK_TYPE", "")
894
912
  default_max_rounds = 1 if task_type == "requirements-discovery" else 2
895
913
  raw_plan_verify = (ctx.get("OKSTRA_PLAN_VERIFICATION", "") or "").strip().lower()
896
914
  plan_verify_enabled = raw_plan_verify != "false"
@@ -907,7 +925,7 @@ def _build_convergence_block(ctx: dict) -> dict:
907
925
 
908
926
 
909
927
  def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
910
- task_manifest_path = Path(ctx.get("TASK_MANIFEST_FILE", ""))
928
+ task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
911
929
  task_manifest = {}
912
930
  if task_manifest_path.exists():
913
931
  try:
@@ -931,7 +949,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
931
949
  "taskGroup": ctx.get("TASK_GROUP", ""),
932
950
  "taskId": ctx.get("TASK_ID", ""),
933
951
  "taskKey": ctx.get("TASK_KEY", ""),
934
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
952
+ "taskType": ctx.get("TASK_TYPE", ""),
935
953
  "workCategory": task_manifest.get(
936
954
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
937
955
  ),
@@ -995,7 +1013,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
995
1013
  "teamContract": {
996
1014
  "leadAgent": "claude",
997
1015
  "leadRole": "Claude lead",
998
- "leadModel": ctx.get("LEAD_MODEL_DISPLAY", ""),
1016
+ "leadModel": ctx.get("LEAD_MODEL", ""),
999
1017
  "leadModelExecutionValue": ctx.get("LEAD_MODEL_EXECUTION_VALUE", ""),
1000
1018
  "leadExecutionMode": "synthesis-only",
1001
1019
  "finalSynthesisOwner": "Claude lead",
@@ -1034,7 +1052,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1034
1052
 
1035
1053
 
1036
1054
  def render_timeline(timeline_path: str, ctx: dict) -> None:
1037
- task_manifest_path = Path(ctx.get("TASK_MANIFEST_FILE", ""))
1055
+ task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
1038
1056
  task_manifest = {}
1039
1057
  if task_manifest_path.exists():
1040
1058
  try:
@@ -1056,7 +1074,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1056
1074
  except Exception:
1057
1075
  existing = {}
1058
1076
  runs = existing.get("runs", [])
1059
- current_run_manifest_path = ctx.get("RUN_MANIFEST_FILE", "")
1077
+ current_run_manifest_path = ctx.get("RUN_MANIFEST_PATH", "")
1060
1078
  current_run_manifest_relative_path = ctx.get("RUN_MANIFEST_RELATIVE_PATH", "")
1061
1079
  filtered = [
1062
1080
  item
@@ -1085,7 +1103,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1085
1103
  "sessions": ctx.get("RUN_SESSIONS_SEQ", ""),
1086
1104
  "workerResults": ctx.get("WORKER_RESULTS_SEQ", ""),
1087
1105
  },
1088
- "taskType": ctx.get("ANALYSIS_TYPE", ""),
1106
+ "taskType": ctx.get("TASK_TYPE", ""),
1089
1107
  "workCategory": task_manifest.get(
1090
1108
  "workCategory", ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
1091
1109
  ),
@@ -1127,7 +1145,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1127
1145
 
1128
1146
  def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1129
1147
  template = Path(template_path).read_text(encoding="utf-8")
1130
- task_manifest_path = Path(ctx["TASK_MANIFEST_FILE"])
1148
+ task_manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
1131
1149
  task_manifest = {}
1132
1150
  if task_manifest_path.exists():
1133
1151
  try:
@@ -1187,7 +1205,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1187
1205
  )
1188
1206
  mapping = {
1189
1207
  "{{TASK_KEY}}": task_manifest.get("taskKey", ctx.get("TASK_KEY", "")),
1190
- "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("ANALYSIS_TYPE", "")),
1208
+ "{{TASK_TYPE}}": task_manifest.get("taskType", ctx.get("TASK_TYPE", "")),
1191
1209
  "{{TASK_DATE}}": ctx.get("TASK_DATE", ""),
1192
1210
  "{{PROJECT_ID}}": ctx.get("PROJECT_ID", ""),
1193
1211
  "{{TASK_GROUP}}": ctx.get("TASK_GROUP", ""),
@@ -1202,7 +1220,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1202
1220
  "{{RECOMMENDED_ANALYSERS}}": ", ".join(
1203
1221
  task_manifest.get("recommendedWorkers", [])
1204
1222
  ),
1205
- "{{LEAD_MODEL}}": rc.get("leadModel", ctx.get("LEAD_MODEL_DISPLAY", "")),
1223
+ "{{LEAD_MODEL}}": rc.get("leadModel", ctx.get("LEAD_MODEL", "")),
1206
1224
  "{{OKSTRA_VERSION}}": ctx.get("OKSTRA_VERSION", ""),
1207
1225
  "{{LATEST_RUN_RELATIVE_PATH}}": task_manifest.get(
1208
1226
  "latestRunPath", ctx.get("LATEST_RUN_RELATIVE_PATH", "")
@@ -1222,11 +1240,11 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1222
1240
  ),
1223
1241
  "{{MODEL_ASSIGNMENT_LINES}}": "\n".join(
1224
1242
  [
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', '')}`",
1243
+ f"- `Claude lead`: `{rc.get('leadModel', ctx.get('LEAD_MODEL', ''))}`",
1244
+ f"- `Claude worker`: `{ctx.get('CLAUDE_WORKER_MODEL', '')}`",
1245
+ f"- `Codex worker`: `{ctx.get('CODEX_WORKER_MODEL', '')}`",
1246
+ f"- `Gemini worker`: `{ctx.get('GEMINI_WORKER_MODEL', '')}`",
1247
+ f"- `Report writer worker`: `{ctx.get('REPORT_WRITER_MODEL', '')}`",
1230
1248
  ]
1231
1249
  ),
1232
1250
  "{{TASK_MANIFEST_RELATIVE_PATH}}": task_manifest.get(
@@ -1291,7 +1309,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1291
1309
  rendered = template
1292
1310
  for k, v in mapping.items():
1293
1311
  rendered = rendered.replace(k, v)
1294
- rendered = _strip_phase_blocks(rendered, ctx.get("ANALYSIS_TYPE", ""))
1312
+ rendered = _strip_phase_blocks(rendered, ctx.get("TASK_TYPE", ""))
1295
1313
  _write_text(Path(output_path), rendered.rstrip() + "\n")
1296
1314
 
1297
1315
 
@@ -1355,11 +1373,24 @@ def build_available_mcp_servers_block(project_root: Path) -> str:
1355
1373
  # --------------------------------------------------------------------------- #
1356
1374
 
1357
1375
 
1358
- def render_template_file(template_path: str, output_path: str, ctx: dict) -> None:
1359
- template = Path(template_path).read_text(encoding="utf-8")
1376
+ def inject_lead_prompt_computed_tokens(ctx: dict) -> None:
1377
+ """Populate ctx in-place with the 9 derived lead-prompt tokens.
1378
+
1379
+ Tokens that are not 1:1 with a ctx key (TEAM_CREATION_GATE,
1380
+ WORKER_RESULT_PATH_LINES, TEAM_ROLE_LINES, MODEL_ASSIGNMENT_LINES,
1381
+ REQUIRED_WORKER_ROLE_SENTENCE, GEMINI_ATTEMPT_SENTENCE,
1382
+ PREFERRED_WORKER_RESULTS_SENTENCE, EXECUTION_STATUS_EXACT_ENTRIES,
1383
+ EXECUTION_STATUS_TABLE_ROWS) are computed deterministically from ctx
1384
+ so the pure-lookup renderer (`render_template_with_ctx`) can resolve
1385
+ them via plain `ctx[token]` lookup.
1386
+
1387
+ Always overwrites — caller-supplied values for these 9 keys are replaced
1388
+ on every call. For optional defaults (VALIDATION_STATUS etc.) use the
1389
+ companion `apply_lead_prompt_defaults` which preserves caller values.
1390
+ """
1360
1391
  selected = _resolve_workers(ctx)
1361
1392
  catalog = _worker_catalog(ctx)
1362
- lead_model = ctx.get("LEAD_MODEL_DISPLAY", "")
1393
+ lead_model = ctx.get("LEAD_MODEL", "")
1363
1394
  lead_model_execution = ctx.get("LEAD_MODEL_EXECUTION_VALUE", "")
1364
1395
 
1365
1396
  def fmt_assignment(role: str, model: str, execution: str) -> str:
@@ -1367,12 +1398,12 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1367
1398
  return f"- `{role}`: `{model}` (launch value: `{execution}`)"
1368
1399
  return f"- `{role}`: `{model}`"
1369
1400
 
1370
- worker_result_lines = []
1401
+ worker_result_lines: list[str] = []
1371
1402
  team_role_lines = [f" 1. `Claude lead` (assigned model: `{lead_model}`)"]
1372
1403
  model_assignment_lines = [
1373
1404
  fmt_assignment("Claude lead", lead_model, lead_model_execution)
1374
1405
  ]
1375
- worker_role_labels = []
1406
+ worker_role_labels: list[str] = []
1376
1407
  execution_status_entries = ["`Claude lead`"]
1377
1408
  execution_status_table_lines = [
1378
1409
  "| 에이전트 | 역할 | 모델 | 상태 | 핵심 발견 요약 |",
@@ -1424,7 +1455,7 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1424
1455
  # selected) is single-lead and MUST NOT call `TeamCreate`. Emit a
1425
1456
  # short notice instead of the BLOCKING gate.
1426
1457
  # - All other phases keep the full team-creation contract.
1427
- task_type = ctx.get("ANALYSIS_TYPE", "")
1458
+ task_type = ctx.get("TASK_TYPE", "")
1428
1459
  if task_type == "release-handoff" or not selected:
1429
1460
  team_creation_gate_block = (
1430
1461
  "## Single-Lead Phase (no team creation)\n"
@@ -1466,172 +1497,97 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
1466
1497
  "response is to go back to step 2 — NOT to strip `team_name` and retry."
1467
1498
  )
1468
1499
 
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
- }
1500
+ # Compute results (deterministic from ctx, 덮어쓰기)
1501
+ ctx["TEAM_CREATION_GATE"] = team_creation_gate_block
1502
+ ctx["WORKER_RESULT_PATH_LINES"] = "\n".join(worker_result_lines)
1503
+ ctx["MODEL_ASSIGNMENT_LINES"] = "\n".join(model_assignment_lines)
1504
+ ctx["TEAM_ROLE_LINES"] = "\n".join(team_role_lines)
1505
+ ctx["REQUIRED_WORKER_ROLE_SENTENCE"] = worker_role_sentence
1506
+ ctx["GEMINI_ATTEMPT_SENTENCE"] = worker_attempt_sentence
1507
+ ctx["PREFERRED_WORKER_RESULTS_SENTENCE"] = preferred_results_sentence
1508
+ ctx["EXECUTION_STATUS_EXACT_ENTRIES"] = ", ".join(execution_status_entries)
1509
+ ctx["EXECUTION_STATUS_TABLE_ROWS"] = "\n".join(execution_status_table_lines)
1510
+
1511
+
1512
+ def apply_lead_prompt_defaults(ctx: dict) -> None:
1513
+ """Apply default values for optional lead-prompt ctx fields.
1514
+
1515
+ Sets four optional tokens that the lead prompt template references but
1516
+ which callers may legitimately leave unset (e.g., no validation has run
1517
+ yet, no related tasks were declared). Caller-supplied values are
1518
+ preserved via `setdefault` / `if-not-in` semantics — this function only
1519
+ fills gaps, never overwrites.
1520
+
1521
+ Companion to `inject_lead_prompt_computed_tokens` (which always
1522
+ overwrites with deterministically-derived values). The two functions
1523
+ are kept separate so each has a single clear responsibility:
1524
+ inject = compute-and-overwrite, apply_defaults = fill-if-missing.
1525
+ """
1526
+ ctx.setdefault("VALIDATION_STATUS", "not-run")
1527
+ ctx.setdefault("RELATED_TASKS_BULLETS", "- None recorded")
1528
+ ctx.setdefault("RELATED_TASKS_INLINE", "None")
1529
+ ctx.setdefault(
1530
+ "WORKER_PROMPT_PREAMBLE_PATH",
1531
+ str(Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md"),
1532
+ )
1533
+ if "AVAILABLE_MCP_SERVERS" not in ctx:
1534
+ ctx["AVAILABLE_MCP_SERVERS"] = build_available_mcp_servers_block(
1535
+ Path(ctx.get("PROJECT_ROOT", "."))
1536
+ )
1537
+
1538
+
1539
+ _TOKEN_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}")
1540
+
1541
+
1542
+ def render_template_with_ctx(template_path: str, output_path: str, ctx: dict) -> None:
1543
+ """Render a `{{TOKEN}}` template with pure ctx[token] lookup.
1544
+
1545
+ - Tokens match regex `_TOKEN_RE` (uppercase snake).
1546
+ - Each token MUST exist in ctx. Missing → `TokenRenderError` (fail-fast).
1547
+ - Phase block stripping (`{% if header.taskType == 'X' %} ... {% endif %}`)
1548
+ is applied per `ctx['TASK_TYPE']`.
1549
+ - Frontmatter mapping (`_frontmatter_mapping`) is overlaid (same as legacy
1550
+ renderer).
1551
+
1552
+ Callers that need computed tokens (team_creation_gate etc.) MUST call
1553
+ `inject_lead_prompt_computed_tokens(ctx)` BEFORE invoking this function.
1554
+ Optional defaults (VALIDATION_STATUS etc.) should be filled by calling
1555
+ `apply_lead_prompt_defaults(ctx)` in the same setup step.
1556
+ """
1557
+ template = Path(template_path).read_text(encoding="utf-8")
1558
+
1628
1559
  fm_ctx = dict(ctx)
1629
1560
  fm_ctx.setdefault("DOC_TYPE", _doc_type_from_template_path(template_path))
1630
- mapping.update(_frontmatter_mapping(fm_ctx))
1561
+ fm_overlay = _frontmatter_mapping(fm_ctx) # {"{{DOC_TITLE}}": "...", ...}
1562
+
1563
+ # frontmatter overlay 가 채우는 키들도 lookup 대상 — 단일 lookup 으로 통일
1564
+ lookup: dict[str, str] = {}
1565
+ for tok_with_braces, value in fm_overlay.items():
1566
+ key = tok_with_braces[2:-2] # "{{X}}" -> "X"
1567
+ lookup[key] = value
1568
+
1631
1569
  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", ""))
1570
+ missing: list[str] = []
1571
+ for match in _TOKEN_RE.finditer(template):
1572
+ token = match.group(1)
1573
+ if token in lookup:
1574
+ value = lookup[token]
1575
+ elif token in ctx:
1576
+ value = str(ctx[token])
1577
+ else:
1578
+ missing.append(token)
1579
+ continue
1580
+ rendered = rendered.replace("{{" + token + "}}", value)
1581
+
1582
+ if missing:
1583
+ names = ", ".join(sorted(set(missing)))
1584
+ raise TokenRenderError(
1585
+ f"undefined lead-prompt token(s): {names} (template={template_path}). "
1586
+ f"Add the key(s) to ctx in run.py / "
1587
+ f"inject_lead_prompt_computed_tokens() / apply_lead_prompt_defaults()."
1588
+ )
1589
+
1590
+ rendered = _strip_phase_blocks(rendered, ctx.get("TASK_TYPE", ""))
1635
1591
  _write_text(Path(output_path), rendered.rstrip() + "\n")
1636
1592
 
1637
1593
 
@@ -1681,7 +1637,10 @@ def main(argv: list[str]) -> int:
1681
1637
  render_task_index(template_path, output_path, _load_ctx(ctx_path))
1682
1638
  elif sub == "template":
1683
1639
  ctx_path, template_path, output_path = rest
1684
- render_template_file(template_path, output_path, _load_ctx(ctx_path))
1640
+ ctx = _load_ctx(ctx_path)
1641
+ inject_lead_prompt_computed_tokens(ctx)
1642
+ apply_lead_prompt_defaults(ctx)
1643
+ render_template_with_ctx(template_path, output_path, ctx)
1685
1644
  else:
1686
1645
  print(f"unknown subcommand: {sub}", file=sys.stderr)
1687
1646
  return 2