okstra 0.50.0 → 0.52.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 (61) 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 +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +15 -11
  11. package/runtime/agents/workers/claude-worker.md +3 -3
  12. package/runtime/agents/workers/codex-worker.md +2 -2
  13. package/runtime/agents/workers/gemini-worker.md +2 -2
  14. package/runtime/bin/lib/okstra/cli.sh +8 -1
  15. package/runtime/bin/lib/okstra/globals.sh +3 -0
  16. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  17. package/runtime/bin/lib/okstra/usage.sh +6 -0
  18. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  19. package/runtime/bin/okstra.sh +2 -0
  20. package/runtime/prompts/launch.template.md +3 -1
  21. package/runtime/prompts/profiles/_common-contract.md +4 -4
  22. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  23. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  24. package/runtime/prompts/profiles/implementation-planning.md +8 -4
  25. package/runtime/prompts/profiles/implementation.md +1 -1
  26. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  27. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  28. package/runtime/python/okstra_ctl/migrate.py +2 -12
  29. package/runtime/python/okstra_ctl/paths.py +22 -0
  30. package/runtime/python/okstra_ctl/render.py +284 -125
  31. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  32. package/runtime/python/okstra_ctl/run.py +507 -245
  33. package/runtime/python/okstra_ctl/sequence.py +2 -5
  34. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  35. package/runtime/python/okstra_ctl/wizard.py +129 -133
  36. package/runtime/python/okstra_ctl/worktree.py +13 -5
  37. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  38. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  39. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  40. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  47. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  48. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  49. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  50. package/runtime/skills/okstra-memory/SKILL.md +28 -5
  51. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  52. package/runtime/skills/okstra-run/SKILL.md +1 -1
  53. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  54. package/runtime/templates/reports/final-report.template.md +1 -0
  55. package/runtime/templates/worker-prompt-preamble.md +3 -3
  56. package/runtime/validators/validate-implementation-plan-stages.py +57 -11
  57. package/src/_python-helper.mjs +3 -3
  58. package/src/context-cost.mjs +27 -0
  59. package/src/install.mjs +1 -0
  60. package/src/memory.mjs +50 -11
  61. package/src/uninstall.mjs +1 -0
@@ -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.
@@ -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", ""),
@@ -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)