okstra 0.219.0 → 0.220.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 (98) hide show
  1. package/README.md +5 -5
  2. package/dist/cli-registry.mjs +18 -10
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/config.mjs +2 -3
  5. package/dist/commands/lifecycle/config.mjs.map +1 -1
  6. package/dist/lib/runtime-payload.mjs +1 -2
  7. package/dist/lib/runtime-payload.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +1 -1
  9. package/docs/architecture.md +9 -7
  10. package/docs/cli.md +5 -3
  11. package/docs/project-structure-overview.md +3 -4
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/okstra-lead-contract.md +5 -4
  16. package/runtime/prompts/lead/report-writer.md +10 -17
  17. package/runtime/prompts/lead/team-contract.md +1 -2
  18. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  19. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  20. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
  21. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
  22. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
  23. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
  24. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
  25. package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
  30. package/runtime/python/okstra_ctl/blocking_checks.py +8 -0
  31. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -2
  32. package/runtime/python/okstra_ctl/correction_cli.py +59 -0
  33. package/runtime/python/okstra_ctl/correction_round.py +168 -0
  34. package/runtime/python/okstra_ctl/dispatch_core.py +21 -349
  35. package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
  36. package/runtime/python/okstra_ctl/domain/provider.py +1 -1
  37. package/runtime/python/okstra_ctl/domain/role.py +0 -6
  38. package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
  39. package/runtime/python/okstra_ctl/i18n.py +2 -2
  40. package/runtime/python/okstra_ctl/path_hints.py +0 -4
  41. package/runtime/python/okstra_ctl/paths.py +0 -3
  42. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -0
  43. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
  44. package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
  45. package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
  46. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +30 -6
  47. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  48. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -0
  49. package/runtime/python/okstra_ctl/phases/implementation/profile.md +2 -1
  50. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +6 -0
  51. package/runtime/python/okstra_ctl/phases/implementation/spec.md +6 -1
  52. package/runtime/python/okstra_ctl/phases/implementation/validation.py +62 -1
  53. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
  54. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
  55. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +9 -9
  56. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
  57. package/runtime/python/okstra_ctl/render.py +0 -10
  58. package/runtime/python/okstra_ctl/render_final_report.py +4 -4
  59. package/runtime/python/okstra_ctl/report_finalize.py +37 -76
  60. package/runtime/python/okstra_ctl/report_html/render.py +4 -44
  61. package/runtime/python/okstra_ctl/report_language.py +3 -3
  62. package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
  63. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  64. package/runtime/python/okstra_ctl/run.py +1 -21
  65. package/runtime/python/okstra_ctl/stage_done.py +183 -0
  66. package/runtime/python/okstra_ctl/stage_fail.py +114 -0
  67. package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
  68. package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
  69. package/runtime/python/okstra_ctl/stage_reopen.py +12 -8
  70. package/runtime/python/okstra_ctl/stage_targets.py +29 -62
  71. package/runtime/python/okstra_ctl/team.py +1 -6
  72. package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
  73. package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
  74. package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
  75. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
  76. package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
  77. package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
  78. package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
  79. package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
  80. package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
  81. package/runtime/schemas/final-report-v3.0.schema.json +78 -0
  82. package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
  83. package/runtime/skills/okstra-discussion/SKILL.md +1 -1
  84. package/runtime/skills/okstra-run/SKILL.md +1 -1
  85. package/runtime/skills/okstra-setup/SKILL.md +2 -2
  86. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  87. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  88. package/runtime/templates/reports/html/i18n/en.json +6 -1
  89. package/runtime/templates/reports/html/i18n/ko.json +6 -1
  90. package/runtime/templates/worker-prompt-preamble.md +1 -1
  91. package/runtime/validators/validate-run.py +3 -28
  92. package/runtime/agents/operations/report-translation.json +0 -6
  93. package/runtime/agents/roles/translator.json +0 -19
  94. package/runtime/bin/okstra-report-translate.py +0 -404
  95. package/runtime/prompts/duties/translator.json +0 -32
  96. package/runtime/python/okstra_ctl/report_translation.py +0 -509
  97. package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
  98. package/runtime/templates/translator-prompt-preamble.md +0 -36
@@ -74,6 +74,11 @@ from .domain.worker_runtime import (
74
74
  )
75
75
  from .ports.worker_runtime import WorkerRuntimePort
76
76
  from .execution_identity import Attempt, ExecutionManifest, Invocation, RoleExecution
