okstra 0.49.0 → 0.51.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 (86) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +23 -24
  5. package/docs/kr/cli.md +6 -6
  6. package/docs/project-structure-overview.md +13 -9
  7. package/docs/superpowers/plans/2026-06-05-wizard-batch-prompts.md +559 -0
  8. package/docs/superpowers/specs/2026-06-05-wizard-batch-prompts-design.md +121 -0
  9. package/docs/task-process/error-analysis.md +1 -1
  10. package/docs/task-process/final-verification.md +1 -1
  11. package/docs/task-process/release-handoff.md +1 -1
  12. package/docs/task-process/requirements-discovery.md +1 -1
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/SKILL.md +18 -14
  16. package/runtime/agents/workers/claude-worker.md +4 -4
  17. package/runtime/agents/workers/codex-worker.md +3 -3
  18. package/runtime/agents/workers/gemini-worker.md +3 -3
  19. package/runtime/agents/workers/report-writer-worker.md +3 -3
  20. package/runtime/bin/lib/okstra/cli.sh +8 -1
  21. package/runtime/bin/lib/okstra/globals.sh +3 -0
  22. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  23. package/runtime/bin/lib/okstra/usage.sh +6 -0
  24. package/runtime/bin/okstra-render-report-views.py +1 -1
  25. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  26. package/runtime/bin/okstra.sh +2 -0
  27. package/runtime/prompts/launch.template.md +4 -2
  28. package/runtime/prompts/profiles/_common-contract.md +15 -15
  29. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  30. package/runtime/prompts/profiles/_implementation-executor.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  32. package/runtime/prompts/profiles/error-analysis.md +1 -1
  33. package/runtime/prompts/profiles/final-verification.md +2 -2
  34. package/runtime/prompts/profiles/implementation-planning.md +10 -9
  35. package/runtime/prompts/profiles/implementation.md +1 -1
  36. package/runtime/prompts/profiles/improvement-discovery.md +5 -5
  37. package/runtime/prompts/profiles/release-handoff.md +2 -2
  38. package/runtime/prompts/profiles/requirements-discovery.md +2 -2
  39. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  40. package/runtime/python/okstra_ctl/clarification_items.py +11 -11
  41. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  42. package/runtime/python/okstra_ctl/migrate.py +2 -12
  43. package/runtime/python/okstra_ctl/paths.py +22 -0
  44. package/runtime/python/okstra_ctl/render.py +285 -126
  45. package/runtime/python/okstra_ctl/render_final_report.py +32 -1
  46. package/runtime/python/okstra_ctl/report_views.py +12 -12
  47. package/runtime/python/okstra_ctl/run.py +510 -248
  48. package/runtime/python/okstra_ctl/sequence.py +2 -5
  49. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  50. package/runtime/python/okstra_ctl/wizard.py +219 -136
  51. package/runtime/python/okstra_ctl/workflow.py +1 -1
  52. package/runtime/python/okstra_ctl/worktree.py +13 -5
  53. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  54. package/runtime/skills/okstra-brief/SKILL.md +1 -1
  55. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  56. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  57. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  58. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  59. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  60. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  61. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  62. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  63. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  64. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  65. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  66. package/runtime/skills/okstra-convergence/SKILL.md +8 -8
  67. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  68. package/runtime/skills/okstra-report-writer/SKILL.md +27 -23
  69. package/runtime/skills/okstra-run/SKILL.md +3 -1
  70. package/runtime/skills/okstra-team-contract/SKILL.md +8 -5
  71. package/runtime/templates/reports/final-report.template.md +188 -187
  72. package/runtime/templates/reports/i18n/en.json +4 -4
  73. package/runtime/templates/reports/i18n/ko.json +4 -4
  74. package/runtime/templates/reports/implementation-planning-input.template.md +1 -1
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/templates/reports/user-response.template.md +1 -1
  77. package/runtime/templates/worker-prompt-preamble.md +4 -4
  78. package/runtime/validators/lib/fixtures.sh +2 -2
  79. package/runtime/validators/validate-implementation-plan-stages.py +9 -9
  80. package/runtime/validators/validate-report-views.py +10 -10
  81. package/runtime/validators/validate-run.py +36 -36
  82. package/runtime/validators/validate_improvement_report.py +8 -8
  83. package/src/_python-helper.mjs +3 -3
  84. package/src/context-cost.mjs +27 -0
  85. package/src/install.mjs +1 -0
  86. package/src/uninstall.mjs +1 -0
@@ -31,9 +31,25 @@ __all__ = [
31
31
  "DISCOVERY_RELATIVE",
32
32
  "compute_run_paths",
33
33
  "next_run_seq",
34
+ "task_dir",
35
+ "task_runs_dir",
34
36
  ]
