okstra 0.191.2 → 0.193.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 (37) hide show
  1. package/docs/architecture.md +2 -2
  2. package/docs/cli.md +3 -2
  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 +13 -3
  11. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +1 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -8
  14. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  15. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  16. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  17. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  18. package/runtime/python/okstra_ctl/conformance.py +74 -0
  19. package/runtime/python/okstra_ctl/convergence.py +63 -1
  20. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +238 -0
  21. package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
  22. package/runtime/python/okstra_ctl/execution_mutation_audit.py +96 -8
  23. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  24. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  25. package/runtime/python/okstra_ctl/plan_items_cli.py +91 -6
  26. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  27. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  28. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  29. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  30. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  31. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  32. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  33. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  34. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  35. package/runtime/python/okstra_ctl/worker_prompt_contract.py +11 -0
  36. package/runtime/skills/okstra-run/SKILL.md +2 -2
  37. package/runtime/validators/validate-run.py +78 -16
@@ -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"
@@ -255,7 +255,15 @@ def _parser() -> argparse.ArgumentParser:
255
255
  )
256
256
  apply_verdicts.add_argument(
257
257
  "--append", action="store_true",
258
- help="add these votes to existing rows instead of replacing the round",
258
+ help="add these votes to the existing rows (the critic's tie vote); "
259
+ "without it every recorded verdict row is replaced, so a round "
260
+ "that was never closed with complete-round is refused first",
261
+ )
262
+ apply_verdicts.add_argument(
263
+ "--discard-open-rounds", action="store_true",
264
+ help="replace even the rows of a round that complete-round never "
265
+ "closed — the recovery path when those rows are being re-applied "
266
+ "from their result files in order; the discarded rows are listed",
259
267
  )
260
268
  complete = commands.add_parser(
261
269
  "complete-round",
@@ -1387,10 +1395,13 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1387
1395
  "advisory plan-body gating allows one verification round"
1388
1396
  )
1389
1397
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1390
- writer = (
1391
- _append_item_verdicts if getattr(args, "append", False)
1392
- else _replace_item_verdicts
1393
- )
1398
+ append = bool(getattr(args, "append", False))
1399
+ if not append:
1400
+ _reject_uncompleted_round_loss(
1401
+ recorded, rows, data.get("roundHistory"), args.round_number, target,
1402
+ discard_open_rounds=bool(getattr(args, "discard_open_rounds", False)),
1403
+ )
1404
+ writer = _append_item_verdicts if append else _replace_item_verdicts
1394
1405
  for item in recorded:
1395
1406
  if isinstance(item, Mapping) and item.get("id") in rows:
1396
1407
  writer(item, rows[item["id"]], args.round_number, project_root)