77
+ from .correction_round import (
78
+ CorrectionRoundError,
79
+ correction_round,
80
+ validate_correction_dispatch,
81
+ )
77
82
  from .execution_manifest import (
78
83
  ExecutionManifestError,
79
84
  finish_attempt_mutation,
@@ -403,6 +408,7 @@ def build_dispatch_plan(
403
408
  # Ahead of the per-prompt checks: a batch the lead must split is a plan-shape
404
409
  # problem, and reporting a prompt-header error for a doomed batch buries it.
405
410
  _validate_implementation_phase_order(jobs)
411
+ _validate_correction_rounds(manifest_path, jobs)
406
412
  _validate_dispatch_prompts(manifest, active_context, jobs)
407
413
  _reject_stale_schema_excerpt(project_root, manifest, jobs)
408
414
  return DispatchPlan(
@@ -434,6 +440,7 @@ def dispatch_plan(
434
440
  plan = _ensure_runtime_chain(plan)
435
441
  _validate_report_writer_isolation(plan.jobs)
436
442
  _validate_implementation_phase_order(plan.jobs)
443
+ _validate_correction_rounds(plan.manifest_path, plan.jobs)
437
444
  _revalidate_plan_role_executions(plan)
438
445
  pane_backends = sorted(
439
446
  {job.backend for job in plan.jobs if job.backend == BACKEND_CMUX_PANE}
@@ -538,6 +545,7 @@ def dispatch_cli_wrapper_plan(
538
545
  plan = _ensure_runtime_chain(plan)
539
546
  _validate_report_writer_isolation(plan.jobs)
540
547
  _validate_implementation_phase_order(plan.jobs)
548
+ _validate_correction_rounds(plan.manifest_path, plan.jobs)
541
549
  _revalidate_plan_role_executions(plan)
542
550
  if before_start is not None:
543
551
  before_start(plan)
@@ -628,6 +636,16 @@ def _validate_implementation_phase_order(jobs: Sequence[WorkerJob]) -> None:
628
636
  )
629
637
 
630
638
 
639
+ def _validate_correction_rounds(manifest_path: Path, jobs: Sequence[WorkerJob]) -> None:
640
+ """A `correction-r<N>` job runs only in its round order and within the round cap."""
641
+ if not any(correction_round(job.dispatch_kind) is not None for job in jobs):
642
+ return
643
+ try:
644
+ validate_correction_dispatch(read_execution_manifest(manifest_path), jobs)
645
+ except CorrectionRoundError as exc:
646
+ raise DispatchError(str(exc)) from exc
647
+
648
+
631
649
  def _named_role(worker_ids: Sequence[str], role: str) -> str:
632
650
  """`verifier codex` for one, `verifiers codex, antigravity` for several.
633
651
 
@@ -674,7 +692,7 @@ def _dispatch_cli_wrapper_batch(
674
692
  # (`_persisted_write_contract`) attempt 2 의 정책 digest 는 attempt 1 과
675
693
  # 다른데, 첫 라운드의 baseline 을 그대로 쓰면 마감의
676
694
  # `_validate_snapshot_authority` 가 `policyDigests` 불일치로 죽고 attempt-N
677
- # 사이드카는 끝내 안 써진다(실측 2026-09-08, translator attempt 2:
695
+ # 사이드카는 끝내 안 써진다(실측 2026-09-08, 워커 attempt 2:
678
696
  # 산출물 완전·exit 0 인데 dispatcher 예외). 앞 라운드에서 완주한 동료의
679
697
  # 변경은 그 attempt 의 감사가 이미 판정했으므로 새 baseline 에 흡수돼도
680
698
  # 맞다 — 재시도는 `failed-no-mutation` 뒤에만 오므로(`retry_allowed`)
@@ -920,15 +938,6 @@ def _jobs_from_roster(
920
938
  options,
921
939
  team_state=team_state,
922
940
  )
923
- if selected == ["translator"]:
924
- return [_translator_job_from_reservation(
925
- project_root,
926
- workspace_root,
927
- manifest,
928
- team_state,
929
- active_context,
930
- options,
931
- )]
932
941
  _mark_roster_skips(project_root, manifest, team_state, selected, requested_workers, options)
933
942
  worker_facts, prompt_paths = _materialize_roster_prompts(
934
943
  project_root,
@@ -962,29 +971,6 @@ def _select_workers(
962
971
  *,
963
972
  team_state: Mapping[str, Any] | None = None,
964
973
  ) -> list[str]:
965
- # 번역자 디스패치는 roster 를 읽지 않는다 — 근거는 prepare 가 심는 canonical
966
- # translator role execution 이다. 그래서 빈 roster 검사보다 앞에 둔다.
967
- # 종전에는 뒤에 있어서, roster 가 비는 phase 에서 이 분기에 닿을 수 없었다:
968
- # `release-handoff` 는 의도적으로 single-lead 라 `_resolve_roster` 가 빈
969
- # 목록을 강제하는데(`run.py`), `reportLanguage` 가 en 이 아니면 render-views 는
970
- # 번역 사이드카를 요구한다. 두 요구가 겹치면 예약 경로는 "run manifest has no
971
- # recommendedWorkers entries" 로, jobs-file 경로는 "jobs file translator must
972
- # use canonical reservation dispatch" 로 막혀 번역자를 띄울 방법이 없었다
973
- # (실측 2026-09-07, fontsninja-v3-site dev-10626 release-handoff 001 — 리드가
974
- # `okstra report-translate write` 로 우회했고, 그 run 의 매니페스트에는
975
- # translator role execution 과 invocationAssignments.translator 가 둘 다 있다).
976
- if tuple(requested_workers) == ("translator",):
977
- provider = _canonical_translator_provider(manifest)
978
- if provider is None:
979
- raise DispatchError(
980
- "translator dispatch requires a canonical translator role execution"
981
- )
982
- if provider not in options.supported_worker_wrappers:
983
- raise DispatchError(
984
- f"unsupported {options.unsupported_worker_label} translator provider: "
985
- f"{provider}"
986
- )
987
- return ["translator"]
988
974
  recommended = _string_list(manifest.get("recommendedWorkers"))
989
975
  if not recommended:
990
976
  raise DispatchError("run manifest has no recommendedWorkers entries")
@@ -1027,270 +1013,6 @@ def _select_workers(
1027
1013
  return selected
1028
1014
 
1029
1015
 
1030
- def _canonical_translator_provider(manifest: Mapping[str, Any]) -> str | None:
1031
- execution = _canonical_translator_execution(manifest)
1032
- return _string_value(execution.get("provider")) if execution else None
1033
-
1034
-
1035
- def _canonical_translator_execution(
1036
- manifest: Mapping[str, Any],
1037
- ) -> Mapping[str, Any] | None:
1038
- assignments = manifest.get("invocationAssignments")
1039
- assignment = (
1040
- assignments.get("translator") if isinstance(assignments, Mapping) else None
1041
- )
1042
- if not isinstance(assignment, Mapping):
1043
- return None
1044
- provider = _string_value(assignment.get("provider"))
1045
- model_execution = _string_value(assignment.get("modelExecutionValue"))
1046
- runner = _string_value(assignment.get("runner"))
1047
- if not provider or not model_execution or runner != BACKEND_CLI_WRAPPER:
1048
- return None
1049
- role_executions = manifest.get("roleExecutions")
1050
- matches = [
1051
- row
1052
- for row in role_executions
1053
- if isinstance(row, Mapping)
1054
- and row.get("role") == "translator"
1055
- and row.get("provider") == provider
1056
- and isinstance(row.get("binding"), Mapping)
1057
- and row["binding"].get("runner") == runner
1058
- and row["binding"].get("resolvedExecutionValue") == model_execution
1059
- ] if isinstance(role_executions, list) else []
1060
- return matches[0] if len(matches) == 1 else None
1061
-
1062
-
1063
- _TRANSLATOR_RESERVATION_KEYS = {
1064
- "schemaVersion", "executionIdentityVersion", "invocationId",
1065
- "assignmentRef", "audience", "dispatchKind", "promptPath",
1066
- "metadataPath", "invocationRef", "participantRef",
1067
- "roleExecutionRef", "dutyId", "attempt",
1068
- }
1069
-
1070
-
1071
- def _translator_job_from_reservation(
1072
- project_root: Path,
1073
- workspace_root: Path,
1074
- manifest: Mapping[str, Any],
1075
- team_state: Mapping[str, Any],
1076
- active_context: Mapping[str, Any],
1077
- options: _BuildOptions,
1078
- ) -> WorkerJob:
1079
- execution = _canonical_translator_execution(manifest)
1080
- if execution is None:
1081
- raise DispatchError(
1082
- "translator dispatch requires a canonical translator role execution"
1083
- )
1084
- candidates, seen = translator_reservations(project_root, manifest, team_state)
1085
- run_manifest_rel = _string_value(manifest.get("runManifestPath"))
1086
- if len(candidates) != 1:
1087
- run_label = run_manifest_rel or "run manifest path unknown"
1088
- raise DispatchError(
1089
- "translator dispatch requires exactly one canonical invocation "
1090
- f"reservation for this run ({run_label}); found {len(candidates)}. "
1091
- "Translator reservations seen: " + ("; ".join(seen) if seen else "none")
1092
- )
1093
- reservation = candidates[0]
1094
- prompt_path = _resolve_project_path(
1095
- project_root, _require_string(reservation, "promptPath")
1096
- )
1097
- metadata_path = _resolve_project_path(
1098
- project_root, _require_string(reservation, "metadataPath")
1099
- )
1100
- expected_metadata = prompt_path.with_name(prompt_path.name + ".meta.json")
1101
- if metadata_path != expected_metadata:
1102
- raise DispatchError("translator invocation metadata path is not prompt-adjacent")
1103
- metadata = _load_json_object(metadata_path, "agent invocation metadata")
1104
- invocation = _invocation_job_fields(metadata, prompt_path, manifest)
1105
- expected = {
1106
- "invocationId": invocation["invocation_id"],
1107
- "invocationRef": invocation["invocation_ref"],
1108
- "participantRef": invocation["participant_ref"],
1109
- "roleExecutionRef": invocation["role_execution_ref"],
1110
- "dutyId": invocation["duty_id"],
1111
- "attempt": invocation["attempt"],
1112
- }
1113
- if any(reservation.get(key) != value for key, value in expected.items()):
1114
- raise DispatchError("translator reservation and invocation metadata differ")
1115
- if (
1116
- invocation["audience"] != "translator"
1117
- or invocation["assignment_ref"] != "translator"
1118
- or metadata.get("dispatchKind") != "translator"
1119
- ):
1120
- raise DispatchError("translator invocation identity is invalid")
1121
- result_path, worker_result_path = _translator_prompt_outputs(
1122
- project_root, prompt_path
1123
- )
1124
- if result_path == worker_result_path:
1125
- raise DispatchError(
1126
- "translator result and standard worker result paths must differ"
1127
- )
1128
- provider = _require_string(execution, "provider")
1129
- binding = execution.get("binding")
1130
- if not isinstance(binding, Mapping):
1131
- raise DispatchError("translator role execution has no binding")
1132
- return WorkerJob(
1133
- worker_id="translator",
1134
- provider=provider,
1135
- backend=options.default_backend,
1136
- project_root=project_root,
1137
- model_execution_value=_require_string(binding, "resolvedExecutionValue"),
1138
- wrapper_path=_resolve_wrapper(provider, workspace_root, options),
1139
- prompt_path=prompt_path,
1140
- result_path=result_path,
1141
- worker_result_path=worker_result_path,
1142
- completion_paths=(result_path, worker_result_path),
1143
- worktree_path=_worktree_path(manifest, active_context),
1144
- role="translator",
1145
- idle_timeout_seconds=options.idle_timeout_seconds,
1146
- dispatch_kind="translator",
1147
- **invocation,
1148
- )
1149
-
1150
-
1151
- def translator_reservations(
1152
- project_root: Path,
1153
- manifest: Mapping[str, Any],
1154
- team_state: Mapping[str, Any],
1155
- ) -> tuple[list[Mapping[str, Any]], list[str]]:
1156
- """이 run 이 지금 디스패치할 수 있는 translator 예약과, 본 예약 전부의 요약.
1157
-
1158
- 두 소비자가 같은 답을 읽는다: `_translator_job_from_reservation` 은 그 하나를
1159
- 골라 띄우고, `report-finalize` 의 `translate` 단계
1160
- (`okstra_ctl.report_translation_dispatch`) 는 0건이면 예약을 만든다. 후보를
1161
- 세는 규칙이 두 곳이면 한쪽만 고쳐져 "만들었는데 못 고른다" 가 나온다.
1162
- """
1163
- execution = _canonical_translator_execution(manifest)
1164
- if execution is None:
1165
- raise DispatchError(
1166
- "translator dispatch requires a canonical translator role execution"
1167
- )
1168
- contract = manifest.get("agentContract")
1169
- if not isinstance(contract, Mapping):
1170
- raise DispatchError("translator dispatch requires an agent contract")
1171
- reservation_root = _resolve_required_path(
1172
- project_root, contract, "invocationReservationRootPath"
1173
- )
1174
- dispatched_ids = {
1175
- str(row.get("dispatchId") or "")
1176
- for collection in (
1177
- team_state.get("workerDispatches") or [],
1178
- team_state.get("agentDispatches") or [],
1179
- )
1180
- for row in collection
1181
- if isinstance(row, Mapping)
1182
- }
1183
- return _translator_reservation_candidates(
1184
- project_root,
1185
- reservation_root,
1186
- execution=execution,
1187
- dispatched_ids=dispatched_ids,
1188
- run_manifest_rel=_string_value(manifest.get("runManifestPath")),
1189
- )
1190
-
1191
-
1192
- def _translator_reservation_candidates(
1193
- project_root: Path,
1194
- reservation_root: Path,
1195
- *,
1196
- execution: Mapping[str, Any],
1197
- dispatched_ids: set[str],
1198
- run_manifest_rel: str,
1199
- ) -> tuple[list[Mapping[str, Any]], list[str]]:
1200
- """이 run 이 디스패치할 수 있는 translator 예약과, 본 예약 전부의 요약.
1201
-
1202
- 예약 디렉터리(`prompts/.agent-invocations/`)는 run 이 아니라 task type 단위라
1203
- 같은 task 의 이전 run 이 남긴 translator 예약이 함께 놓인다. translator 는
1204
- `assignmentRef` 가 접미 없는 `translator` 하나이고, 이전 run 의 디스패치는
1205
- 이번 run 의 team-state 에 없으므로 "아직 디스패치 안 됨" 으로 읽혀 후보가
1206
- 둘이 됐다 — 두 번째 run 부터 번역이 영영 막혔다(2026-09-03 실측,
1207
- fontsninja-v3-site dev-10626-1 r02·r04). 예약 자체는 run 을 모르지만 그 옆의
1208
- `.meta.json` 은 `contractSource.runManifestPath` 로 자기 run 을 안다. 그
1209
- 값을 이번 run 의 `runManifestPath` 와 대조한다. 매니페스트가 그 경로를 안
1210
- 가진 레거시 run 은 종전대로 run 을 가리지 않는다.
1211
- """
1212
- candidates: list[Mapping[str, Any]] = []
1213
- seen: list[str] = []
1214
- for path in sorted(reservation_root.glob("*.json")):
1215
- reservation = _load_json_object(path, "agent invocation reservation")
1216
- if not (
1217
- set(reservation) == _TRANSLATOR_RESERVATION_KEYS
1218
- and reservation.get("schemaVersion") == "2.0"
1219
- and reservation.get("executionIdentityVersion") == 2
1220
- and reservation.get("assignmentRef") == "translator"
1221
- and reservation.get("audience") == "translator"
1222
- and reservation.get("dispatchKind") == "translator"
1223
- and reservation.get("dutyId") == "translator"
1224
- ):
1225
- continue
1226
- invocation_id = str(reservation.get("invocationId") or "")
1227
- reservation_run = _reservation_run_manifest_rel(project_root, reservation)
1228
- dispatched = _build_dispatch_id(
1229
- invocation_id, int(reservation.get("attempt") or 0),
1230
- ) in dispatched_ids
1231
- seen.append(
1232
- f"{invocation_id} (run {reservation_run or 'unknown'}"
1233
- + (", dispatched" if dispatched else "")
1234
- + ")"
1235
- )
1236
- if dispatched:
1237
- continue
1238
- if (
1239
- reservation.get("participantRef") != execution.get("participantRef")
1240
- or reservation.get("roleExecutionRef") != execution.get("roleExecutionRef")
1241
- ):
1242
- continue
1243
- if run_manifest_rel and (
1244
- reservation_run is None
1245
- or os.path.normpath(reservation_run) != os.path.normpath(run_manifest_rel)
1246
- ):
1247
- continue
1248
- candidates.append(reservation)
1249
- return candidates, seen
1250
-
1251
-
1252
- def _reservation_run_manifest_rel(
1253
- project_root: Path, reservation: Mapping[str, Any],
1254
- ) -> str | None:
1255
- """예약 옆 `.meta.json` 의 `contractSource.runManifestPath`. 못 읽으면 None."""
1256
- metadata_value = _string_value(reservation.get("metadataPath"))
1257
- if not metadata_value:
1258
- return None
1259
- try:
1260
- metadata = _load_json_object(
1261
- _resolve_project_path(project_root, metadata_value),
1262
- "agent invocation metadata",
1263
- )
1264
- except DispatchError:
1265
- return None
1266
- source = metadata.get("contractSource")
1267
- if not isinstance(source, Mapping):
1268
- return None
1269
- return _string_value(source.get("runManifestPath")) or None
1270
-
1271
-
1272
- def _translator_prompt_outputs(
1273
- project_root: Path, prompt_path: Path,
1274
- ) -> tuple[Path, Path]:
1275
- try:
1276
- lines = prompt_path.read_text(encoding="utf-8").splitlines()
1277
- except OSError as exc:
1278
- raise DispatchError(f"cannot read translator prompt: {exc}") from exc
1279
- values: list[str] = []
1280
- for header in ("Result Path", "Worker Result Path"):
1281
- prefix = f"**{header}:** "
1282
- matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)]
1283
- if len(matches) != 1 or not matches[0]:
1284
- raise DispatchError(
1285
- f"translator prompt requires exactly one non-empty {prefix.strip()} header"
1286
- )
1287
- values.append(matches[0])
1288
- return (
1289
- _resolve_project_path(project_root, values[0]),
1290
- _resolve_project_path(project_root, values[1]),
1291
- )
1292
-
1293
-
1294
1016
  def _select_cli_wrapper_assignments(
1295
1017
  recommended: Sequence[str],
1296
1018
  requested_workers: Sequence[str],
@@ -2878,27 +2600,6 @@ def _wrapper_log_tail(job: WorkerJob) -> str | None:
2878
2600
  return tail.decode("utf-8", errors="replace").strip() or None
2879
2601
 
2880
2602
 
2881
- def _projectionless_translator(plan: DispatchPlan, job: WorkerJob) -> bool:
2882
- if not (
2883
- job.has_execution_identity
2884
- and job.role == "translator"
2885
- and job.worker_id == "translator"
2886
- and job.audience == "translator"
2887
- and job.assignment_ref == "translator"
2888
- and job.duty_id == "translator"
2889
- and job.dispatch_kind == "translator"
2890
- ):
2891
- return False
2892
- execution = _canonical_translator_execution(plan.manifest)
2893
- return bool(
2894
- execution
2895
- and execution.get("roleExecutionRef") == job.role_execution_ref
2896
- and execution.get("participantRef") == job.participant_ref
2897
- and execution.get("executionLabel") == job.execution_label
2898
- and execution.get("provider") == job.provider
2899
- )
2900
-
2901
-
2902
2603
  def _transition_job_status(
2903
2604
  plan: DispatchPlan,
2904
2605
  job: WorkerJob,
@@ -2907,24 +2608,6 @@ def _transition_job_status(
2907
2608
  *,
2908
2609
  model_execution_value: str = "",
2909
2610
  ) -> None:
2910
- if not _projectionless_translator(plan, job):
2911
- _transition_worker_status(
2912
- plan.team_state_path,
2913
- job.worker_id,
2914
- status,
2915
- reason,
2916
- model_execution_value=model_execution_value,
2917
- )
2918
- return
2919
- team_state = _load_json_object(plan.team_state_path, "team-state")
2920
- workers = team_state.get("workers")
2921
- if not isinstance(workers, list):
2922
- raise DispatchError("team-state workers must be an array")
2923
- if not any(
2924
- isinstance(row, Mapping) and row.get("workerId") == job.worker_id
2925
- for row in workers
2926
- ):
2927
- return
2928
2611
  _transition_worker_status(
2929
2612
  plan.team_state_path,
2930
2613
  job.worker_id,
@@ -2945,20 +2628,9 @@ def _job_terminal_status(
2945
2628
  row for row in workers
2946
2629
  if isinstance(row, Mapping) and row.get("workerId") == job.worker_id
2947
2630
  ]
2948
- if len(matches) == 1:
2949
- return _string_value(matches[0].get("status"))
2950
- if matches or not _projectionless_translator(plan, job):
2631
+ if len(matches) != 1:
2951
2632
  raise DispatchError(f"team-state has no unique workerId={job.worker_id}")
2952
- dispatches = team_state.get("workerDispatches")
2953
- records = [
2954
- row for row in dispatches
2955
- if isinstance(row, Mapping)
2956
- and row.get("promptPath") == str(job.prompt_path)
2957
- and row.get("attempt") == attempt
2958
- ] if isinstance(dispatches, list) else []
2959
- if len(records) != 1:
2960
- raise DispatchError("translator dispatch has no unique status record")
2961
- return _string_value(records[0].get("status"))
2633
+ return _string_value(matches[0].get("status"))
2962
2634
 
2963
2635
 
2964
2636
  def _record_dispatch(
@@ -2221,19 +2221,6 @@ def _validate_v2_jobs_file_authority(
2221
2221
  )
2222
2222
  if errors:
2223
2223
  raise DispatchError("jobs file v2 authority: " + "; ".join(errors))
2224
- role_executions = {
2225
- string_value(row.get("roleExecutionRef")): row
2226
- for row in manifest.get("roleExecutions") or []
2227
- if isinstance(row, Mapping)
2228
- }
2229
- if any(
2230
- isinstance(role_executions.get(job.role_execution_ref), Mapping)
2231
- and role_executions[job.role_execution_ref].get("role") == "translator"
2232
- for job in jobs
2233
- ):
2234
- raise DispatchError(
2235
- "jobs file translator must use canonical reservation dispatch"
2236
- )
2237
2224
 
2238
2225
 
2239
2226
  def worker_jobs_from_file(
@@ -275,6 +275,6 @@ def _legacy_execution_capabilities(roles: frozenset[str]) -> frozenset[str]:
275
275
  capabilities.add("source-readonly")
276
276
  if role == "implementer":
277
277
  capabilities.add("project-mutation")
278
- if role in {"report-writer", "translator"}:
278
+ if role == "report-writer":
279
279
  capabilities.add("extended-artifact-authoring")
280
280
  return frozenset(capabilities)
@@ -35,11 +35,6 @@ ROLE_DEFINITIONS = (
35
35
  (),
36
36
  _READONLY_CAPABILITIES | frozenset({"extended-artifact-authoring"}),
37
37
  ),
38
- RoleDefinition(
39
- "translator",
40
- (),
41
- _READONLY_CAPABILITIES | frozenset({"extended-artifact-authoring"}),
42
- ),
43
38
  )
44
39
 
45
40
  DUTY_ROLE_IDS = {
@@ -61,7 +56,6 @@ DUTY_ROLE_IDS = {
61
56
  "schedule-verifier": "verifier",
62
57
  "business-flow-investigator": "analyser",
63
58
  "report-writer": "report-writer",
64
- "translator": "translator",
65
59
  }
66
60
 
67
61
 
@@ -6,7 +6,6 @@ from pathlib import Path
6
6
 
7
7
  DATA_JSON_SUFFIX = ".data.json"
8
8
  MARKDOWN_SUFFIX = ".md"
9
- TRANSLATION_SOURCE_SUFFIX = ".translation-source.json"
10
9
 
11
10
 
12
11
  def _stem(data_path: Path) -> str:
@@ -106,21 +105,6 @@ def timeline_report_record_rel(run: dict) -> str:
106
105
  return report_record_rel_from_legacy_pointer(str(run.get("reportPath") or ""))
107
106
 
108
107
 
109
- def translation_source_path(data_path: Path) -> Path:
110
- """Return the translator's work list for a final-report data.json path."""
111
- return data_path.with_name(_stem(data_path) + TRANSLATION_SOURCE_SUFFIX)
112
-
113
-
114
- def translation_sidecar_path(data_path: Path, lang: str) -> Path:
115
- """Return the filled translation sidecar the HTML renderer overlays.
116
-
117
- One file per language beside the report it translates, so the renderer
118
- finds it from the data.json alone and a run for another language does not
119
- overwrite it.
120
- """
121
- return data_path.with_name(f"{_stem(data_path)}.i18n.{lang}.json")
122
-
123
-
124
108
  def sidecar_source_rel(path: Path) -> str:
125
109
  """사이드카 프론트매터가 `source-report` / `source-data` 에 적는 경로.
126
110
 
@@ -47,8 +47,8 @@ def load_dictionary(lang: str, rel: tuple[str, ...] = DICTIONARY_REL) -> dict[st
47
47
  """`<lang>.json` 이 있으면 그것, 없으면 영어 사전.
48
48
 
49
49
  사전은 페이지 크롬(표 머리글·빈 상태·enum 라벨)의 고정 문자열이고 본문은
50
- 번역 사이드카가 덮는다. 사전이 없는 언어의 리포트는 본문은 그 언어, 크롬은
51
- 영어로 나온다 — 사전이 없다고 렌더를 거절하면 독자는 아무것도 못 받는다.
50
+ report-writer 가 리포트 언어로 쓴다. 사전이 없는 언어의 리포트는 본문은 그
51
+ 언어, 크롬은 영어로 나온다 — 사전이 없다고 렌더를 거절하면 독자는 아무것도 못 받는다.
52
52
  """
53
53
  directory = _i18n_dir(rel)
54
54
  path = directory / f"{lang}.json"
@@ -119,7 +119,6 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
119
119
  "IMPLEMENTATION_WORKER_PREAMBLE_PATH", ""
120
120
  ),
121
121
  "report-writer": ctx.get("REPORT_WRITER_PREAMBLE_PATH", ""),
122
- "translator": ctx.get("TRANSLATOR_PREAMBLE_PATH", ""),
123
122
  },
124
123
  "workerErrorContractPath": ctx.get("WORKER_ERROR_CONTRACT_PATH", ""),
125
124
  "hostSessionContractPath": (
@@ -515,9 +514,6 @@ def _runtime_fields(paths: Mapping[str, Any]) -> dict[str, str]:
515
514
  "REPORT_WRITER_PREAMBLE_PATH": str(
516
515
  runtime_home / "templates" / "report-writer-prompt-preamble.md"
517
516
  ),
518
- "TRANSLATOR_PREAMBLE_PATH": str(
519
- runtime_home / "templates" / "translator-prompt-preamble.md"
520
- ),
521
517
  "WORKER_ERROR_CONTRACT_PATH": str(
522
518
  runtime_home / "templates" / "worker-error-contract.md"
523
519
  ),
@@ -663,9 +663,6 @@ def compute_run_paths(
663
663
  "REPORT_WRITER_PREAMBLE_PATH": str(
664
664
  runtime_home / "templates" / "report-writer-prompt-preamble.md"
665
665
  ),
666
- "TRANSLATOR_PREAMBLE_PATH": str(
667
- runtime_home / "templates" / "translator-prompt-preamble.md"
668
- ),
669
666
  "WORKER_ERROR_CONTRACT_PATH": str(
670
667
  runtime_home / "templates" / "worker-error-contract.md"
671
668
  ),
@@ -26,6 +26,7 @@
26
26
  - **whole-task mutates only when it has to.** When one stage branch already contains every other done stage's commit — the usual shape of a linear plan, since each stage branches from its predecessor's done commit — that branch IS the whole task, and verification runs in that stage's worktree with no merge at all (`stage_targets.containing_stage`). Only when the stage graph has several tips does entry auto-merge (with `--no-ff`) the done stages into the task branch to create an integration commit; that case is the mutating one. The stage worktrees are NOT removed on entry: they are reclaimed after the verdict, by the Phase 7 `teardown-stages` step, and only when the verdict clears the work for release (`accepted`, or `conditional-accept` with no condition blocking release). A `blocked` verdict therefore leaves every stage worktree in place, so the rework it routes to can start immediately. The stage branches are kept as the reviewable stack (the target of `okstra handoff local-checkout --stage <N>`). If a merge conflict occurs it reports the conflicting files and aborts (the user resolves them manually and retries). A stage worktree with uncommitted changes remaining is preserved. Therefore the "fully-merged, clean target" the entry gate above refers to is the state after this auto-integration step completes, and whole-task final-verification must be treated as a mutating phase that creates the integration commit.
27
27
  - **single-stage scope** (`--stage N`): prep verified stage N is `status:done` and its isolated stage worktree exists and is clean. Other stages' state is irrelevant. A single-stage run is a partial verification of one stage, and that is exactly the unit release-handoff ships: an `accepted` verdict here makes the stage PR-eligible, so `release-handoff` is the routing target. It says nothing about any other stage.
28
28
  - the lead still captures `git status --short` from the injected worktree to confirm the analysis ran against the delivered work-tree state; an unexpected divergence (dirty tree outside `.okstra/`, missing worktree) is a `tool-failure`, not a silent proceed.
29
+ - **Stage worktree lifecycle:** implementation finalization may already have removed a completed stage's directory. Preparation restores the preserved stage branch for single-stage verification, or the containing tip branch for a linear whole-task target. After successful report validation, a release-ready single-stage verdict reclaims that stage's clean worktree; a whole-task verdict reclaims completed stages without any cleanup-time merge. Blocked, active, dirty, nested, or changed stages remain available for rework. The branch and registry base survive cleanup. After finalization use the main project root or absolute artifact paths because the verification cwd may have been removed. Enforcement: `report_finalize._teardown_stage_worktrees`, `stage_targets.teardown_completed_stage_worktrees`, and `tests/worktree/test_stage_rebase.py`.
29
30
  - Worker verification procedure:
30
31
  - **Target confirmation:** analyse the injected target and nothing else. Read `verification-target.md` for the stage/report mapping and the complete diff stat. Prepare fixed that target and `phases/final_verification/validation.py` `validate_verification_target_match` (called by `validators/validate-run.py`) re-checks the report against its digest, so the procedure to follow here is simply: if the worktree you can see does not match the injected target, record a `tool-failure` — never reselect a target.
31
32
  - **Evidence:** attach file:line, exact command + exit code, log excerpt, or MCP SELECT evidence to every finding. Mark a requirement as covered only when the cited artifact demonstrates it.
@@ -184,6 +184,8 @@ flowchart TD
184
184
 
185
185
  The stage merge of whole-task mode is a runtime-owned integration step that prepare performs; the matching teardown is a runtime-owned step too, but it runs after the verdict (Phase 7 `teardown-stages`) and only when the verdict clears the work for release. After that, lead verification is read-only. Source edit, follow-up fix, and scope expansion are all forbidden. When a defect is found, it is not fixed within the current run. The lead chooses the next phase from `prompts/lead/phase-routing.md` using the blocker facts this phase recorded.
186
186
 
187
+ Implementation finalization may already have reclaimed completed stage worktrees. Target acquisition restores a missing single-stage worktree, or the containing tip-stage worktree for a linear whole-task target, from its preserved branch. Restoration does not merge into the task branch. After a release-ready single-stage verdict, `report-finalize` also reclaims that stage's worktree using the captured verification head; a blocked verdict keeps the restored worktree for rework. Cleanup never merges stages: `stage_targets.teardown_completed_stage_worktrees` only reclaims completed, inactive worktrees whose tips match the selected completion commits. Tests: `tests/worktree/test_stage_rebase.py`.
188
+
187
189
  ### 8. Verified code
188
190
 
189
191
  - `scripts/okstra_ctl/phases/final_verification/profile.md`
@@ -1,6 +1,6 @@
1
1
  """final-verification 이 검증할 대상을 고른다.
2
2
 
3
- 단일 stage 검증은 그 stage 의 worktree 를 읽기만 한다. 전체 작업 검증은 완료된
3
+ 검증 대상 고정 전에 이동한 의존 기준을 재배치한다. 전체 작업 검증은 완료된
4
4
  stage 전부를 담은 stage 브랜치가 있으면 그것을 쓰고, 없으면 task 브랜치에 통합한
5
5
  결과를 쓴다. 원장·registry·Git·통합은 공통 모듈이 제공하고, 여기서는 어느 것을
6
6
  언제 부를지만 정한다. 전체 과정은 task 키 잠금 하나 안에서 돈다.
@@ -115,6 +115,14 @@ def _read_final_verification_done_rows(
115
115
  ),
116
116
  plan_run_root,
117
117
  )
118
+ from okstra_ctl.stage_rebase import synchronize_stage_stack
119
+ from okstra_ctl.stage_targets import StageWorktreeIdentity
120
+
121
+ synchronize_stage_stack(
122
+ project_root=request.project_root, plan_run_root=plan_run_root,
123
+ stage_map=list(request.stage_map),
124
+ identity=StageWorktreeIdentity(project_id, task_group, task_id, request.work_category),
125
+ )
118
126
  return read_stage_consumer_state(plan_run_root).done_rows
119
127
 
120
128
 
@@ -141,6 +149,10 @@ def _acquire_single_stage_target(
141
149
  assert stage is not None
142
150
  row = worktree_registry.get_stage_row(*registry_coordinates, stage)
143
151
  worktree_path = (row or {}).get("worktree_path", "")
152
+ if worktree_path and not Path(worktree_path).exists():
153
+ from okstra_ctl.worktree.provision import restore_stage_worktree
154
+
155
+ restore_stage_worktree(request.project_root, worktree_path, row["branch"])
144
156
  head = ""
145
157
  if worktree_path and Path(worktree_path).is_dir():
146
158
  head_result = _git(worktree_path, "rev-parse", "HEAD")
@@ -183,6 +195,10 @@ def _acquire_tip_stage_target(
183
195
  return None
184
196
  row = worktree_registry.get_stage_row(*registry_coordinates, tip) or {}
185
197
  worktree_path = row.get("worktree_path", "")
198
+ if worktree_path and not Path(worktree_path).exists():
199
+ from okstra_ctl.worktree.provision import restore_stage_worktree
200
+
201
+ restore_stage_worktree(request.project_root, worktree_path, row["branch"])
186
202
  if not worktree_path or not Path(worktree_path).is_dir():
187
203
  return None
188
204
  head_result = _git(worktree_path, "rev-parse", "HEAD")
@@ -95,6 +95,18 @@ def claim_implementation_stage_run(
95
95
  plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
96
96
  backfill_done_from_carry(plan_run_root)
97
97
  auto_reconcile_best_effort(inp, plan_run_root)
98
+ from okstra_ctl.stage_rebase import synchronize_stage_stack
99
+
100
+ try:
101
+ synchronize_stage_stack(
102
+ project_root=Path(inp.project_root), plan_run_root=plan_run_root,
103
+ stage_map=ctx_stage_map,
104
+ identity=stage_targets.StageWorktreeIdentity(
105
+ inp.project_id, task_group_segment, task_id_segment, inp.work_category,
106
+ ),
107
+ )
108
+ except RuntimeError as exc:
109
+ raise ImplementationStageError(str(exc)) from exc
98
110
  reserved_stages = _reg.list_active_stage_numbers(
99
111
  inp.project_id,
100
112
  inp.task_group,