35
37
 
36
38
 
39
+ def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
40
+ """task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.
41
+
42
+ raw group/id 를 받아 내부에서 slugify 한다 — compute_run_paths 가 쓰는 것과
43
+ 동일한 segment 규칙이다. 과거 sequence/wizard 가 이 구조를 손으로 재유도해
44
+ silent drift 위험이 있던 것을 이 SSOT 로 모은다."""
45
+ return Path(project_root) / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
46
+
47
+
48
+ def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
49
+ """task 의 runs 디렉터리: ``task_dir/runs``."""
50
+ return task_dir(project_root, task_group, task_id) / "runs"
51
+
52
+
37
53
  def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
38
54
  """run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
39
55
  돌려준다. 디렉터리 부재 시 1.
@@ -100,6 +116,7 @@ def compute_run_paths(
100
116
  task_manifest = task_root / "task-manifest.json"
101
117
  task_index = task_root / "task-index.md"
102
118
  instruction_set = task_root / "instruction-set"
119
+ analysis_packet = instruction_set / "analysis-packet.md"
103
120
  runs_dir = task_root / "runs"
104
121
  history_dir = task_root / "history"
105
122
  timeline_file = history_dir / "timeline.json"
@@ -143,6 +160,7 @@ def compute_run_paths(
143
160
  final_report = run_reports / f"final-report{suffixes['reports']}.md"
144
161
  final_status = run_status / f"final{suffixes['status']}.status"
145
162
  team_state = run_state / f"team-state{suffixes['state']}.json"
163
+ active_run_context = run_state / f"active-run-context{suffixes['state']}.json"
146
164
  final_report_template = instruction_set / "final-report-template.md"
147
165
  final_report_schema = instruction_set / "final-report-schema.json"
148
166
  reference_expectations = instruction_set / "reference-expectations.md"
@@ -183,6 +201,7 @@ def compute_run_paths(
183
201
  "TASK_MANIFEST_PATH": str(task_manifest),
184
202
  "TASK_INDEX_PATH": str(task_index),
185
203
  "INSTRUCTION_SET_PATH": str(instruction_set),
204
+ "ANALYSIS_PACKET_PATH": str(analysis_packet),
186
205
  "RUNS_DIR": str(runs_dir),
187
206
  "HISTORY_DIR": str(history_dir),
188
207
  "TIMELINE_PATH": str(timeline_file),
@@ -205,6 +224,7 @@ def compute_run_paths(
205
224
  "FINAL_REPORT_PATH": str(final_report),
206
225
  "FINAL_STATUS_PATH": str(final_status),
207
226
  "TEAM_STATE_PATH": str(team_state),
227
+ "ACTIVE_RUN_CONTEXT_PATH": str(active_run_context),
208
228
  "FINAL_REPORT_TEMPLATE_PATH": str(final_report_template),
209
229
  "FINAL_REPORT_SCHEMA_PATH": str(final_report_schema),
210
230
  "REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
@@ -244,6 +264,7 @@ def compute_run_paths(
244
264
  ("TASK_MANIFEST_RELATIVE_PATH", task_manifest),
245
265
  ("TASK_INDEX_RELATIVE_PATH", task_index),
246
266
  ("INSTRUCTION_SET_RELATIVE_PATH", instruction_set),
267
+ ("ANALYSIS_PACKET_RELATIVE_PATH", analysis_packet),
247
268
  ("RUNS_RELATIVE_PATH", runs_dir),
248
269
  ("HISTORY_RELATIVE_PATH", history_dir),
249
270
  ("TIMELINE_RELATIVE_PATH", timeline_file),
@@ -263,6 +284,7 @@ def compute_run_paths(
263
284
  ("FINAL_REPORT_RELATIVE_PATH", final_report),
264
285
  ("FINAL_STATUS_RELATIVE_PATH", final_status),
265
286
  ("TEAM_STATE_RELATIVE_PATH", team_state),
287
+ ("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", active_run_context),
266
288
  ("WORKER_RESULTS_RELATIVE_PATH", worker_results),
267
289
  ("RUN_CARRY_RELATIVE_PATH", run_carry),
268
290
  ("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
@@ -24,6 +24,11 @@ from pathlib import Path
24
24
 
25
25
  from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
26
26
 
27
+ # phase 시퀀스 / 기본 next-phase 매핑의 SSOT 는 workflow 모듈이다. 과거
28
+ # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
29
+ # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
30
+ from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
31
+
27
32
 
28
33
  class TokenRenderError(Exception):
29
34
  """Raised when a template references a `{{TOKEN}}` not present in ctx.
