okstra 0.191.2 → 0.192.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 (29) hide show
  1. package/docs/architecture.md +2 -2
  2. package/docs/cli.md +2 -1
  3. package/docs/project-structure-overview.md +3 -1
  4. package/docs/task-process/README.md +2 -2
  5. package/docs/task-process/common-flow.md +4 -5
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/translator-worker.md +1 -1
  9. package/runtime/prompts/launch.template.md +1 -1
  10. package/runtime/prompts/lead/convergence.md +7 -3
  11. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  12. package/runtime/prompts/lead/report-writer.md +11 -8
  13. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  14. package/runtime/python/okstra_ctl/convergence.py +63 -1
  15. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
  16. package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
  17. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  18. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  19. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  20. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  21. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  22. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  23. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  24. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  25. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  26. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  27. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  28. package/runtime/skills/okstra-run/SKILL.md +2 -2
  29. package/runtime/validators/validate-run.py +2 -2
@@ -0,0 +1,231 @@
1
+ """Reverify 지시문을 수렴 상태와 라운드 계획에서 결정적으로 렌더한다.
2
+
3
+ Phase 5.5 의 재검증 워커는 리드가 손으로 쓴 지시문을 받아 왔다. 그 손에서 두
4
+ 가지가 반복해서 어긋났다(실측 2026-09-09, 다른 세션의 첫 reverify 보고).
5
+
6
+ - 응답 형식 — 리드가 `- Verdict:` 불릿을 적었고 워커는 시킨 대로 썼다. 파서는
7
+ 이제 그 모양도 읽지만(`verdict_blocks._field_match`), 형식 블록을 리드가 매
8
+ 라운드 다시 쓰는 구조 자체가 결함이다.
9
+ - 근거 축약 — 리드가 `**Cited evidence**` 줄에 원 워커가 인용한 근거의 일부만
10
+ 옮겨 적었다. 검증자는 계약대로 그 줄만 열었고, `burden-not-met` 5건은 주장을
11
+ 검증한 것이 아니라 리드의 전사(轉寫)를 검증한 것이었다.
12
+
13
+ 이 모듈은 그 지시문을 코드로 만든다. finding 마다 그룹의 요약·원 워커·인용
14
+ 근거 줄에 더해, **원 워커의 결과 파일과 그 안의 항목 id, 그리고 원 워커의 감사
15
+ 사이드카**(실행한 읽기 전용 명령과 출력이 기록된 파일)를 싣고, 그 둘을 열어도
16
+ 된다고 명시한다. 검증자는 리드의 요약이 아니라 원 워커가 실제로 인용한 것을
17
+ 판단한다. 응답 형식은 `verdict_blocks` 가 읽는 정본 모양 그대로다.
18
+
19
+ 산출물은 프롬프트 materializer 의 `--instruction` 이 받는 본문이다. `## Instructions`
20
+ 로 시작하므로 `complete_reverify_instruction` 이 모델·task type·금지 목록을 그
21
+ 앞에 붙이고, 출력 계약(`templates/reverify-output-contract.md`)을 뒤에 덧붙인다.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Mapping, Sequence
28
+
29
+ from .convergence_critic_prompt import analyser_results
30
+ from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
31
+
32
+
33
+ class ReverifyPromptError(ValueError):
34
+ """지시문을 결정적으로 만들 수 없다."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ReverifyFinding:
39
+ """검증 큐의 finding 하나와, 그 원 워커의 실물 인용 위치."""
40
+
41
+ finding_id: str
42
+ summary: str
43
+ origin_worker: str
44
+ origin_item_id: str
45
+ origin_evidence: str
46
+ origin_result_path: str
47
+ origin_audit_path: str
48
+
49
+
50
+ _ADVERSARIAL_MANDATE = """Your job is to BREAK each finding below, not to confirm it. For EACH finding,
51
+ open the cited evidence directly and actively search for evidence that the claim
52
+ is wrong, overstated, or unproven. Then respond with exactly one verdict:
53
+
54
+ - **REFUTED**: You broke the claim. State the basis:
55
+ - counter-evidence — you found contradicting evidence (give file:line or log line), OR
56
+ - burden-not-met — you re-inspected the cited evidence and could neither confirm
57
+ nor refute it (the claim has not proven itself).
58
+ - **SURVIVES**: You actively tried to refute it and failed — the claim withstood the attack.
59
+ - **SURVIVES-WITH-CAVEAT**: It holds, but a scope limit / extra condition / missing
60
+ precondition exists (state it).
61
+ - **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
62
+ from opening or reproducing the cited evidence. Do not use REFUTED as a substitute.
63
+
64
+ The burden of proof is on the claim. If after inspecting the cited evidence you remain
65
+ uncertain, your verdict is REFUTED with basis = burden-not-met.
66
+
67
+ Inspect ONLY the evidence each finding cites and its immediate surroundings. Do NOT
68
+ re-read the task brief, instruction-set, or report template."""
69
+
70
+ _COLLABORATIVE_MANDATE = """Review the following findings discovered by other workers.
71
+ For EACH finding, respond with exactly one verdict:
72
+
73
+ - **AGREE**: The finding is valid based on the evidence presented
74
+ - **DISAGREE**: The finding is incorrect or unsupported (explain briefly why)
75
+ - **SUPPLEMENT**: The finding is valid AND you have additional supporting evidence or context
76
+ - **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
77
+ from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
78
+
79
+ Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
80
+
81
+ # 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
82
+ # 결과 항목이다. 검증자가 요약만 열고 "이 근거로는 입증되지 않는다" 고 답하던
83
+ # 자리를 막는다.
84
+ _EVIDENCE_ACCESS = """The `**Cited evidence**` line is the lead's summary of what the origin worker cited.
85
+ The complete citation is the origin worker's own item: before judging, open the
86
+ `**Origin item**` file at the named `### <item-id>` section and read every path, line,
87
+ command, and quote it cites. The `**Origin audit sidecar**` records the read-only
88
+ commands that worker ran and their output; it counts as cited evidence and you may
89
+ open it. Judge the claim against what the origin worker actually cited, never against
90
+ the summary line alone."""
91
+
92
+ _ADVERSARIAL_RESPONSE = """### <finding-id>
93
+ **Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
94
+ **Basis** (only if REFUTED): counter-evidence | burden-not-met
95
+ **Explanation**: <2-3 sentences; for counter-evidence include the file:line you found>"""
96
+
97
+ _COLLABORATIVE_RESPONSE = """### <finding-id>
98
+ **Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
99
+ **Explanation**: <2-3 sentences>"""
100
+
101
+
102
+ def _nonempty_string(value: Any) -> str:
103
+ return value if isinstance(value, str) and value.strip() else ""
104
+
105
+
106
+ def plan_row_for_worker(plan: Mapping[str, Any], worker_id: str) -> Mapping[str, Any]:
107
+ """계획의 `dispatches[]` 에서 이 워커의 행 하나. 검증기와 같은 규칙으로 맞춘다.
108
+
109
+ `validators/validate-run.py` `_plan_dispatch_finding_ids` 처럼 `worker` 가
110
+ 그대로 같거나 `<worker>-worker` 가 같으면 그 행이다.
111
+ """
112
+ dispatches = plan.get("dispatches")
113
+ if not isinstance(dispatches, list):
114
+ raise ReverifyPromptError("round plan has no dispatches array")
115
+ matched = [
116
+ row for row in dispatches
117
+ if isinstance(row, Mapping)
118
+ and (row.get("worker") == worker_id or f"{row.get('worker')}-worker" == worker_id)
119
+ ]
120
+ if len(matched) != 1:
121
+ planned = ", ".join(
122
+ _nonempty_string(row.get("worker")) or "?" for row in dispatches
123
+ if isinstance(row, Mapping)
124
+ )
125
+ raise ReverifyPromptError(
126
+ f"round plan dispatches nothing to `{worker_id}`; planned workers: "
127
+ f"{planned or 'none'}"
128
+ )
129
+ finding_ids = matched[0].get("findingIds")
130
+ if not isinstance(finding_ids, list) or not finding_ids:
131
+ raise ReverifyPromptError(f"round plan row for `{worker_id}` has no findingIds")
132
+ return matched[0]
133
+
134
+
135
+ def reverify_findings(
136
+ groups: Mapping[str, Any],
137
+ plan: Mapping[str, Any],
138
+ worker_id: str,
139
+ *,
140
+ project_root: Path,
141
+ run_dir: Path,
142
+ ) -> list[ReverifyFinding]:
143
+ """계획 행의 finding 을 계획 순서대로, 원 워커의 실물 인용 위치와 함께."""
144
+ row = plan_row_for_worker(plan, worker_id)
145
+ by_id = {
146
+ _nonempty_string(group.get("findingId")): group
147
+ for group in groups.get("groups") or []
148
+ if isinstance(group, Mapping) and _nonempty_string(group.get("findingId"))
149
+ }
150
+ result_paths = {
151
+ result.worker_id: result.result_path
152
+ for result in analyser_results(groups, project_root=project_root, run_dir=run_dir)
153
+ }
154
+ findings: list[ReverifyFinding] = []
155
+ for finding_id in row["findingIds"]:
156
+ group = by_id.get(str(finding_id))
157
+ if group is None:
158
+ raise ReverifyPromptError(
159
+ f"round plan names `{finding_id}`, which the grouping does not carry"
160
+ )
161
+ origin = _nonempty_string(group.get("originWorker"))
162
+ discovered = group.get("discoveredBy")
163
+ origin_item = (
164
+ _nonempty_string((discovered.get(origin) or {}).get("itemId"))
165
+ if isinstance(discovered, Mapping) and isinstance(discovered.get(origin), Mapping)
166
+ else ""
167
+ )
168
+ result_path = result_paths.get(origin, "")
169
+ if not origin or not origin_item or not result_path:
170
+ raise ReverifyPromptError(
171
+ f"finding `{finding_id}` has no resolvable origin item "
172
+ f"(origin worker `{origin or '?'}`, item `{origin_item or '?'}`)"
173
+ )
174
+ try:
175
+ audit_path = audit_sidecar_rel(result_path)
176
+ except WorkerArtifactPathError as exc:
177
+ raise ReverifyPromptError(str(exc)) from exc
178
+ findings.append(ReverifyFinding(
179
+ finding_id=str(finding_id),
180
+ summary=_nonempty_string(group.get("summary")),
181
+ origin_worker=origin,
182
+ origin_item_id=origin_item,
183
+ origin_evidence=_nonempty_string(group.get("originEvidence")),
184
+ origin_result_path=result_path,
185
+ origin_audit_path=audit_path,
186
+ ))
187
+ return findings
188
+
189
+
190
+ def reverify_prompt_body(
191
+ *,
192
+ task_key: str,
193
+ round_number: int,
194
+ adversarial: bool,
195
+ findings: Sequence[ReverifyFinding],
196
+ ) -> str:
197
+ """reverify 지시문 파일 본문. 같은 입력이면 같은 바이트를 낸다."""
198
+ if not _nonempty_string(task_key):
199
+ raise ReverifyPromptError("convergence groups carry no taskKey")
200
+ if not findings:
201
+ raise ReverifyPromptError("no findings to verify")
202
+ mode = "ADVERSARIAL re-verification" if adversarial else "re-verification"
203
+ mandate = _ADVERSARIAL_MANDATE if adversarial else _COLLABORATIVE_MANDATE
204
+ response = _ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
205
+ rows = [
206
+ "## Instructions\n\n",
207
+ f"Perform {mode} for {task_key} (round {round_number}).\n\n",
208
+ mandate, "\n\n",
209
+ _EVIDENCE_ACCESS, "\n\n",
210
+ "## Findings to verify\n",
211
+ ]
212
+ for finding in findings:
213
+ rows.append(f"\n### {finding.finding_id}: {finding.summary or '(no summary)'}\n")
214
+ rows.append(f"**Origin**: {finding.origin_worker}\n")
215
+ rows.append(f"**Cited evidence**: {finding.origin_evidence or '(none recorded)'}\n")
216
+ rows.append(
217
+ f"**Origin item**: `{finding.origin_result_path}` — section "
218
+ f"`### {finding.origin_item_id}`\n"
219
+ )
220
+ rows.append(f"**Origin audit sidecar**: `{finding.origin_audit_path}`\n")
221
+ rows.append("\n## Response format\n\n")
222
+ rows.append(
223
+ "One block per finding, headed by the finding id at exactly three hashes. "
224
+ "Field labels are bold with the colon outside (`**Verdict**: …`); the "
225
+ "collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
226
+ )
227
+ rows.append(response.replace("<finding-id>", findings[0].finding_id))
228
+ rows.append("\n")
229
+ if len(findings) > 1:
230
+ rows.append(f"\n### {findings[1].finding_id}\n**Verdict**: ...\n")
231
+ return "".join(rows)
@@ -1032,29 +1032,8 @@ def _translator_job_from_reservation(
1032
1032
  raise DispatchError(
1033
1033
  "translator dispatch requires a canonical translator role execution"
1034
1034
  )
1035
- contract = manifest.get("agentContract")
1036
- if not isinstance(contract, Mapping):
1037
- raise DispatchError("translator dispatch requires an agent contract")
1038
- reservation_root = _resolve_required_path(
1039
- project_root, contract, "invocationReservationRootPath"
1040
- )
1041
- dispatched_ids = {
1042
- str(row.get("dispatchId") or "")
1043
- for collection in (
1044
- team_state.get("workerDispatches") or [],
1045
- team_state.get("agentDispatches") or [],
1046
- )
1047
- for row in collection
1048
- if isinstance(row, Mapping)
1049
- }
1035
+ candidates, seen = translator_reservations(project_root, manifest, team_state)
1050
1036
  run_manifest_rel = _string_value(manifest.get("runManifestPath"))
1051
- candidates, seen = _translator_reservation_candidates(
1052
- project_root,
1053
- reservation_root,
1054
- execution=execution,
1055
- dispatched_ids=dispatched_ids,
1056
- run_manifest_rel=run_manifest_rel,
1057
- )
1058
1037
  if len(candidates) != 1:
1059
1038
  run_label = run_manifest_rel or "run manifest path unknown"
1060
1039
  raise DispatchError(
@@ -1120,6 +1099,47 @@ def _translator_job_from_reservation(
1120
1099
  )
1121
1100
 
1122
1101
 
1102
+ def translator_reservations(
1103
+ project_root: Path,
1104
+ manifest: Mapping[str, Any],
1105
+ team_state: Mapping[str, Any],
1106
+ ) -> tuple[list[Mapping[str, Any]], list[str]]:
1107
+ """이 run 이 지금 디스패치할 수 있는 translator 예약과, 본 예약 전부의 요약.
1108
+
1109
+ 두 소비자가 같은 답을 읽는다: `_translator_job_from_reservation` 은 그 하나를
1110
+ 골라 띄우고, `report-finalize` 의 `translate` 단계
1111
+ (`okstra_ctl.report_translation_dispatch`) 는 0건이면 예약을 만든다. 후보를
1112
+ 세는 규칙이 두 곳이면 한쪽만 고쳐져 "만들었는데 못 고른다" 가 나온다.
1113
+ """
1114
+ execution = _canonical_translator_execution(manifest)
1115
+ if execution is None:
1116
+ raise DispatchError(
1117
+ "translator dispatch requires a canonical translator role execution"
1118
+ )
1119
+ contract = manifest.get("agentContract")
1120
+ if not isinstance(contract, Mapping):
1121
+ raise DispatchError("translator dispatch requires an agent contract")
1122
+ reservation_root = _resolve_required_path(
1123
+ project_root, contract, "invocationReservationRootPath"
1124
+ )
1125
+ dispatched_ids = {
1126
+ str(row.get("dispatchId") or "")
1127
+ for collection in (
1128
+ team_state.get("workerDispatches") or [],
1129
+ team_state.get("agentDispatches") or [],
1130
+ )
1131
+ for row in collection
1132
+ if isinstance(row, Mapping)
1133
+ }
1134
+ return _translator_reservation_candidates(
1135
+ project_root,
1136
+ reservation_root,
1137
+ execution=execution,
1138
+ dispatched_ids=dispatched_ids,
1139
+ run_manifest_rel=_string_value(manifest.get("runManifestPath")),
1140
+ )
1141
+
1142
+
1123
1143
  def _translator_reservation_candidates(
1124
1144
  project_root: Path,
1125
1145
  reservation_root: Path,
@@ -71,8 +71,9 @@ HANDLED_TASK_TYPES = frozenset(
71
71
  )
72
72
 
73
73
  # implementation-option-selection 의 routing enum 중 phase 가 아닌 값.
74
+ # `pending-direction-selection` 과 `blocked` 는 `_from_option_selection` 이
75
+ # 명시 분기로 다룬다.
74
76
  _OPTION_SELECTION_NON_PHASE = {
75
- "pending-direction-selection": STATUS_PENDING,
76
77
  "blocked": STATUS_BLOCKED,
77
78
  }
78
79
 
@@ -231,13 +232,22 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
231
232
  # release-handoff 는 `routingRecommendation`.
232
233
  return make(status=STATUS_BLOCKED, rationale=_selection_guidance(selection))
233
234
  if routing == "pending-direction-selection":
234
- # 이 상태는 **성공**이다 — 비교가 끝났고 사용자가 방향을 고르면 된다.
235
- # 그런데 근거가 비면 closeout 표에 `pending` 행이 없어 "Otherwise →
236
- # /okstra-inspect status" 떨어지고, 사용자는 끝난 phase 들여다보라는
237
- # 안내를 받는다. 실측(dev-10341): 1회차가 IO-001·IO-002 내고 여기서
238
- # 멈췄는데 안내가 없어 같은 phase 돌았고, 그동안 워커가
239
- # 무너지며 결과가 0건으로 나빠졌다.
240
- return make(status=STATUS_PENDING, rationale=_direction_selection_reason(selection))
235
+ # 이 상태는 **성공**이다 — 비교가 끝났고, 방향은 계획 단계의 위저드가
236
+ # 고르게 한다(`selected_direction_pick`). 그러니 다음 phase 지금 바로
237
+ # 시작할 있는 `implementation-planning` 이고 status `ready` 다.
238
+ # 종전에는 phase 없는 `pending` 이었다: task 선택 화면이 `next: --
239
+ # (pending)` 찍어 목적지를 지웠고, task-type 화면은 추천 없이 방금
240
+ # 끝난 phase 재실행을 1번에 올렸다(실측 2026-09-09) — 근거 문장은
241
+ # "다시 돌리지 마세요" 라고 말하는데 화면은 그 반대를 권한 셈이다.
242
+ # 근거는 후보 id 를 이름으로 싣는다. 실측(dev-10341): 1회차가
243
+ # IO-001·IO-002 를 내고 안내 없이 멈춰 같은 phase 가 세 번 더 돌았다.
244
+ # 후보가 0건이면 고를 것이 없으므로 종전대로 phase 없는 pending 이다.
245
+ rationale = _direction_selection_reason(selection)
246
+ if not rationale:
247
+ return make(status=STATUS_PENDING)
248
+ return make(
249
+ phase="implementation-planning", status=STATUS_READY, rationale=rationale
250
+ )
241
251
  if routing in _OPTION_SELECTION_NON_PHASE:
242
252
  return make(status=_OPTION_SELECTION_NON_PHASE[routing])
243
253
  if not routing:
@@ -763,8 +763,9 @@ REVERIFY_PREAMBLE = (
763
763
  # 정본 서술은 plan-body-verification.md §"Response format" 이지만 전달 채널은
764
764
  # 이 출력이다 — 큐는 모든 검증자 프롬프트에 verbatim 으로 실리는 유일한
765
765
  # 조각이라, 여기 실린 블록은 리드가 프롬프트를 어떻게 손 조립하든 도달한다.
766
- # 실측 실패 모양(`## <id>` 2해시 헤딩, `**Verdict:** AGREE` 굵게 콜론)을
767
- # 본문이 직접 금지한다.
766
+ # 실측 실패 모양`## <id>` 2해시 헤딩은 본문이 직접 금지한다. 라벨 변형
767
+ # (`**Verdict:** AGREE`, `- Verdict: AGREE`)은 파서가 같은 필드로 읽으므로
768
+ # (`verdict_blocks._field_match`) 금지하지 않고 그렇게 적는다.
768
769
  PLAN_VERIFY_RESPONSE_FORMAT = (
769
770
  "\n## Response format\n"
770
771
  "\n"
@@ -772,8 +773,9 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
772
773
  "item's own id at exactly three hashes (`### P-...`). The collector parses "
773
774
  "`^### ` and nothing else — a block at any other depth is not an unparsed "
774
775
  "block; it is a verdict that was never recorded, and the round is scored "
775
- "on the items that remain. Field labels keep the colon outside the bold: "
776
- "`**Verdict**: AGREE`, never `**Verdict:** AGREE`.\n"
776
+ "on the items that remain. Field labels are bold with the colon outside: "
777
+ "`**Verdict**: AGREE`; the collector also reads `**Verdict:** AGREE` and "
778
+ "`- Verdict: AGREE` as the same field.\n"
777
779
  "\n"
778
780
  "### <item-id>\n"
779
781
  "**Verdict**: AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT | UNVERIFIABLE\n"
@@ -25,10 +25,15 @@ placeholders, which a v2 report never carries because its numeric cells are
25
25
  `null` until this step fills them. Substituting the tokens on a later retry
26
26
  then leaves the already-rendered html stale for `validators/validate-report-views.py`.
27
27
 
28
- The translation sidecar is NOT one of these steps. `render-views` overlays it,
29
- so a non-English run dispatches the translator before this sequence starts —
30
- after verifying the data.json is English, which is why `check-source` is also
31
- available as a standalone command.
28
+ The translation sidecar is the `translate` step, between `check-source` and
29
+ `render-views`: `render-views` overlays it, and `check-source` has to pass first
30
+ because the extractor refuses a work list from a non-English source. It used to
31
+ be a manual lead sequence outside this command (materialize the translator
32
+ prompt, dispatch it, resume finalize at `render-views`), written only in a doc
33
+ the lead lazy-reads; a lead that ran the whole sequence at once skipped it and
34
+ the run kept an English view under a `ko` report (2026-09-09, fontsninja-v3-site
35
+ dev-10628-3). `okstra_ctl.report_translation_dispatch` owns the step; an English
36
+ report or an existing sidecar makes it a no-op.
32
37
 
33
38
  Every lead adapter drives Phase 7 through this module: the Codex adapter calls
34
39
  it in-process (``codex_dispatch``), and a Claude-led run reaches the same code
@@ -62,6 +67,7 @@ from .final_report_paths import (
62
67
  from .paths import task_dir, task_manifest_file
63
68
  from .report_view_artifacts import html_view_path
64
69
  from .release_gate import release_handoff_allowed
70
+ from .report_translation_dispatch import TranslateOutcome, translate_report
65
71
  from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
66
72
  from .stage_integrate import IntegrateError
67
73
  from .stage_targets import (
@@ -83,6 +89,7 @@ from okstra_project.phase_pointer import (
83
89
 
84
90
  STEP_PROJECT_ACTIVITY = "project-activity"
85
91
  STEP_CHECK_SOURCE = "check-source"
92
+ STEP_TRANSLATE = "translate"
86
93
  STEP_TOKEN_USAGE = "token-usage"
87
94
  STEP_RENDER_VIEWS = "render-views"
88
95
  STEP_SPAWN_FOLLOWUPS = "spawn-followups"
@@ -96,6 +103,7 @@ STEP_ORDER = (
96
103
  # a Korean SSOT into English chrome, spawning follow-ups from it, and
97
104
  # validating it all succeed on a record the next phase cannot read.
98
105
  STEP_CHECK_SOURCE,
106
+ STEP_TRANSLATE,
99
107
  STEP_TOKEN_USAGE,
100
108
  STEP_RENDER_VIEWS,
101
109
  STEP_SPAWN_FOLLOWUPS,
@@ -112,6 +120,8 @@ V3_STEP_ORDER = (
112
120
  STEP_TOKEN_USAGE,
113
121
  STEP_PROJECT_ACTIVITY,
114
122
  STEP_CHECK_SOURCE,
123
+ # After the English gate, before the render that overlays its sidecar.
124
+ STEP_TRANSLATE,
115
125
  STEP_RENDER_VIEWS,
116
126
  STEP_SPAWN_FOLLOWUPS,
117
127
  STEP_VALIDATE_RUN,
@@ -337,6 +347,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
337
347
  str(ctx.data_path),
338
348
  ],
339
349
  ),
350
+ (
351
+ STEP_TRANSLATE,
352
+ ["<in-process>", "translate", str(ctx.data_path)],
353
+ ),
340
354
  (
341
355
  STEP_TOKEN_USAGE,
342
356
  [
@@ -782,6 +796,8 @@ def _run_finalize_step(
782
796
  """한 Phase 7 단계를 실행하고 그 단계의 종료 코드만 돌려준다."""
783
797
  if name == STEP_PROJECT_ACTIVITY:
784
798
  return _run_project_activity(ctx, command)
799
+ if name == STEP_TRANSLATE:
800
+ return _run_translate(ctx, command)
785
801
  if name == STEP_TEARDOWN_STAGES:
786
802
  return _teardown_stage_worktrees(ctx, command)
787
803
  if name == STEP_RECORD_GROUP_MEMORY:
@@ -801,6 +817,31 @@ def _run_finalize_step(
801
817
  )
802
818
 
803
819
 
820
+ def _run_translate(
821
+ ctx: FinalizeContext,
822
+ command: Sequence[str],
823
+ ) -> subprocess.CompletedProcess[str]:
824
+ """비영어 리포트의 번역 사이드카를 만든다. 영어 리포트는 건너뛴다.
825
+
826
+ 실패는 다른 단계처럼 기록만 하고 시퀀스는 계속 돈다 — `render-views` 는
827
+ 사이드카 없이 영어 열람본을 내고, `validate-run` 은 권고를 남기며, 결과의
828
+ `--only translate --only render-views …` 재개 힌트가 그 둘을 다시 돌린다.
829
+ """
830
+ try:
831
+ outcome = translate_report(
832
+ project_root=ctx.project_root,
833
+ workspace_root=ctx.workspace_root,
834
+ manifest_path=ctx.manifest_path,
835
+ manifest=_load_manifest(ctx.manifest_path),
836
+ data_path=ctx.data_path,
837
+ )
838
+ except (FinalizeError, OSError, JsonBoundaryError) as exc:
839
+ outcome = TranslateOutcome(1, "", f"translate failed: {exc}")
840
+ return subprocess.CompletedProcess(
841
+ command, outcome.returncode, outcome.stdout, outcome.stderr
842
+ )
843
+
844
+
804
845
  def _run_project_activity(
805
846
  ctx: FinalizeContext,
806
847
  command: Sequence[str],
@@ -932,15 +973,21 @@ _CLI_EPILOG = r"""Usage:
932
973
  okstra report-finalize --project-root <dir> --run-manifest <path> \
