okstra 0.191.2 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/docs/architecture.md +2 -2
  2. package/docs/cli.md +2 -1
  3. package/docs/project-structure-overview.md +3 -1
  4. package/docs/task-process/README.md +2 -2
  5. package/docs/task-process/common-flow.md +4 -5
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/translator-worker.md +1 -1
  9. package/runtime/prompts/launch.template.md +1 -1
  10. package/runtime/prompts/lead/convergence.md +7 -3
  11. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  12. package/runtime/prompts/lead/report-writer.md +11 -8
  13. package/runtime/prompts/wizard/prompts.ko.json +52 -23
  14. package/runtime/python/okstra_ctl/convergence.py +63 -1
  15. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +231 -0
  16. package/runtime/python/okstra_ctl/dispatch_core.py +42 -22
  17. package/runtime/python/okstra_ctl/next_phase.py +18 -8
  18. package/runtime/python/okstra_ctl/plan_items.py +6 -4
  19. package/runtime/python/okstra_ctl/report_finalize.py +57 -10
  20. package/runtime/python/okstra_ctl/report_translation_dispatch.py +300 -0
  21. package/runtime/python/okstra_ctl/verdict_blocks.py +37 -7
  22. package/runtime/python/okstra_ctl/wizard/engine.py +16 -2
  23. package/runtime/python/okstra_ctl/wizard/registry.py +11 -2
  24. package/runtime/python/okstra_ctl/wizard/roles.py +364 -361
  25. package/runtime/python/okstra_ctl/wizard/state.py +39 -27
  26. package/runtime/python/okstra_ctl/wizard/steps_identity.py +50 -8
  27. package/runtime/python/okstra_ctl/wizard/steps_roles.py +1 -0
  28. package/runtime/skills/okstra-run/SKILL.md +2 -2
  29. package/runtime/validators/validate-run.py +2 -2
@@ -0,0 +1,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"^\*\*(?P<key>Verdict|Fixability|Note|Explanation|Prior dissent|Basis"
65
- r"|Your evidence)\*\*"
66
- r"(?:[ \t]*\([^)]*\))?[ \t]*:[ \t]*(?P<value>.*)$",
67
- re.MULTILINE,
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 = _FIELD_RE.match(line)
145
+ match = _field_match(line)
116
146
  if match:
117
- field = match.group("key")
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] = match.group("value")
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 은 옵션(추천), text 는 빈 값."""
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
- _submit_role_prompt(state, prompt, _sim_answer(prompt))
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.use_defaults is False
655
- or bool(_suggest_latest_final_report(s)))),
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,