okstra 0.191.2 → 0.192.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -1
- 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 +7 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -8
- package/runtime/prompts/wizard/prompts.ko.json +52 -23
- package/runtime/python/okstra_ctl/convergence.py +63 -1
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
- 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/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/skills/okstra-run/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +2 -2
|
@@ -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
|
|
@@ -5,6 +5,14 @@ The response shape is fixed by contract (`prompts/lead/plan-body-verification.md
|
|
|
5
5
|
per-round ad-hoc regex makes the round's fidelity depend on whoever wrote it,
|
|
6
6
|
and its failure mode is silence: dev-10400 lost 19 of 37 assigned items to a
|
|
7
7
|
no-match that nothing reported. Every shape this module cannot read is an error.
|
|
8
|
+
|
|
9
|
+
A label that means the same thing is read, not refused. The contract writes
|
|
10
|
+
`**Verdict**: AGREE`; a worker following a lead-authored instruction wrote
|
|
11
|
+
`- Verdict: REFUTED` and the whole reverify round was rejected for it
|
|
12
|
+
(2026-09-09). The bullet form, the colon inside the bold (`**Verdict:**`), a
|
|
13
|
+
bullet before the bold, and a differently-cased key all name the same field —
|
|
14
|
+
refusing them costs a dispatch cycle and yields no information. A bare
|
|
15
|
+
`Verdict:` without bullet or bold is still prose: `Note:` opens sentences.
|
|
8
16
|
"""
|
|
9
17
|
from __future__ import annotations
|
|
10
18
|
|
|
@@ -60,11 +68,19 @@ DISAGREE_BASES = frozenset({"counter-evidence", "burden-not-met"})
|
|
|
60
68
|
_ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
|
|
61
69
|
# The contract writes some labels with a parenthetical qualifier —
|
|
62
70
|
# `**Fixability** (only when DISAGREE):` — so the colon may trail a `(...)`.
|
|
71
|
+
# The label shapes read as one field (see the module docstring):
|
|
72
|
+
# `**Key**: v` `**Key:** v` `- Key: v` `- **Key**: v` `- **Key:** v`
|
|
73
|
+
# `_field_match` enforces that an opening `**` is closed exactly once and that
|
|
74
|
+
# a label without bold carries a bullet.
|
|
75
|
+
_FIELD_KEYS = ("Verdict", "Fixability", "Note", "Explanation", "Prior dissent",
|
|
76
|
+
"Basis", "Your evidence")
|
|
77
|
+
_CANONICAL_KEYS = {key.lower(): key for key in _FIELD_KEYS}
|
|
63
78
|
_FIELD_RE = re.compile(
|
|
64
|
-
r"
|
|
65
|
-
r"|
|
|
66
|
-
r"(?:[ \t]*\([^)]*\))?[ \t]*:
|
|
67
|
-
|
|
79
|
+
r"^(?P<bullet>[-*+][ \t]+)?(?P<open>\*\*)?"
|
|
80
|
+
r"(?P<key>" + "|".join(re.escape(key) for key in _FIELD_KEYS) + r")"
|
|
81
|
+
r"(?P<close>\*\*)?(?:[ \t]*\([^)]*\))?[ \t]*:(?P<close_after>\*\*)?"
|
|
82
|
+
r"[ \t]*(?P<value>.*)$",
|
|
83
|
+
re.IGNORECASE,
|
|
68
84
|
)
|
|
69
85
|
_VERDICT_RE = re.compile(r"^(?P<token>[A-Z-]+)(?:\((?P<kind>[a-f])\))?$")
|
|
70
86
|
_PROSE_FIELDS = frozenset({"Explanation", "Note", "Prior dissent", "Your evidence"})
|
|
@@ -75,6 +91,20 @@ class VerdictBlockError(ValueError):
|
|
|
75
91
|
"""Raised when a worker response does not match the contract shape."""
|
|
76
92
|
|
|
77
93
|
|
|
94
|
+
def _field_match(line: str) -> tuple[str, str] | None:
|
|
95
|
+
"""`(canonical key, value)` when *line* is a field label, else ``None``."""
|
|
96
|
+
match = _FIELD_RE.match(line)
|
|
97
|
+
if match is None:
|
|
98
|
+
return None
|
|
99
|
+
opened = bool(match.group("open"))
|
|
100
|
+
closes = sum(1 for name in ("close", "close_after") if match.group(name))
|
|
101
|
+
if opened and closes != 1:
|
|
102
|
+
return None
|
|
103
|
+
if not opened and (closes or not match.group("bullet")):
|
|
104
|
+
return None
|
|
105
|
+
return _CANONICAL_KEYS[match.group("key").lower()], match.group("value")
|
|
106
|
+
|
|
107
|
+
|
|
78
108
|
@dataclass(frozen=True)
|
|
79
109
|
class FindingVote:
|
|
80
110
|
"""One worker's vote on one convergence finding, in schema vocabulary."""
|
|
@@ -112,12 +142,12 @@ def _scan_blocks(text: str) -> dict[str, dict[str, str]]:
|
|
|
112
142
|
continue
|
|
113
143
|
if not item_id:
|
|
114
144
|
continue
|
|
115
|
-
match =
|
|
145
|
+
match = _field_match(line)
|
|
116
146
|
if match:
|
|
117
|
-
field = match
|
|
147
|
+
field, value = match
|
|
118
148
|
if field in blocks[item_id]:
|
|
119
149
|
raise VerdictBlockError(f"item `{item_id}` has duplicate field `{field}`")
|
|
120
|
-
blocks[item_id][field] =
|
|
150
|
+
blocks[item_id][field] = value
|
|
121
151
|
elif field in _PROSE_FIELDS:
|
|
122
152
|
blocks[item_id][field] += "\n" + line
|
|
123
153
|
return {
|
|
@@ -180,8 +180,13 @@ def _passed_screens(state: WizardState) -> int:
|
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def _sim_answer(prompt: Prompt) -> str:
|
|
183
|
-
"""분모 추정 시뮬레이션의 기본답: pick 은
|
|
183
|
+
"""분모 추정 시뮬레이션의 기본답: pick 은 추천(체크박스는 추천 집합, 없으면
|
|
184
|
+
첫 옵션), text 는 빈 값."""
|
|
184
185
|
if prompt.kind == "pick" and prompt.options:
|
|
186
|
+
if prompt.multi:
|
|
187
|
+
flagged = [option.value for option in prompt.options if option.recommended]
|
|
188
|
+
if flagged:
|
|
189
|
+
return ",".join(flagged)
|
|
185
190
|
return prompt.options[0].value
|
|
186
191
|
return ""
|
|
187
192
|
|
|
@@ -191,7 +196,10 @@ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
|
191
196
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
192
197
|
try:
|
|
193
198
|
if _is_role_selection_step(prompt.step):
|
|
194
|
-
|
|
199
|
+
# 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다 — 기본답은
|
|
200
|
+
# 잘리지 않은 원본(추천 집합)에서 낸다.
|
|
201
|
+
original = next_role_prompt(state) or prompt
|
|
202
|
+
_submit_role_prompt(state, original, _sim_answer(original))
|
|
195
203
|
if prompt.step not in state.answered:
|
|
196
204
|
state.answered.append(prompt.step)
|
|
197
205
|
if prompt.step not in state.role_selection_order:
|
|
@@ -369,6 +377,12 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
369
377
|
if prompt.kind == "pick_group":
|
|
370
378
|
return _submit_group(state, prompt, value)
|
|
371
379
|
if _is_role_selection_step(prompt.step):
|
|
380
|
+
# 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다(`present_picker`).
|
|
381
|
+
# 답은 잘리지 않은 원본의 선택지로 검증한다 — 체크박스의 CSV 는 여러
|
|
382
|
+
# 쪽에 걸쳐 고른 값이다.
|
|
383
|
+
original = next_role_prompt(state)
|
|
384
|
+
if original is not None and original.step == prompt.step:
|
|
385
|
+
prompt = original
|
|
372
386
|
echo = _submit_role_prompt(state, prompt, value or "")
|
|
373
387
|
if prompt.step not in state.answered:
|
|
374
388
|
state.answered.append(prompt.step)
|
|
@@ -648,11 +648,20 @@ STEPS: list[Step] = [
|
|
|
648
648
|
# 이어가기에 필수인 직전 final-report 가 존재하면 use_defaults 와 무관하게
|
|
649
649
|
# 입력 기회를 노출한다. (과거: use_defaults 게이트 뒤에 숨어 "Use defaults"
|
|
650
650
|
# 를 고르면 직전 리포트가 통째로 누락됐다.)
|
|
651
|
+
# implementation-planning 의 `--clarification-response` 는 같은 phase 의
|
|
652
|
+
# 계획서만 받는다(`run._validate_planning_entry_inputs`). 그 계획서가 없는
|
|
653
|
+
# 첫 계획 run 에서 이 화면은 `없음 / 직접 입력` 뿐이고, 직접 입력으로 넣을
|
|
654
|
+
# 수 있는 파일도 없다 — 질문이 아니라 막다른 길이다(실측 2026-09-09, grok:
|
|
655
|
+
# 사용자는 직전 후보비교 run 의 user-responses/ 답변을 넘길 자리를 여기서
|
|
656
|
+
# 찾았다). 그 답변은 방향 선택 단계(`selected_direction_pick`)가 리포트와
|
|
657
|
+
# 함께 전달하므로, 계획 run 은 이어받을 계획서가 있을 때만 여기를 묻는다.
|
|
651
658
|
Step(S_CLARIFICATION_PICK,
|
|
652
659
|
applies=lambda s: (S_CLARIFICATION_PICK not in s.answered
|
|
653
660
|
and s.use_defaults is not None
|
|
654
|
-
and (s
|
|
655
|
-
|
|
661
|
+
and (bool(_suggest_latest_final_report(s))
|
|
662
|
+
if s.task_type == "implementation-planning"
|
|
663
|
+
else (s.use_defaults is False
|
|
664
|
+
or bool(_suggest_latest_final_report(s))))),
|
|
656
665
|
build=_build_clarification_pick, submit=_submit_clarification_pick,
|
|
657
666
|
owns=("clarification_response_path", "clarification_pending_text")),
|
|
658
667
|
Step(S_CLARIFICATION,
|