@@ -1439,12 +1450,86 @@ def _append_item_verdicts(
1439
1450
  if clash:
1440
1451
  raise PlanItemContractError(
1441
1452
  f"plan item {item.get('id')} already has a vote from {clash} — "
1442
- "the extra vote must come from a worker who has not voted on it"
1453
+ "the extra vote must come from a worker who has not voted on it. "
1454
+ "--append is the critic's tie vote; a worker re-voting in a later "
1455
+ "round is recorded without --append, after the earlier round is "
1456
+ "closed with complete-round"
1443
1457
  )
1444
1458
  item["verdicts"] = [*current, *stamped]
1445
1459
  _remember_verified_hash(item)
1446
1460
 
1447
1461
 
1462
+ def _reject_uncompleted_round_loss(
1463
+ recorded: Sequence[Mapping[str, Any]],
1464
+ rows: Mapping[str, Any],
1465
+ history: object,
1466
+ round_number: int,
1467
+ target: Path,
1468
+ *,
1469
+ discard_open_rounds: bool = False,
1470
+ ) -> None:
1471
+ """이번 라운드가 아직 닫히지 않은 다른 라운드의 표를 지우려 하면 쓰기 전에 거절한다.
1472
+
1473
+ `--append` 없는 apply-verdicts 는 항목의 verdicts 를 통째로 교체한다 — 지난
1474
+ 라운드 표는 `complete-round` 가 `planItems[].rounds` 에 스냅샷으로 남길 때만
1475
+ 살아남는다. 그 스냅샷 없이 교체하면 표는 복구할 수 없이 사라지고, 유일한
1476
+ 가드였던 `_reject_round_gap` 은 complete-round 안에 있어 사라진 뒤에야 말했다.
1477
+ 실측(2026-09-09, `fontsninja-v3-site` dev-10627 planning 002): r1·r2 를 닫지
1478
+ 않고 r3 를 적용해 큐 19건의 r1 표가 없어졌다.
1479
+
1480
+ 이력이 없는 데이터(final-report `--data` 경로)는 대상이 아니다 — 라운드
1481
+ 스냅샷을 갖는 것은 convergence 소유 상태 파일뿐이다.
1482
+ """
1483
+ if not isinstance(history, list):
1484
+ return
1485
+ closed = {
1486
+ row.get("round")
1487
+ for row in history
1488
+ if isinstance(row, Mapping) and isinstance(row.get("round"), int)
1489
+ }
1490
+ at_risk: dict[int, list[str]] = {}
1491
+ for item in recorded:
1492
+ if not isinstance(item, Mapping) or item.get("id") not in rows:
1493
+ continue
1494
+ for row in item.get("verdicts") or []:
1495
+ if not isinstance(row, Mapping):
1496
+ continue
1497
+ recorded_round = row.get("round")
1498
+ if (
1499
+ isinstance(recorded_round, int)
1500
+ and recorded_round != round_number
1501
+ and recorded_round not in closed
1502
+ ):
1503
+ at_risk.setdefault(recorded_round, []).append(str(item.get("id")))
1504
+ if not at_risk:
1505
+ return
1506
+ detail = "; ".join(
1507
+ f"round {number}: {', '.join(sorted(set(ids)))}"
1508
+ for number, ids in sorted(at_risk.items())
1509
+ )
1510
+ if discard_open_rounds:
1511
+ # 복구 경로 — 잃어버린 라운드를 결과 파일에서 순서대로 다시 적용할 때는
1512
+ # 지금 남은 뒤 라운드 표가 버려야 할 쪽이다. 무엇을 버리는지는 남긴다.
1513
+ print(
1514
+ f"apply-verdicts: discarding open-round verdicts ({detail})",
1515
+ file=sys.stderr,
1516
+ )
1517
+ return
1518
+ commands = " then ".join(
1519
+ f"`okstra plan-items complete-round --state {target} "
1520
+ f"--run-manifest <run-manifest> --round {number}`"
1521
+ for number in sorted(at_risk)
1522
+ )
1523
+ raise PlanItemContractError(
1524
+ f"round {round_number} would replace verdicts of a round that was never "
1525
+ f"completed ({detail}) — apply-verdicts replaces every recorded row, and "
1526
+ "only complete-round keeps a round's votes in planItems[].rounds. Close "
1527
+ f"the earlier round first: run {commands}, then re-run this command. "
1528
+ "If those rows are themselves being re-applied from their result files "
1529
+ "in order, pass --discard-open-rounds. Nothing was written."
1530
+ )
1531
+
1532
+
1448
1533
  def _gate_module() -> Any:
1449
1534
  # 부모 개수를 세면 체크아웃에서만 맞는다 — 설치본은 패키지가
1450
1535
  # `~/.okstra/lib/python/` 이라 validators 가 `~/.okstra/lib/` 아래다.
@@ -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
@@ -0,0 +1,300 @@
1
+ """Phase 7 `translate` 단계 — 번역 사이드카를 okstra 가 직접 만든다.
2
+
3
+ 비영어 리포트의 번역 워커는 종전에 리드의 수동 절차였다: `agent-prompt
4
+ materialize --audience translator` 로 예약을 만들고, `worker-dispatch --workers
5
+ translator` 로 띄우고, `report-finalize` 를 두 번에 나눠 도는 순서가 리드가
6
+ lazy-read 하는 문서(`prompts/lead/report-writer.md`)에만 있었다. 실측(2026-09-09,
7
+ fontsninja-v3-site dev-10628-3 implementation-option-selection, grok 리드): 리드가
8
+ `report-finalize` 를 한 번에 돌려 번역 예약이 0건이었고, 디스패처는 0건을 거절할
9
+ 뿐 만들지 못한다(`dispatch_core._translator_job_from_reservation`). 검증은 권고
10
+ 한 줄을 남겼고 HTML 은 영어로 남았다.
11
+
12
+ 이 모듈은 그 절차를 `report-finalize` 의 한 단계로 내린다. 예약이 없으면 지시문과
13
+ 프롬프트를 발행하고(`agent-prompt materialize` 와 같은 코드), CLI 래퍼 디스패처로
14
+ 워커를 띄운 뒤 사이드카가 생겼는지 본다. 디스패치 기록과 결과 링크는
15
+ `dispatch_core` 가 배치 안에서 이미 하므로 여기서 다시 하지 않는다.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import io
21
+ import json
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Mapping
25
+
26
+ from . import worker_dispatch
27
+ from .agent.prompt_cli.cli import main as agent_prompt_main
28
+ from .dispatch_core import translator_reservations
29
+ from .dispatch_state import (
30
+ DispatchError,
31
+ load_json_object,
32
+ resolve_project_path,
33
+ resolve_required_path,
34
+ )
35
+ from .final_report_paths import translation_sidecar_path
36
+ from .json_boundary import JsonBoundaryError
37
+
38
+ TRANSLATOR_WORKER_ID = "translator"
39
+
40
+ # 지시문의 언어 이름. 워커는 `**Report Language:**` 헤더로 코드를 받지만, 지시문은
41
+ # 사람이 읽는 문장이라 이름으로 적는다. 표에 없는 코드는 코드 그대로 쓴다.
42
+ _LANGUAGE_NAMES = {"ko": "Korean"}
43
+
44
+ # 리드가 종전에 손으로 쓰던 지시문(실측 2026-09-08, fontsninja-nlpvibe dev-10786
45
+ # requirements-discovery-002)과 같은 절차다. 명령은 `agents/workers/translator-worker.md`
46
+ # 의 Procedure 와 같은 세 개이고, 결과·감사 경로는 프롬프트 앵커가 이름한다.
47
+ _INSTRUCTIONS = """## Instructions
48
+
49
+ Translate the finalized report into {language} faithfully. This is translation only: no analysis, no source edits.
50
+
51
+ 1. Run `okstra report-translate source --run-manifest {manifest}` and read the complete work list, in chunks if it is long. Copy the `Source digest` it prints.
52
+ 2. Write one translated block per `## T-NNN` item to the exact Result Path, using the same `## T-NNN` headings. Preserve every identifier, path, command, enum token, link, and qualification character for character.
53
+ 3. Publish with `okstra report-translate write --run-manifest {manifest} --source-digest <digest from step 1> --translations <Result Path>`, then run `okstra report-translate check-data --run-manifest {manifest}`. Fix the translation until that check passes; do not return on a failing check.
54
+ 4. Write a completion pointer to the Worker Result Path naming the translations file, the published sidecar, the digest, and the check result. Write the audit sidecar with reading, completeness, and terminology checks.
55
+
56
+ Do not edit the canonical report, the narrative, the HTML, or any source file. All `.okstra` paths resolve from Project Root.
57
+ """
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class TranslateOutcome:
62
+ """`translate` 단계의 결과. `report_finalize` 가 CompletedProcess 로 옮긴다."""
63
+
64
+ returncode: int
65
+ stdout: str = ""
66
+ stderr: str = ""
67
+
68
+
69
+ class TranslateError(Exception):
70
+ """번역 워커를 예약하거나 띄우지 못했다. 메시지가 그 자리를 이름한다."""
71
+
72
+
73
+ def report_language(manifest: Mapping[str, Any], data_path: Path) -> str:
74
+ """이 run 의 사람 리포트 언어. 매니페스트가 정본이고, 없으면 리포트의 meta.
75
+
76
+ 매니페스트 `reportLanguage` 는 prepare 가 `report_language.resolve_report_language`
77
+ 로 정한 값이고, 조립이 `meta.reportLanguage` 로 복사한다. 그 필드가 없는 옛
78
+ run 은 리포트에서 읽는다. 둘 다 없으면 영어다.
79
+ """
80
+ value = str(manifest.get("reportLanguage") or "").strip()
81
+ if value:
82
+ return value
83
+ if data_path.is_file():
84
+ try:
85
+ data = load_json_object(data_path, "final-report data")
86
+ except (DispatchError, JsonBoundaryError):
87
+ return "en"
88
+ meta = data.get("meta")
89
+ if isinstance(meta, Mapping):
90
+ value = str(meta.get("reportLanguage") or "").strip()
91
+ return value or "en"
92
+
93
+
94
+ def translate_report(
95
+ *,
96
+ project_root: Path,
97
+ workspace_root: Path,
98
+ manifest_path: Path,
99
+ manifest: Mapping[str, Any],
100
+ data_path: Path,
101
+ ) -> TranslateOutcome:
102
+ """비영어 리포트의 번역 사이드카를 있게 만든다. 영어 리포트는 할 일이 없다.
103
+
104
+ 사이드카가 이미 있으면(앞선 호출, 또는 리드가 손으로 띄운 워커) 그대로 둔다.
105
+ 없으면 예약을 보장하고 워커를 띄운 뒤, 사이드카의 존재로만 성공을 판정한다 —
106
+ 워커의 종료 코드는 산출물의 존재를 대신하지 못한다.
107
+ """
108
+ lang = report_language(manifest, data_path)
109
+ if lang == "en":
110
+ return TranslateOutcome(0, f"skipped: reportLanguage is {lang!r}")
111
+ sidecar = translation_sidecar_path(data_path, lang)
112
+ if sidecar.is_file():
113
+ return TranslateOutcome(0, f"translation sidecar already present: {sidecar.name}")
114
+ try:
115
+ metadata_path = ensure_translator_reservation(
116
+ project_root, manifest_path, manifest, lang
117
+ )
118
+ dispatch = dispatch_translator(project_root, workspace_root, manifest_path)
119
+ except TranslateError as exc:
120
+ return TranslateOutcome(1, "", str(exc))
121
+ if sidecar.is_file():
122
+ return TranslateOutcome(
123
+ 0,
124
+ f"translated into {lang!r}: {sidecar.name} (reservation "
125
+ f"{metadata_path.name})",
126
+ dispatch.stderr,
127
+ )
128
+ return TranslateOutcome(
129
+ 1,
130
+ dispatch.stdout,
131
+ f"translator dispatch exited {dispatch.returncode} and left no "
132
+ f"translation sidecar at {sidecar}"
133
+ + (f": {dispatch.stderr.strip()}" if dispatch.stderr.strip() else ""),
134
+ )
135
+
136
+
137
+ def ensure_translator_reservation(
138
+ project_root: Path,
139
+ manifest_path: Path,
140
+ manifest: Mapping[str, Any],
141
+ lang: str,
142
+ ) -> Path:
143
+ """이 run 의 아직 안 띄운 translator 예약 하나를 돌려준다. 없으면 만든다.
144
+
145
+ Returns the invocation metadata path of that reservation.
146
+
147
+ 예약이 둘이면 디스패처가 고를 수 없고 회수 명령도 없으므로 그대로 거절한다 —
148
+ 그 상태는 리드가 다른 id 로 두 번 발행했을 때만 생긴다. 예약 id 는
149
+ `<task-type>-<seq>-translator` 이고, 같은 run 의 앞선 시도가 실패해 그 id 가
150
+ 이미 쓰였으면 `-r2`, `-r3` 로 잇는다.
151
+ """
152
+ team_state = load_json_object(
153
+ resolve_required_path(project_root, manifest, "teamStatePath"), "team-state"
154
+ )
155
+ try:
156
+ candidates, seen = translator_reservations(project_root, manifest, team_state)
157
+ except DispatchError as exc:
158
+ raise TranslateError(f"translator reservation lookup failed: {exc}") from exc
159
+ if len(candidates) == 1:
160
+ return resolve_project_path(
161
+ project_root, str(candidates[0].get("metadataPath") or "")
162
+ )
163
+ if len(candidates) > 1:
164
+ raise TranslateError(
165
+ "translator dispatch requires exactly one canonical invocation "
166
+ f"reservation for this run; found {len(candidates)}. Translator "
167
+ "reservations seen: " + "; ".join(seen)
168
+ )
169
+ return _materialize_translator(project_root, manifest_path, manifest, lang, seen)
170
+
171
+
172
+ def dispatch_translator(
173
+ project_root: Path, workspace_root: Path, manifest_path: Path,
174
+ ) -> TranslateOutcome:
175
+ """`okstra worker-dispatch --workers translator` 와 같은 코드를 프로세스 안에서 돈다.
176
+
177
+ 디스패처는 결과 JSON 을 stdout 에 찍는다. Phase 7 의 stdout 은 finalize 결과
178
+ JSON 하나여야 하므로 여기서 받아 단계 결과로 넘긴다.
179
+ """
180
+ out, err = io.StringIO(), io.StringIO()
181
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
182
+ try:
183
+ code = worker_dispatch.main([
184
+ "--project-root", str(project_root),
185
+ "--run-manifest", str(manifest_path),
186
+ "--workspace-root", str(workspace_root),
187
+ "--workers", TRANSLATOR_WORKER_ID,
188
+ ])
189
+ except (DispatchError, OSError) as exc:
190
+ raise TranslateError(f"translator dispatch failed: {exc}") from exc
191
+ return TranslateOutcome(code, out.getvalue(), err.getvalue())
192
+
193
+
194
+ def _materialize_translator(
195
+ project_root: Path,
196
+ manifest_path: Path,
197
+ manifest: Mapping[str, Any],
198
+ lang: str,
199
+ seen: list[str],
200
+ ) -> Path:
201
+ task_type = str(manifest.get("taskType") or "").strip()
202
+ if not task_type:
203
+ raise TranslateError("run manifest has no taskType")
204
+ run_dir = resolve_required_path(project_root, manifest, "runDirectoryPath")
205
+ base_id = f"{task_type}-{_seq(manifest, 'manifests')}-translator"
206
+ used = {entry.split(" ", 1)[0] for entry in seen}
207
+ invocation_id = base_id
208
+ attempt = 1
209
+ while invocation_id in used:
210
+ attempt += 1
211
+ invocation_id = f"{base_id}-r{attempt}"
212
+ prompt_seq = _seq(manifest, "prompts")
213
+ result_seq = _seq(manifest, "workerResults")
214
+ suffix = "" if attempt == 1 else f"-r{attempt}"
215
+ instruction = (
216
+ _instruction_root(project_root, manifest, run_dir)
217
+ / f"translator-instructions-{task_type}-{_seq(manifest, 'state')}{suffix}.md"
218
+ )
219
+ prompt = run_dir / "prompts" / f"translator-worker-prompt-{task_type}-{prompt_seq}{suffix}.md"
220
+ translations = (
221
+ run_dir / "worker-results"
222
+ / f"translator-translations-{task_type}-{result_seq}{suffix}.md"
223
+ )
224
+ worker_result = (
225
+ run_dir / "worker-results" / f"translator-worker-{task_type}-{result_seq}{suffix}.md"
226
+ )
227
+ if not instruction.is_file():
228
+ # 리드가 먼저 써 둔 지시문은 그대로 쓴다. 없을 때만 okstra 의 것을 쓴다.
229
+ instruction.parent.mkdir(parents=True, exist_ok=True)
230
+ instruction.write_text(
231
+ _INSTRUCTIONS.format(
232
+ language=_LANGUAGE_NAMES.get(lang, lang),
233
+ manifest=manifest_path.relative_to(project_root).as_posix()
234
+ if manifest_path.is_relative_to(project_root)
235
+ else str(manifest_path),
236
+ ),
237
+ encoding="utf-8",
238
+ )
239
+ out, err = io.StringIO(), io.StringIO()
240
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
241
+ code = agent_prompt_main([
242
+ "materialize",
243
+ "--project-root", str(project_root),
244
+ "--run-manifest", str(manifest_path),
245
+ "--invocation-id", invocation_id,
246
+ "--worker-id", TRANSLATOR_WORKER_ID,
247
+ "--audience", TRANSLATOR_WORKER_ID,
248
+ "--dispatch-kind", TRANSLATOR_WORKER_ID,
249
+ "--assignment-ref", TRANSLATOR_WORKER_ID,
250
+ "--instruction", str(instruction),
251
+ "--prompt", str(prompt),
252
+ "--result", str(translations),
253
+ "--audit-source", str(worker_result),
254
+ "--json",
255
+ ])
256
+ if code != 0:
257
+ raise TranslateError(
258
+ "translator prompt materialization failed: "
259
+ + (err.getvalue().strip() or out.getvalue().strip() or f"exit {code}")
260
+ )
261
+ try:
262
+ payload = json.loads(out.getvalue())
263
+ except json.JSONDecodeError as exc:
264
+ raise TranslateError(
265
+ f"translator materialization printed no JSON: {out.getvalue()[:200]!r}"
266
+ ) from exc
267
+ metadata = str(payload.get("metadataPath") or "")
268
+ if not metadata:
269
+ raise TranslateError("translator materialization named no metadataPath")
270
+ return resolve_project_path(project_root, metadata)
271
+
272
+
273
+ def _instruction_root(
274
+ project_root: Path, manifest: Mapping[str, Any], run_dir: Path,
275
+ ) -> Path:
276
+ """지시문을 둘 디렉터리. materialize 는 `authorizedPaths.instructionRoots` 밖의
277
+ 지시문을 거절하므로 그 목록에서 고른다 — 리드가 쓰던 `state/` 가 있으면 그것,
278
+ 없으면 첫 항목, 목록이 없으면 run 의 `state/`."""
279
+ contract = manifest.get("agentContract")
280
+ authorized = contract.get("authorizedPaths") if isinstance(contract, Mapping) else None
281
+ roots = authorized.get("instructionRoots") if isinstance(authorized, Mapping) else None
282
+ candidates = [
283
+ resolve_project_path(project_root, str(root))
284
+ for root in (roots if isinstance(roots, list) else [])
285
+ if str(root).strip()
286
+ ]
287
+ for root in candidates:
288
+ if root.name == "state":
289
+ return root
290
+ return candidates[0] if candidates else run_dir / "state"
291
+
292
+
293
+ def _seq(manifest: Mapping[str, Any], category: str) -> str:
294
+ seqs = manifest.get("runSequencesByCategory")
295
+ if not isinstance(seqs, Mapping):
296
+ raise TranslateError("run manifest has no runSequencesByCategory object")
297
+ value = str(seqs.get(category) or seqs.get("manifests") or "").strip()
298
+ if not value:
299
+ raise TranslateError(f"run manifest has no {category} sequence")
300
+ return value