@@ -75,7 +80,7 @@ def _strip_phase_blocks(text: str, current_phase: str) -> str:
75
80
  entirely. When *current_phase* is empty or not one of the four
76
81
  block-targetable phases (e.g. `requirements-discovery`,
77
82
  `error-analysis`), every block is dropped — correct because none of
78
- the `## 4.5` / `4.6` / `4.7` / `4.8` deliverable sections apply
83
+ the `## 5.5` / `5.6` / `5.7` / `5.8` deliverable sections apply
79
84
  there.
80
85
 
81
86
  Observed (fontsninja-classifier-v2 RD run): the raw final-report
@@ -238,11 +243,154 @@ def _worker_catalog(ctx: dict) -> dict:
238
243
  }
239
244
 
240
245
 
246
+ def _active_workers(ctx: dict) -> list[dict]:
247
+ catalog = _worker_catalog(ctx)
248
+ workers = []
249
+ for worker_id in _resolve_workers(ctx):
250
+ item = catalog[worker_id]
251
+ workers.append({
252
+ "workerId": item["workerId"],
253
+ "role": item["role"],
254
+ "agent": item["agent"],
255
+ "agentLabel": item["agentLabel"],
256
+ "model": item["model"],
257
+ "modelExecutionValue": item["modelExecutionValue"],
258
+ "promptPath": item["promptPath"],
259
+ "resultPath": item["resultPath"],
260
+ "attemptRequired": True,
261
+ })
262
+ return workers
263
+
264
+
265
+ def _active_task(ctx: dict) -> dict:
266
+ return {
267
+ "projectId": ctx.get("PROJECT_ID", ""),
268
+ "taskGroup": ctx.get("TASK_GROUP", ""),
269
+ "taskId": ctx.get("TASK_ID", ""),
270
+ "taskKey": ctx.get("TASK_KEY", ""),
271
+ "taskType": ctx.get("TASK_TYPE", ""),
272
+ "workCategory": ctx.get("WORKFLOW_WORK_CATEGORY", "unknown"),
273
+ "projectRoot": ctx.get("PROJECT_ROOT", ""),
274
+ "taskRootPath": ctx.get("TASK_ROOT_RELATIVE_PATH", ""),
275
+ }
276
+
277
+
278
+ def _active_workflow(ctx: dict) -> dict:
279
+ return {
280
+ "currentPhase": ctx.get("WORKFLOW_CURRENT_PHASE", ""),
281
+ "currentPhaseState": ctx.get("WORKFLOW_CURRENT_PHASE_STATE", ""),
282
+ "lastCompletedPhase": ctx.get("WORKFLOW_LAST_COMPLETED_PHASE", ""),
283
+ "nextRecommendedPhase": ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", ""),
284
+ "awaitingApproval": ctx.get("WORKFLOW_AWAITING_APPROVAL", "false") == "true",
285
+ "routingStatus": ctx.get("WORKFLOW_ROUTING_STATUS", ""),
286
+ "allowedOutputs": ctx.get("PHASE_ALLOWED_OUTPUTS", ""),
287
+ "forbiddenActions": ctx.get("PHASE_FORBIDDEN_ACTIONS", ""),
288
+ }
289
+
290
+
291
+ def _active_run(ctx: dict) -> dict:
292
+ return {
293
+ "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
294
+ "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
295
+ "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
296
+ "promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
297
+ "finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
298
+ "finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
299
+ "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
300
+ "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
301
+ "workerPromptsDirectoryPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
302
+ "workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
303
+ }
304
+
305
+
306
+ def _active_instruction_set(ctx: dict) -> dict:
307
+ instruction_set = ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
308
+ clarification = (
309
+ instruction_set + "/clarification-response.md"
310
+ if ctx.get("CLARIFICATION_RESPONSE_RELATIVE_PATH", "") else ""
311
+ )
312
+ return {
313
+ "path": instruction_set,
314
+ "analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
315
+ "taskBriefPath": instruction_set + "/task-brief.md",
316
+ "analysisProfilePath": instruction_set + "/analysis-profile.md",
317
+ "analysisMaterialPath": instruction_set + "/analysis-material.md",
318
+ "referenceExpectationsPath": ctx.get("REFERENCE_EXPECTATIONS_RELATIVE_PATH", ""),
319
+ "clarificationResponsePath": clarification,
320
+ "finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
321
+ "finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
322
+ }
323
+
324
+
325
+ def _active_error_logs(ctx: dict) -> dict:
326
+ return {
327
+ "runErrorsLogPath": ctx.get("RUN_ERRORS_LOG_RELATIVE_PATH", ""),
328
+ "sidecarsByWorkerId": {
329
+ "claude": ctx.get("CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
330
+ "codex": ctx.get("CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
331
+ "gemini": ctx.get("GEMINI_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
332
+ "report-writer": ctx.get("REPORT_WRITER_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", ""),
333
+ },
334
+ }
335
+
336
+
337
+ def _active_executor_worktree(ctx: dict) -> dict:
338
+ return {
339
+ "status": ctx.get("EXECUTOR_WORKTREE_STATUS", ""),
340
+ "path": ctx.get("EXECUTOR_WORKTREE_PATH", ""),
341
+ "branch": ctx.get("EXECUTOR_WORKTREE_BRANCH", ""),
342
+ "baseRef": ctx.get("EXECUTOR_WORKTREE_BASE_REF", ""),
343
+ "note": ctx.get("EXECUTOR_WORKTREE_NOTE", ""),
344
+ }
345
+
346
+
347
+ def _active_source_artifacts(ctx: dict) -> dict:
348
+ return {
349
+ "taskManifestPath": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
350
+ "runContextPath": ctx.get("RUN_CONTEXT_RELATIVE_PATH", ""),
351
+ "runInputsPath": ctx.get("RUN_INPUTS_RELATIVE_PATH", ""),
352
+ "historyTimelinePath": ctx.get("TIMELINE_RELATIVE_PATH", ""),
353
+ }
354
+
355
+
356
+ def _active_lazy_read_plan() -> dict:
357
+ return {
358
+ "leadPhase1Primary": True,
359
+ "readTaskIndexOnlyForHumanSummary": True,
360
+ "readHistoryTimelineOnlyForHistoryOrCarryInDisambiguation": True,
361
+ "readFinalReportTemplateOnlyForReportWriter": True,
362
+ }
363
+
364
+
241
365
  # --------------------------------------------------------------------------- #
242
366
  # team-state
243
367
  # --------------------------------------------------------------------------- #
244
368
 
245
369
 
370
+ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
371
+ """Write the compact lead intake surface for the current run.
372
+
373
+ This is an interface file, not a replacement for task/run manifests. The
374
+ source manifests remain the audit/replay authority; this file concentrates
375
+ the fields the lead needs at Phase 1 so it does not have to recompose the
376
+ current run from several shallow artifacts.
377
+ """
378
+ payload = {
379
+ "schemaVersion": "1.0",
380
+ "kind": "active-run-context",
381
+ "task": _active_task(ctx),
382
+ "workflow": _active_workflow(ctx),
383
+ "run": _active_run(ctx),
384
+ "instructionSet": _active_instruction_set(ctx),
385
+ "workers": _active_workers(ctx),
386
+ "errorLogs": _active_error_logs(ctx),
387
+ "executorWorktree": _active_executor_worktree(ctx),
388
+ "sourceArtifacts": _active_source_artifacts(ctx),
389
+ "lazyReadPlan": _active_lazy_read_plan(),
390
+ }
391
+ _write_json(Path(active_context_path), payload)
392
+
393
+
246
394
  def render_team_state(team_state_path: str, ctx: dict) -> None:
247
395
  selected = _resolve_workers(ctx)
248
396
  catalog = _worker_catalog(ctx)
@@ -598,6 +746,121 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
598
746
  ]
599
747
 
600
748
 
749
+ def _derive_phase_states(existing_workflow: dict, ctx: dict) -> tuple[dict, str, str]:
750
+ """phaseStates dict + (current_phase, current_phase_state) 를 도출한다.
751
+
752
+ 기존 manifest 의 phaseStates 를 보존하면서 PHASE_SEQUENCE 의 모든 phase 를
753
+ not-started 로 채우고 current_phase 만 현재 상태로 덮어쓴다."""
754
+ phase_states = (
755
+ existing_workflow.get("phaseStates", {})
756
+ if isinstance(existing_workflow.get("phaseStates"), dict)
757
+ else {}
758
+ )
759
+ current_phase = ctx.get("WORKFLOW_CURRENT_PHASE", ctx.get("TASK_TYPE", ""))
760
+ current_phase_state = ctx.get("WORKFLOW_CURRENT_PHASE_STATE", "not-started")
761
+ for phase in PHASE_SEQUENCE:
762
+ phase_states.setdefault(phase, "not-started")
763
+ if current_phase:
764
+ phase_states[current_phase] = current_phase_state
765
+ return phase_states, current_phase, current_phase_state
766
+
767
+
768
+ def _derive_next_recommended_phase(
769
+ current_phase: str, current_phase_state: str, existing_workflow: dict, ctx: dict
770
+ ) -> str:
771
+ """canonical next phase 를 deterministic 하게 결정한다.
772
+
773
+ `current_phase` 가 terminal-success(`completed`) 가 아니면 그 자리에 머무르도록
774
+ recommend 한다. `prepared` / `in-progress` / `blocked` / `error` /
775
+ `contract-violated` 상태에서 다음 phase 로 미리 advance 하면 wizard(와 사람)
776
+ 가 현재 phase 를 끝난 것으로 오인한다. (Historical bug: implementation 이
777
+ `prepared` 상태로 provision 됐을 때 wizard 가 `final-verification` 을 기본
778
+ task_type 으로 추천했다.)"""
779
+ canonical_next = DEFAULT_NEXT_PHASE.get(current_phase) or ctx.get(
780
+ "WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown"
781
+ )
782
+ existing_next = existing_workflow.get("nextRecommendedPhase") or ""
783
+ if current_phase_state not in {"completed"}:
784
+ # 아직 끝나지 않음 — 같은 phase 를 추천한다. sequence 상 더 앞을 가리키는
785
+ # stale existing_next 는 무시하고, 같은 current_phase 를 가리키는 값만 존중.
786
+ return current_phase
787
+ if existing_next and existing_next != current_phase:
788
+ return existing_next
789
+ return canonical_next
790
+
791
+
792
+ def _derive_latest_pointers(existing: dict, ctx: dict, current_report_relative: str) -> dict:
793
+ """latest run/report/team 포인터 + lastSafeCheckpoint 를 도출한다.
794
+
795
+ render-only 경로는 기존 manifest 의 포인터를 보존(checkpoint 가 진행 중인
796
+ 실제 run 을 덮어쓰지 않도록)하고, 일반 경로는 이번 run 의 ctx 값으로 갱신한다."""
797
+ render_only = ctx.get("RENDER_ONLY", "") == "true"
798
+ existing_workflow = (
799
+ existing.get("workflow", {}) if isinstance(existing.get("workflow"), dict) else {}
800
+ )
801
+ existing_checkpoint = existing_workflow.get("lastSafeCheckpoint", {})
802
+ if not isinstance(existing_checkpoint, dict):
803
+ existing_checkpoint = {}
804
+ if render_only:
805
+ return {
806
+ "lastSafeCheckpoint": {
807
+ "label": existing_checkpoint.get("label", ""),
808
+ "taskManifestPath": existing_checkpoint.get(
809
+ "taskManifestPath", ctx.get("TASK_MANIFEST_RELATIVE_PATH", "")
810
+ ),
811
+ "taskIndexPath": existing_checkpoint.get(
812
+ "taskIndexPath", ctx.get("TASK_INDEX_RELATIVE_PATH", "")
813
+ ),
814
+ "latestRunPath": existing_checkpoint.get(
815
+ "latestRunPath", existing.get("latestRunPath", "")
816
+ ),
817
+ "latestRunManifestPath": existing_checkpoint.get(
818
+ "latestRunManifestPath", ""
819
+ ),
820
+ "latestTeamStatePath": existing_checkpoint.get(
821
+ "latestTeamStatePath", existing.get("teamStatePath", "")
822
+ ),
823
+ "latestReportPath": existing_checkpoint.get(
824
+ "latestReportPath", existing.get("latestReportPath", "")
825
+ ),
826
+ "latestResumeCommandPath": existing_checkpoint.get(
827
+ "latestResumeCommandPath", existing.get("latestResumeCommandPath", "")
828
+ ),
829
+ },
830
+ "latestRunPath": existing.get("latestRunPath", "")
831
+ or ctx.get("LATEST_RUN_RELATIVE_PATH", ""),
832
+ "latestRunStatus": existing.get("latestRunStatus", "")
833
+ or ctx.get("CURRENT_RUN_STATUS", ""),
834
+ "latestRunPromptsPath": existing.get("latestRunPromptsPath", "")
835
+ or ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
836
+ "latestReportPath": existing.get("latestReportPath", "")
837
+ or current_report_relative,
838
+ "latestTeamStatePath": existing.get("teamStatePath", "")
839
+ or ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
840
+ "latestResumeCommandPath": existing.get("latestResumeCommandPath", ""),
841
+ }
842
+ return {
843
+ "lastSafeCheckpoint": {
844
+ "label": ctx.get("WORKFLOW_LAST_SAFE_CHECKPOINT_LABEL", ""),
845
+ "taskManifestPath": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
846
+ "taskIndexPath": ctx.get("TASK_INDEX_RELATIVE_PATH", ""),
847
+ "latestRunPath": ctx.get("LATEST_RUN_RELATIVE_PATH", ""),
848
+ "latestRunManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
849
+ "latestTeamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
850
+ "latestReportPath": current_report_relative,
851
+ "latestResumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
852
+ },
853
+ "latestRunPath": ctx.get("LATEST_RUN_RELATIVE_PATH", ""),
854
+ "latestRunStatus": ctx.get("CURRENT_RUN_STATUS", ""),
855
+ "latestRunPromptsPath": ctx.get("RUN_PROMPTS_RELATIVE_PATH", ""),
856
+ "latestReportPath": current_report_relative
857
+ or existing.get("latestReportPath", ""),
858
+ "latestTeamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
859
+ "latestResumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", "")
860
+ or existing.get("latestResumeCommandPath", ""),
861
+ }
862
+
863
+
601
864
  def render_task_manifest(manifest_path: str, ctx: dict) -> None:
602
865
  path = Path(manifest_path)
603
866
  existing = {}
@@ -608,22 +871,6 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
608
871
  existing = {}
609
872
  reviewers = _resolve_workers(ctx)
610
873
  catalog = _worker_catalog(ctx)
611
- phase_sequence = [
612
- "requirements-discovery",
613
- "error-analysis",
614
- "implementation-planning",
615
- "implementation",
616
- "final-verification",
617
- "release-handoff",
618
- ]
619
- default_next_phase = {
620
- "requirements-discovery": "pending-routing-decision",
621
- "error-analysis": "implementation-planning",
622
- "implementation-planning": "implementation",
623
- "implementation": "final-verification",
624
- "final-verification": "pending-release-handoff",
625
- "release-handoff": "done-or-follow-up",
626
- }
627
874
  required_worker_roles = _required_worker_roles(ctx, reviewers)
628
875
  worker_prompt_paths = {item: catalog[item]["promptPath"] for item in reviewers}
629
876
  required_agent_status_entries = ["Claude lead"] + [
@@ -633,129 +880,37 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
633
880
  current_report_relative = ctx.get("LATEST_REPORT_RELATIVE_PATH") or ctx.get(
634
881
  "FINAL_REPORT_RELATIVE_PATH", ""
635
882
  )
636
- workflow = (
883
+ existing_workflow = (
637
884
  existing.get("workflow", {})
638
885
  if isinstance(existing.get("workflow"), dict)
639
886
  else {}
640
887
  )
641
- phase_states = (
642
- workflow.get("phaseStates", {})
643
- if isinstance(workflow.get("phaseStates"), dict)
644
- else {}
888
+ phase_states, current_phase, current_phase_state = _derive_phase_states(
889
+ existing_workflow, ctx
645
890
  )
646
- current_phase = ctx.get("WORKFLOW_CURRENT_PHASE", ctx.get("TASK_TYPE", ""))
647
- current_phase_state = ctx.get("WORKFLOW_CURRENT_PHASE_STATE", "not-started")
648
- for phase in phase_sequence:
649
- phase_states.setdefault(phase, "not-started")
650
- if current_phase:
651
- phase_states[current_phase] = current_phase_state
652
891
  work_category = existing.get("workCategory") or ctx.get(
653
892
  "WORKFLOW_WORK_CATEGORY", "unknown"
654
893
  )
655
- # Compute the canonical next phase from current_phase deterministically.
656
- # Only advance past `current_phase` when its state is terminal-success
657
- # (`completed`). For `prepared` / `in-progress` / `blocked` / `error` /
658
- # `contract-violated` states the recommendation MUST stay on
659
- # `current_phase` — advancing prematurely makes wizards (and humans)
660
- # think the current phase is done when it has merely been provisioned.
661
- # Historical bug: implementation provisioned in `prepared` state caused
662
- # the wizard to recommend `final-verification` as the default task_type.
663
- canonical_next = default_next_phase.get(
664
- current_phase, ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown")
894
+ next_recommended_phase = _derive_next_recommended_phase(
895
+ current_phase, current_phase_state, existing_workflow, ctx
665
896
  )
666
- existing_next = workflow.get("nextRecommendedPhase") or ""
667
- terminal_success_states = {"completed"}
668
- if current_phase_state not in terminal_success_states:
669
- # Current phase has not finished — recommend staying on it. Suppress
670
- # any stale `existing_next` that points further forward in the
671
- # sequence; only honour a stale value if it points to the SAME
672
- # current_phase (i.e. encodes "re-enter current phase").
673
- next_recommended_phase = current_phase
674
- elif existing_next and existing_next != current_phase:
675
- next_recommended_phase = existing_next
676
- else:
677
- next_recommended_phase = canonical_next
678
- last_completed_phase = workflow.get("lastCompletedPhase") or ctx.get(
897
+ last_completed_phase = existing_workflow.get("lastCompletedPhase") or ctx.get(
679
898
  "WORKFLOW_LAST_COMPLETED_PHASE", ""
680
899
  )
681
- routing_status = workflow.get("routingStatus") or ctx.get(
900
+ routing_status = existing_workflow.get("routingStatus") or ctx.get(
682
901
  "WORKFLOW_ROUTING_STATUS", "not-applicable"
683
902
  )
684
- awaiting_approval = workflow.get("awaitingApproval")
903
+ awaiting_approval = existing_workflow.get("awaitingApproval")
685
904
  if not isinstance(awaiting_approval, bool):
686
905
  awaiting_approval = ctx.get("WORKFLOW_AWAITING_APPROVAL", "false") == "true"
687
- render_only = ctx.get("RENDER_ONLY", "") == "true"
688
- existing_checkpoint = (
689
- existing.get("workflow", {}).get("lastSafeCheckpoint", {})
690
- if isinstance(existing.get("workflow"), dict)
691
- else {}
692
- )
693
- if not isinstance(existing_checkpoint, dict):
694
- existing_checkpoint = {}
695
- if render_only:
696
- last_safe_checkpoint = {
697
- "label": existing_checkpoint.get("label", ""),
698
- "taskManifestPath": existing_checkpoint.get(
699
- "taskManifestPath", ctx.get("TASK_MANIFEST_RELATIVE_PATH", "")
700
- ),
701
- "taskIndexPath": existing_checkpoint.get(
702
- "taskIndexPath", ctx.get("TASK_INDEX_RELATIVE_PATH", "")
703
- ),
704
- "latestRunPath": existing_checkpoint.get(
705
- "latestRunPath", existing.get("latestRunPath", "")
706
- ),
707
- "latestRunManifestPath": existing_checkpoint.get(
708
- "latestRunManifestPath", ""
709
- ),
710
- "latestTeamStatePath": existing_checkpoint.get(
711
- "latestTeamStatePath", existing.get("teamStatePath", "")
712
- ),
713
- "latestReportPath": existing_checkpoint.get(
714
- "latestReportPath", existing.get("latestReportPath", "")
715
- ),
716
- "latestResumeCommandPath": existing_checkpoint.get(
717
- "latestResumeCommandPath", existing.get("latestResumeCommandPath", "")
718
- ),
719
- }
720
- latest_run_relative = existing.get("latestRunPath", "") or ctx.get(
721
- "LATEST_RUN_RELATIVE_PATH", ""
722
- )
723
- latest_run_status = existing.get("latestRunStatus", "") or ctx.get(
724
- "CURRENT_RUN_STATUS", ""
725
- )
726
- latest_run_prompts_relative = existing.get(
727
- "latestRunPromptsPath", ""
728
- ) or ctx.get("RUN_PROMPTS_RELATIVE_PATH", "")
729
- latest_report_relative = (
730
- existing.get("latestReportPath", "") or current_report_relative
731
- )
732
- latest_team_state_relative = existing.get("teamStatePath", "") or ctx.get(
733
- "TEAM_STATE_RELATIVE_PATH", ""
734
- )
735
- latest_resume_command_relative = existing.get("latestResumeCommandPath", "")
736
- else:
737
- last_safe_checkpoint = {
738
- "label": ctx.get("WORKFLOW_LAST_SAFE_CHECKPOINT_LABEL", ""),
739
- "taskManifestPath": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
740
- "taskIndexPath": ctx.get("TASK_INDEX_RELATIVE_PATH", ""),
741
- "latestRunPath": ctx.get("LATEST_RUN_RELATIVE_PATH", ""),
742
- "latestRunManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
743
- "latestTeamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
744
- "latestReportPath": current_report_relative,
745
- "latestResumeCommandPath": ctx.get(
746
- "CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""
747
- ),
748
- }
749
- latest_run_relative = ctx.get("LATEST_RUN_RELATIVE_PATH", "")
750
- latest_run_status = ctx.get("CURRENT_RUN_STATUS", "")
751
- latest_run_prompts_relative = ctx.get("RUN_PROMPTS_RELATIVE_PATH", "")
752
- latest_report_relative = current_report_relative or existing.get(
753
- "latestReportPath", ""
754
- )
755
- latest_team_state_relative = ctx.get("TEAM_STATE_RELATIVE_PATH", "")
756
- latest_resume_command_relative = ctx.get(
757
- "CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""
758
- ) or existing.get("latestResumeCommandPath", "")
906
+ pointers = _derive_latest_pointers(existing, ctx, current_report_relative)
907
+ last_safe_checkpoint = pointers["lastSafeCheckpoint"]
908
+ latest_run_relative = pointers["latestRunPath"]
909
+ latest_run_status = pointers["latestRunStatus"]
910
+ latest_run_prompts_relative = pointers["latestRunPromptsPath"]
911
+ latest_report_relative = pointers["latestReportPath"]
912
+ latest_team_state_relative = pointers["latestTeamStatePath"]
913
+ latest_resume_command_relative = pointers["latestResumeCommandPath"]
759
914
  convergence_block = _build_convergence_block(ctx)
760
915
  payload = {
761
916
  "schemaVersion": "1.0",
@@ -791,7 +946,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
791
946
  "latestResumeCommandPath": latest_resume_command_relative,
792
947
  "teamStatePath": latest_team_state_relative,
793
948
  "workflow": {
794
- "phaseSequence": phase_sequence,
949
+ "phaseSequence": PHASE_SEQUENCE,
795
950
  "currentPhase": current_phase,
796
951
  "currentPhaseState": phase_states.get(current_phase, current_phase_state),
797
952
  "phaseStates": phase_states,
@@ -806,6 +961,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
806
961
  + "/analysis-profile.md",
807
962
  "analysisMaterialPath": ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
808
963
  + "/analysis-material.md",
964
+ "analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
809
965
  "taskBriefCopyPath": ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
810
966
  + "/task-brief.md",
811
967
  "referenceExpectationsPath": ctx.get(
@@ -821,6 +977,7 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
821
977
  ),
822
978
  "resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
823
979
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
980
+ "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
824
981
  "workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
825
982
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
826
983
  },
@@ -1009,6 +1166,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1009
1166
  "expectedReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
1010
1167
  "expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
1011
1168
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
1169
+ "activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
1170
+ "analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
1012
1171
  "workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
1013
1172
  "reportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
1014
1173
  "validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
@@ -9,7 +9,7 @@ the canonical user-facing markdown.
9
9
 
10
10
  Why this exists: prior to v0.32, report-writer-worker wrote the markdown
11
11
  directly. Free-form authoring led to silent contract violations — missing
12
- columns in the Execution Status table, omitted §7 phase-continuation
12
+ columns in the Execution Status table, omitted §4 phase-continuation
13
13
  rows, invented ``## Index`` sections. Routing everything through one
14
14
  template + schema cuts those failure modes to zero.
15
15
 
@@ -17,6 +17,11 @@ Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
17
17
  the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
18
18
  data.json, then re-invokes this renderer so the markdown stays in sync.
19
19
  The markdown is never hand-edited.
20
+
21
+ As of v0.33+, the renderer itself is the schema-enforcement seam: data.json
22
+ is validated against ``schemas/final-report-v1.0.schema.json`` before any
23
+ Jinja2 rendering begins, so schema violations are caught at write-time
24
+ rather than only by the post-hoc ``validators/validate-run.py``.
20
25
  """
21
26
  from __future__ import annotations
22
27
 
@@ -34,6 +39,7 @@ from typing import Any
34
39
  import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
35
40
  from jinja2 import ChainableUndefined, Environment, FileSystemLoader
36
41
 
42
+ from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
37
43
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
38
44
 
39
45
 
@@ -104,6 +110,29 @@ def _yaml_inline_list(values: list[str]) -> str:
104
110
  return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
105
111
 
106
112
 
113
+ def _enforce_schema(data: dict) -> None:
114
+ """렌더 전에 data.json 을 스키마에 대해 검증하는 seam.
115
+
116
+ 스키마 파일을 찾지 못하는 경우(손상된 설치 환경)는 경고만 출력하고 계속
117
+ 진행한다 — validate-run 과 install 경고가 이미 해당 상황을 표면화하므로
118
+ Phase 7 재렌더를 hard-fail 시키는 것은 과도하다.
119
+ """
120
+ try:
121
+ schema = load_schema()
122
+ except SchemaError as exc:
123
+ print(
124
+ f"render-final-report: schema not locatable; skipping schema enforcement ({exc})",
125
+ file=sys.stderr,
126
+ )
127
+ return
128
+ errors = schema_validate(data, schema)
129
+ if errors:
130
+ raise FinalReportRenderError(
131
+ f"final-report data.json fails schema validation ({len(errors)} error(s)): "
132
+ + "; ".join(errors[:5])
133
+ )
134
+
135
+
107
136
  def _build_environment(template_dir: Path) -> Environment:
108
137
  # ChainableUndefined lets optional fields (e.g.
109
138
  # ``clarificationCarryIn``, ``ticketCoverage.omit``) silently evaluate
@@ -161,6 +190,8 @@ def render(
161
190
  if not template_path.is_file():
162
191
  raise FinalReportRenderError(f"template not found: {template_path}")
163
192
 
193
+ _enforce_schema(data)
194
+
164
195
  lang = resolve_report_language(data, override=report_language)
165
196
  try:
166
197
  dictionary = load_dictionary(lang)