933
974
  --report <final-report-<task-type>-<seq>.md> [--team-state <path>]
934
975
 
935
- Runs the six Phase 7 steps in their contractual order against one final-report:
976
+ Runs the Phase 7 steps in their contractual order against one final-report:
936
977
 
937
978
  1. project-activity project the run's activity events into the data.json
938
979
  2. check-source verify the data.json is the English SSOT
939
- 3. token-usage substitute real token/cost numbers into the data.json
940
- 4. render-views write the schema-v2 task-specific *.html sibling; v1
941
- keeps the legacy conditional interactive view
942
- 5. spawn-followups turn section 4 rows into task stubs
943
- 6. validate-run validate the finished run artifacts
980
+ 3. translate for a non-English reportLanguage, materialize and
981
+ dispatch the translator worker and require its
982
+ *.i18n.<lang>.json sidecar; a no-op for English or
983
+ when the sidecar already exists
984
+ 4. token-usage substitute real token/cost numbers into the data.json
985
+ 5. render-views write the schema-v2 task-specific *.html sibling with
986
+ the translation overlaid; v1 keeps the
987
+ legacy conditional interactive view
988
+ 6. spawn-followups turn section 4 rows into task stubs
989
+ 7. validate-run validate the finished run artifacts
990
+ 8. record-group-memory / 9. teardown-stages after a clean validation
944
991
 
945
992
  Every step is idempotent, so re-running after a fixed failure is safe. The
946
993
  sequence stops at the first non-zero exit and reports which step failed, except