okstra 0.191.1 → 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 (35) 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 +12 -9
  13. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  14. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -0
  15. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +4 -0
  16. package/runtime/python/okstra_ctl/convergence.py +63 -1
  17. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
  18. package/runtime/python/okstra_ctl/dispatch_core.py +66 -22
  19. package/runtime/python/okstra_ctl/dispatch_state.py +24 -0
  20. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  21. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  22. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  23. package/runtime/python/okstra_ctl/report_narrative.py +33 -0
  24. package/runtime/python/okstra_ctl/report_synthesis_packet.py +3 -0
  25. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  26. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  27. package/runtime/python/okstra_ctl/wizard/confirmation.py +13 -1
  28. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  29. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  30. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  31. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  32. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  33. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  34. package/runtime/skills/okstra-run/SKILL.md +5 -9
  35. 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)
@@ -31,6 +31,8 @@ from .dispatch_state import (
31
31
  load_json_object as _load_json_object,
32
32
  link_agent_dispatch_result as _link_agent_dispatch_result,
33
33
  missing_completion_paths as _missing_completion_paths,
34
+ unusable_result_defect,
35
+ _dispatch_worker_key,
34
36
  _plan_verify_result_aliases,
35
37
  mutate_team_state as _mutate_team_state,
36
38
  require_string as _require_string,
@@ -276,6 +278,9 @@ class WorkerOutcome:
276
278
  timeout: bool
277
279
  terminal_stage: str
278
280
  degraded_from: str
281
+ # 파일은 있는데 읽을 수 없는 산출물의 사유 — `missing_completion_paths` 에
282
+ # 그 경로가 함께 들어가고, 실패 사유는 이 문장을 인용한다.
283
+ artifact_defects: tuple[str, ...] = ()
279
284
 
280
285
 
281
286
  @dataclass(frozen=True)
@@ -1027,29 +1032,8 @@ def _translator_job_from_reservation(
1027
1032
  raise DispatchError(
1028
1033
  "translator dispatch requires a canonical translator role execution"
1029
1034
  )
1030
- contract = manifest.get("agentContract")
1031
- if not isinstance(contract, Mapping):
1032
- raise DispatchError("translator dispatch requires an agent contract")
1033
- reservation_root = _resolve_required_path(
1034
- project_root, contract, "invocationReservationRootPath"
1035
- )
1036
- dispatched_ids = {
1037
- str(row.get("dispatchId") or "")
1038
- for collection in (
1039
- team_state.get("workerDispatches") or [],
1040
- team_state.get("agentDispatches") or [],
1041
- )
1042
- for row in collection
1043
- if isinstance(row, Mapping)
1044
- }
1035
+ candidates, seen = translator_reservations(project_root, manifest, team_state)
1045
1036
  run_manifest_rel = _string_value(manifest.get("runManifestPath"))
1046
- candidates, seen = _translator_reservation_candidates(
1047
- project_root,
1048
- reservation_root,
1049
- execution=execution,
1050
- dispatched_ids=dispatched_ids,
1051
- run_manifest_rel=run_manifest_rel,
1052
- )
1053
1037
  if len(candidates) != 1:
1054
1038
  run_label = run_manifest_rel or "run manifest path unknown"
1055
1039
  raise DispatchError(
@@ -1115,6 +1099,47 @@ def _translator_job_from_reservation(
1115
1099
  )
1116
1100
 
1117
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
+
1118
1143
  def _translator_reservation_candidates(
1119
1144
  project_root: Path,
1120
1145
  reservation_root: Path,
@@ -1953,9 +1978,15 @@ def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
1953
1978
  timeout=False,
1954
1979
  terminal_stage="exited",
1955
1980
  degraded_from=handle.degraded_from,
1981
+ artifact_defects=_artifact_defects(handle.job),
1956
1982
  )
1957
1983
 
1958
1984
 
1985
+ def _artifact_defects(job: WorkerJob) -> tuple[str, ...]:
1986
+ defect = unusable_result_defect(job.worker_id, job.result_path)
1987
+ return (defect,) if defect else ()
1988
+
1989
+
1959
1990
  def _correct_teardown_marked_dispatches(plan: DispatchPlan) -> None:
1960
1991
  """Let the wrapper's own exit settle a record teardown wrote off.
1961
1992
 
@@ -3123,6 +3154,7 @@ def _outcome_from_status(record: Mapping[str, Any], status) -> WorkerOutcome:
3123
3154
  timeout=status.timeout,
3124
3155
  terminal_stage=status.stage,
3125
3156
  degraded_from=_string_value(record.get("degradedFrom")),
3157
+ artifact_defects=_record_artifact_defects(record),
3126
3158
  )
3127
3159
 
3128
3160
 
@@ -3190,8 +3222,11 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3190
3222
  result_path = Path(_string_value(record.get("resultPath")))
3191
3223
  worker_result = Path(_string_value(record.get("workerResultPath")))
3192
3224
  missing: list[Path] = []
3225
+ worker_id = _dispatch_worker_key(record)
3193
3226
  for path in _record_completion_paths(record):
3194
3227
  if path.is_file():
3228
+ if path == result_path and unusable_result_defect(worker_id, path):
3229
+ missing.append(path)
3195
3230
  continue
3196
3231
  if path in {result_path, worker_result} and any(alias.is_file() for alias in aliases):
3197
3232
  continue
@@ -3199,6 +3234,13 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
3199
3234
  return tuple(missing)
3200
3235
 
3201
3236
 
3237
+ def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
3238
+ defect = unusable_result_defect(
3239
+ _dispatch_worker_key(record), Path(_string_value(record.get("resultPath")))
3240
+ )
3241
+ return (defect,) if defect else ()
3242
+
3243
+
3202
3244
  def _should_retry(outcome: WorkerOutcome, attempt: int) -> bool:
3203
3245
  """결과가 없으면 재시도한다 — 기준은 종료 코드가 아니라 산출물이다.
3204
3246
 
@@ -3245,6 +3287,8 @@ def _failure_reason(outcome: WorkerOutcome) -> str:
3245
3287
  return "CLI ended cleanly without emitting a result event"
3246
3288
  if outcome.returncode != 0:
3247
3289
  return f"wrapper exited with code {outcome.returncode}"
3290
+ if outcome.artifact_defects:
3291
+ return "required worker artifact is unusable: " + "; ".join(outcome.artifact_defects)
3248
3292
  if outcome.missing_completion_paths:
3249
3293
  missing = ", ".join(str(path) for path in outcome.missing_completion_paths)
3250
3294
  return f"required worker artifact was not produced: {missing}"
@@ -53,6 +53,7 @@ from .execution_manifest import (
53
53
  from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
54
54
  from .final_report_paths import final_report_data_path
55
55
  from .report_inputs import report_narrative_path, uses_report_contract_v3
56
+ from .report_narrative import narrative_structure_defect
56
57
  from .worker_prompt_body import REPORT_WRITER_WORKER_ID
57
58
  from .worker_prompt_contract import (
58
59
  PromptRecord,
@@ -1636,10 +1637,33 @@ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
1636
1637
  return BACKEND_MIXED
1637
1638
 
1638
1639
 
1640
+ def unusable_result_defect(worker_id: str, result_path: Path) -> str | None:
1641
+ """산출물이 있어도 소비자가 읽을 수 없으면 없는 것이다 — 지금은 서사 한 종류.
1642
+
1643
+ report-writer 의 서사가 줄 문법을 어기면(frontmatter·헤딩으로 된 보통
1644
+ 보고서) 조립이 Phase 7 에서 거절하고, 그때는 배치의 재시도가 이미 지나
1645
+ 리드가 손으로 재저작을 띄워야 한다 — 실측(2026-09-09, jobs implementation
1646
+ stage-2)에서 리드는 그것을 하지 않고 run 을 닫았다. 수집 시점에 "없는
1647
+ 산출물" 로 세면 `_should_retry` 가 같은 배치 안에서 다시 띄운다.
1648
+ """
1649
+ if worker_id != REPORT_WRITER_WORKER_ID or not result_path.is_file():
1650
+ return None
1651
+ try:
1652
+ text = result_path.read_text(encoding="utf-8")
1653
+ except (OSError, UnicodeDecodeError) as exc:
1654
+ return f"narrative is unreadable: {exc}"
1655
+ defect = narrative_structure_defect(text)
1656
+ if defect is None:
1657
+ return None
1658
+ return f"narrative does not parse: {defect}"
1659
+
1660
+
1639
1661
  def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
1640
1662
  missing: list[Path] = []
1641
1663
  for path in job.completion_paths:
1642
1664
  if path.is_file():
1665
+ if path == job.result_path and unusable_result_defect(job.worker_id, path):
1666
+ missing.append(path)
1643
1667
  continue
1644
1668
  # reports seq 와 workerResults seq 가 갈라지면 워커는 다른 쪽
1645
1669
  # 파일명으로 쓴다. 둘 중 하나가 있으면 산출물은 있는 것이다.
@@ -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
@@ -29,6 +29,39 @@ class NarrativeContractError(ValueError):
29
29
  """서사 입력이 보고서 작성자 소유권이나 Markdown 문법을 위반했다."""
30
30
 
31
31
 
32
+ # 작성자에게 도달해야 하는 줄 문법 — 합성 패킷의 Authoring Contract 가 이것을
33
+ # 그대로 싣는다. 문법이 preamble 템플릿에만 있던 동안 작성자는 read-scope
34
+ # 규칙대로 패킷만 읽고 frontmatter + 헤딩으로 된 보통 보고서를 냈다(실측
35
+ # 2026-09-09, jobs implementation stage-2: `# OKSTRA Report Narrative` 0회,
36
+ # 조립 거부, 최종 리포트 미발행).
37
+ NARRATIVE_GRAMMAR_INSTRUCTIONS: tuple[str, ...] = (
38
+ f"Narrative line grammar: the file starts with the line `{TITLE}` and then "
39
+ "contains only three line shapes — `- **Humanised Field Name**` (one field; "
40
+ "nest a child by indenting two more spaces), `- Item <N>` (one array entry, "
41
+ "numbered 1..N without gaps), and `> value` (one scalar; repeat the line for a "
42
+ "multi-line value; `> _none_` for null, an empty object, or an empty array). "
43
+ "Blank lines are ignored.",
44
+ "Every other line is rejected — YAML frontmatter (`---` blocks), Markdown "
45
+ "headings (`#`, `##`, `###`), pipe tables at column 0, code fences, bare "
46
+ "paragraphs, JSON. Put such text inside a `> ` value instead. Report assembly "
47
+ "refuses the file otherwise and the run publishes no report.",
48
+ )
49
+
50
+
51
+ def narrative_structure_defect(markdown: str) -> str | None:
52
+ """줄 문법 위반 메시지, 없으면 None — 수집 시점의 산출물 검사용.
53
+
54
+ 값 결함(enum 밖 값 등)은 보지 않는다; 그것은 교정 원장이 고친다. 구조가
55
+ 깨진 파일은 원장이 해소될 자료가 없어 재저작 대상이고, 그것을 산출물이
56
+ 있는 것으로 세면 결함이 Phase 7 조립까지 숨어 있다 재저작 없이 run 이 닫힌다.
57
+ """
58
+ try:
59
+ _parse_tree(markdown)
60
+ except NarrativeContractError as exc:
61
+ return str(exc)
62
+ return None
63
+
64
+
32
65
  class _Node:
33
66
  def __init__(self, kind: str, label: str, level: int) -> None:
34
67
  self.kind = kind