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.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +3 -2
- package/docs/project-structure-overview.md +3 -1
- package/docs/task-process/README.md +2 -2
- package/docs/task-process/common-flow.md +4 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/translator-worker.md +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/convergence.md +13 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +1 -1
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/prompts/profiles/_implementation-executor.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +52 -23
- package/runtime/python/okstra_ctl/conformance.py +74 -0
- package/runtime/python/okstra_ctl/convergence.py +63 -1
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +238 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +96 -8
- package/runtime/python/okstra_ctl/next_phase.py +18 -8
- package/runtime/python/okstra_ctl/plan_items.py +6 -4
- package/runtime/python/okstra_ctl/plan_items_cli.py +91 -6
- package/runtime/python/okstra_ctl/report_finalize.py +57 -10
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
- package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
- package/runtime/python/okstra_ctl/wizard/state.py +39 -27
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +11 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- 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
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
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
|
-
#
|
|
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
|
|
776
|
-
"`**Verdict**: AGREE
|
|
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
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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.
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|