okstra 0.122.0 → 0.123.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 (53) hide show
  1. package/README.md +4 -2
  2. package/docs/architecture/storage-model.md +14 -0
  3. package/docs/architecture.md +34 -6
  4. package/docs/cli.md +45 -5
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-rollup.md +1 -1
  7. package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
  8. package/docs/project-structure-overview.md +3 -2
  9. package/docs/task-process/implementation-planning.md +33 -9
  10. package/docs/task-process/implementation.md +21 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/lib/okstra/usage.sh +3 -3
  14. package/runtime/prompts/launch.template.md +5 -2
  15. package/runtime/prompts/lead/convergence.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  17. package/runtime/prompts/lead/plan-body-verification.md +29 -0
  18. package/runtime/prompts/lead/report-writer.md +9 -3
  19. package/runtime/prompts/profiles/_common-contract.md +1 -1
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +5 -0
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/final-verification.md +2 -0
  24. package/runtime/prompts/profiles/implementation-planning.md +11 -1
  25. package/runtime/prompts/wizard/prompts.ko.json +44 -0
  26. package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
  27. package/runtime/python/okstra_ctl/design_prep.py +1462 -0
  28. package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
  29. package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
  30. package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
  31. package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
  32. package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
  33. package/runtime/python/okstra_ctl/material.py +1 -1
  34. package/runtime/python/okstra_ctl/model_discovery.py +98 -0
  35. package/runtime/python/okstra_ctl/models.py +8 -3
  36. package/runtime/python/okstra_ctl/render.py +5 -0
  37. package/runtime/python/okstra_ctl/run.py +53 -5
  38. package/runtime/python/okstra_ctl/user_response.py +67 -2
  39. package/runtime/python/okstra_ctl/wizard.py +283 -3
  40. package/runtime/python/okstra_token_usage/report.py +11 -0
  41. package/runtime/schemas/final-report-v1.0.schema.json +336 -0
  42. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  43. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  44. package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +2 -2
  45. package/runtime/skills/okstra-user-response/SKILL.md +8 -6
  46. package/runtime/templates/reports/final-report.template.md +67 -0
  47. package/runtime/templates/reports/i18n/en.json +31 -0
  48. package/runtime/templates/reports/i18n/ko.json +31 -0
  49. package/runtime/validators/validate-run.py +426 -5
  50. package/runtime/validators/validate-schedule.py +4 -4
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/inspect/design-prep.mjs +23 -0
  53. package/src/lib/skill-catalog.mjs +2 -1
@@ -46,6 +46,7 @@ from .final_report_schema import load_schema
46
46
  from .final_report_paths import final_report_data_path as _final_report_data_path
47
47
  from .lead_events import LeadEvent, append_lead_event
48
48
  from .lead_runtime import ALLOWED_LEAD_RUNTIMES
49
+ from .model_discovery import normalize_execution_for_dispatch
49
50
  from .models import ModelAssignment, default_model, resolve_model_metadata
50
51
  from .schema_excerpt import build_schema_excerpt
51
52
  from .path_resolve import relative_to_project_root, resolve_user_file
@@ -1412,6 +1413,27 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
1412
1413
  )
1413
1414
 
1414
1415
 
1416
+ def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> str:
1417
+ """Route a role's catalog execution value through the CLI-identity normalizer
1418
+ so the manifest carries the dispatch-correct spelling. SSOT for the mapping
1419
+ lives in model_discovery; this is only the run.py call seam."""
1420
+ return normalize_execution_for_dispatch(
1421
+ provider=provider, execution=meta.execution, display=meta.display, role=role,
1422
+ )
1423
+
1424
+
1425
+ def _worker_execution_value(
1426
+ meta: ModelAssignment, provider: str, role: str, workers: list[str],
1427
+ ) -> str:
1428
+ """Normalize a worker role's execution value only when that provider's
1429
+ worker is in the resolved roster; otherwise return the raw catalog value.
1430
+ A worker that will not be dispatched must not probe its CLI or hard-fail
1431
+ the whole prep (the CLI-identity check belongs only to workers that run)."""
1432
+ if provider in workers:
1433
+ return _normalized_execution(meta, provider=provider, role=role)
1434
+ return meta.execution
1435
+
1436
+
1415
1437
  StageRunClaim = _implementation_stage.StageRunClaim
1416
1438
 
1417
1439
 
@@ -1577,7 +1599,9 @@ def _write_instruction_set_sources(
1577
1599
  instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
1578
1600
  instruction_set.mkdir(parents=True, exist_ok=True)
1579
1601
  profile_rendered = profile_content
1580
- for key in (
1602
+ if inp.task_type == "implementation":
1603
+ profile_rendered += "\n\n{{DESIGN_PREP_CONTEXT}}"
1604
+ profile_tokens = (
1581
1605
  "EXECUTOR_PROVIDER",
1582
1606
  "EXECUTOR_DISPLAY_NAME",
1583
1607
  "EXECUTOR_WORKER_AGENT",
@@ -1588,10 +1612,28 @@ def _write_instruction_set_sources(
1588
1612
  "EXECUTOR_WORKTREE_BASE_REF",
1589
1613
  "EXECUTOR_WORKTREE_STATUS",
1590
1614
  "EXECUTOR_WORKTREE_NOTE",
1615
+ "DESIGN_PREP_CONTEXT",
1591
1616
  "PHASE_FORBIDDEN_ACTIONS",
1592
- ):
1617
+ )
1618
+ for key in profile_tokens:
1593
1619
  profile_rendered = profile_rendered.replace("{{" + key + "}}", ctx.get(key, ""))
1594
1620
  (instruction_set / "analysis-profile.md").write_text(profile_rendered, encoding="utf-8")
1621
+ if inp.task_type == "implementation":
1622
+ executor_source = (
1623
+ Path(ctx["WORKSPACE_ROOT"])
1624
+ / "prompts"
1625
+ / "profiles"
1626
+ / "_implementation-executor.md"
1627
+ )
1628
+ executor_rendered = executor_source.read_text(encoding="utf-8")
1629
+ for key in profile_tokens:
1630
+ executor_rendered = executor_rendered.replace(
1631
+ "{{" + key + "}}", ctx.get(key, "")
1632
+ )
1633
+ (instruction_set / "implementation-executor.md").write_text(
1634
+ executor_rendered,
1635
+ encoding="utf-8",
1636
+ )
1595
1637
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
1596
1638
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
1597
1639
  if inp.clarification_response_path:
@@ -2058,16 +2100,22 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2058
2100
  "CLAUDE_WORKER_MODEL": cw.display,
2059
2101
  "CLAUDE_WORKER_MODEL_EXECUTION_VALUE": cw.execution,
2060
2102
  "CODEX_WORKER_MODEL": co.display,
2061
- "CODEX_WORKER_MODEL_EXECUTION_VALUE": co.execution,
2103
+ "CODEX_WORKER_MODEL_EXECUTION_VALUE": _worker_execution_value(
2104
+ co, "codex", "worker", workers,
2105
+ ),
2062
2106
  "ANTIGRAVITY_WORKER_MODEL": ge.display,
2063
- "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": ge.execution,
2107
+ "ANTIGRAVITY_WORKER_MODEL_EXECUTION_VALUE": _worker_execution_value(
2108
+ ge, "antigravity", "worker", workers,
2109
+ ),
2064
2110
  "REPORT_WRITER_MODEL": rw.display,
2065
2111
  "REPORT_WRITER_MODEL_EXECUTION_VALUE": rw.execution,
2066
2112
  "EXECUTOR_PROVIDER": executor_provider,
2067
2113
  "EXECUTOR_DISPLAY_NAME": executor_display_name,
2068
2114
  "EXECUTOR_WORKER_AGENT": executor_worker_agent,
2069
2115
  "EXECUTOR_MODEL_DISPLAY": executor_model_meta.display,
2070
- "EXECUTOR_MODEL_EXECUTION_VALUE": executor_model_meta.execution,
2116
+ "EXECUTOR_MODEL_EXECUTION_VALUE": _normalized_execution(
2117
+ executor_model_meta, provider=executor_provider, role="executor",
2118
+ ),
2071
2119
  "CRITIC_CHOICE": critic_choice,
2072
2120
  "RELATED_TASKS_JSON": related_tasks_json_str,
2073
2121
  "RELATED_TASKS_BULLETS": bullets,
@@ -26,6 +26,7 @@ from okstra_ctl.clarification_items import (
26
26
  parse_clarification_rows,
27
27
  scan_approval_gate,
28
28
  section_1_present_but_unparsed,
29
+ _section_1_slice,
29
30
  )
30
31
 
31
32
  _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
@@ -146,8 +147,14 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
146
147
  return out[:limit] if limit > 0 else out
147
148
 
148
149
 
149
- _SECTION_REF_RE = re.compile(r"§[\d.]+|C-\d+|[\w./-]+\.\w+:\d+")
150
+ # ID tokens are the report body's own cross-reference labels (RB-002, FU-001,
151
+ # O-002, C-017 …) — always uppercase-led so `gpt-5` and lowercase slugs don't
152
+ # match. `§x.y` and `path.ext:line` are the other two ref shapes.
153
+ _SECTION_REF_RE = re.compile(r"§[\d.]+|[A-Z]{1,4}-\d+|[\w./-]+\.\w+:\d+")
154
+ _ID_TOKEN_RE = re.compile(r"^[A-Z]{1,4}-\d+$")
150
155
  _ALTERNATIVES_CUE = "Alternatives:"
156
+ _DEFINITION_SNIPPET_CAP = 200
157
+ _SNIPPET_NOISE_RE = re.compile(r'<a id="[^"]*"></a>|`|\*\*')
151
158
 
152
159
 
153
160
  def _alternatives_from_expected(expected_form: str) -> list[str]:
@@ -159,6 +166,63 @@ def _alternatives_from_expected(expected_form: str) -> list[str]:
159
166
  return [p.strip(" .,;—-") for p in parts if p.strip(" .,;—-")]
160
167
 
161
168
 
169
+ def _clean_snippet(line: str) -> str:
170
+ s = _SNIPPET_NOISE_RE.sub("", line).strip()
171
+ s = re.sub(r"^[-*+]\s+|^\d+\.\s+", "", s) # drop a leading list marker
172
+ s = re.sub(r"\s*\|\s*", " | ", s).strip(" |")
173
+ s = re.sub(r"\s+", " ", s)
174
+ if len(s) > _DEFINITION_SNIPPET_CAP:
175
+ s = s[:_DEFINITION_SNIPPET_CAP].rstrip() + "…"
176
+ return s
177
+
178
+
179
+ def _resolve_id_token(report_text: str, token: str, s1_slice: str | None) -> str | None:
180
+ """Find where an internal report token (RB-002, FU-001, …) is defined.
181
+
182
+ Prefer a body line where the token is followed by a `:`/`—`/`-`/`)`
183
+ separator (its definition), else the first body occurrence. §1 rows are
184
+ skipped so the clarification statement being explained is never returned
185
+ as its own definition."""
186
+ definition_re = re.compile(rf"{re.escape(token)}\**\s*[)\]]?\s*[:—–-]")
187
+ fallback: str | None = None
188
+ for line in report_text.splitlines():
189
+ if token not in line:
190
+ continue
191
+ stripped = line.strip()
192
+ if s1_slice is not None and stripped and stripped in s1_slice:
193
+ continue
194
+ if definition_re.search(stripped):
195
+ return _clean_snippet(stripped)
196
+ if fallback is None:
197
+ fallback = _clean_snippet(stripped)
198
+ return fallback
199
+
200
+
201
+ def _resolve_section_ref(report_text: str, ref: str) -> str | None:
202
+ num = ref.lstrip("§")
203
+ heading_re = re.compile(rf"^#{{1,6}}\s+{re.escape(num)}(?=[.\s]).*$", re.MULTILINE)
204
+ m = heading_re.search(report_text)
205
+ return _clean_snippet(m.group(0).lstrip("# ").strip()) if m else None
206
+
207
+
208
+ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
209
+ """Resolve each context ref to its plain-text definition from the report
210
+ body, or None when the report text alone cannot resolve it (a `path:line`
211
+ pointer). The deterministic lookup the skill uses to rewrite each
212
+ clarification question in self-contained plain language."""
213
+ s1_slice = _section_1_slice(report_text)
214
+ resolved: list[dict] = []
215
+ for ref in refs:
216
+ if ref.startswith("§"):
217
+ definition = _resolve_section_ref(report_text, ref)
218
+ elif _ID_TOKEN_RE.match(ref):
219
+ definition = _resolve_id_token(report_text, ref, s1_slice)
220
+ else:
221
+ definition = None
222
+ resolved.append({"ref": ref, "definition": definition})
223
+ return resolved
224
+
225
+
162
226
  def show_open_rows(report_path: Path) -> dict:
163
227
  text = report_path.read_text(encoding="utf-8")
164
228
  rows = []
@@ -172,7 +236,8 @@ def show_open_rows(report_path: Path) -> dict:
172
236
  "status": it.status, "statement": statement,
173
237
  "recommended": expected, # raw cell keeps answer + rationale
174
238
  "alternatives": _alternatives_from_expected(expected),
175
- "contextRefs": refs})
239
+ "contextRefs": refs,
240
+ "resolvedRefs": resolve_refs(text, refs)})
176
241
  return {"reportPath": str(report_path), "rows": rows}
177
242
 
178
243
 
@@ -37,6 +37,12 @@ from okstra_ctl.models import (
37
37
  resolve_model_metadata,
38
38
  )
39
39
  from okstra_ctl.clarification_items import scan_approval_gate, user_response_sidecars
40
+ from okstra_ctl.design_prep import (
41
+ DesignPrepError,
42
+ load_design_prep_items,
43
+ resolve_design_prep,
44
+ write_design_prep_input,
45
+ )
40
46
  from okstra_ctl.final_report_paths import final_report_data_path
41
47
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
42
48
  from okstra_ctl.run import (
@@ -303,6 +309,9 @@ S_BASE_REF_TEXT = "base_ref_text"
303
309
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
304
310
  S_APPROVED_PLAN = "approved_plan"
305
311
  S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
312
+ S_DESIGN_PREP_DECISION = "design_prep_decision"
313
+ S_DESIGN_PREP_OVERRIDES = "design_prep_overrides"
314
+ S_DESIGN_PREP_CONFIRM = "design_prep_confirm"
306
315
  S_STAGE_PICK = "stage_pick"
307
316
  S_HANDOFF_STAGE_PICK = "handoff_stage_pick"
308
317
  S_EXECUTOR = "executor"
@@ -403,6 +412,11 @@ class WizardState:
403
412
  # approve-confirm 단계가 3-옵션(yes_apply/yes/no)으로 확장된다.
404
413
  html_approval_sidecar: str = ""
405
414
  html_approval_option: str = ""
415
+ design_prep_queue: list[str] = field(default_factory=list)
416
+ design_prep_current: str = ""
417
+ design_prep_decision: str = ""
418
+ design_prep_overrides_json: str = ""
419
+ design_prep_notes: str = ""
406
420
  selected_stage: str = "auto"
407
421
  selected_stages: str = "" # implementation 다중선택 위상정렬 CSV
408
422
  executor: str = ""
@@ -1077,6 +1091,7 @@ class Step:
1077
1091
  submit: Callable[[WizardState, str], Optional[str]]
1078
1092
  # Field names this step owns; resetting the step clears them.
1079
1093
  owns: tuple[str, ...] = ()
1094
+ repeatable: bool = False
1080
1095
 
1081
1096
 
1082
1097
  def _opt(value: str, label: str = "", description: str = "") -> Option:
@@ -1939,6 +1954,234 @@ def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str
1939
1954
  return echo
1940
1955
 
1941
1956
 
1957
+ def _design_prep_items_by_id(state: WizardState) -> dict[str, dict[str, Any]]:
1958
+ try:
1959
+ items = load_design_prep_items(Path(state.approved_plan_path))
1960
+ except DesignPrepError as exc:
1961
+ raise WizardError(str(exc)) from exc
1962
+ return {str(item["id"]): item for item in items}
1963
+
1964
+
1965
+ def _ensure_design_prep_queue(state: WizardState) -> None:
1966
+ if state.design_prep_current or state.design_prep_queue:
1967
+ return
1968
+ if S_DESIGN_PREP_DECISION in state.answered:
1969
+ return
1970
+ report_path = Path(state.approved_plan_path)
1971
+ if not re.fullmatch(
1972
+ r"final-report-implementation-planning-\d+\.md",
1973
+ report_path.name,
1974
+ ):
1975
+ return
1976
+ data_path = report_path.with_name(
1977
+ report_path.name.removesuffix(".md") + ".data.json"
1978
+ )
1979
+ if not data_path.is_file():
1980
+ return
1981
+ try:
1982
+ items = load_design_prep_items(report_path)
1983
+ effective = resolve_design_prep(report_path)
1984
+ except DesignPrepError as exc:
1985
+ raise WizardError(str(exc)) from exc
1986
+ decisions = {
1987
+ str(item["id"]): item.get("decision")
1988
+ for item in effective.effective_items
1989
+ }
1990
+ unresolved = [
1991
+ item for item in items
1992
+ if item.get("status") in ("blocked", "provisional")
1993
+ and decisions.get(str(item["id"])) == "unanswered"
1994
+ ]
1995
+ unresolved.sort(key=lambda item: item.get("status") != "blocked")
1996
+ state.design_prep_queue = [str(item["id"]) for item in unresolved]
1997
+ _advance_design_prep_item(state)
1998
+
1999
+
2000
+ def _advance_design_prep_item(state: WizardState) -> None:
2001
+ state.design_prep_current = (
2002
+ state.design_prep_queue.pop(0) if state.design_prep_queue else ""
2003
+ )
2004
+ state.design_prep_decision = ""
2005
+ state.design_prep_overrides_json = ""
2006
+ state.design_prep_notes = ""
2007
+
2008
+
2009
+ def _current_design_prep_item(state: WizardState) -> dict[str, Any]:
2010
+ item = _design_prep_items_by_id(state).get(state.design_prep_current)
2011
+ if item is None:
2012
+ raise WizardError(
2013
+ f"design preparation item is missing: {state.design_prep_current}"
2014
+ )
2015
+ return item
2016
+
2017
+
2018
+ def _design_prep_decision_applies(state: WizardState) -> bool:
2019
+ if state.task_type != "implementation" or not state.approved_plan_path:
2020
+ return False
2021
+ _ensure_design_prep_queue(state)
2022
+ return bool(state.design_prep_current and not state.design_prep_decision)
2023
+
2024
+
2025
+ def _build_design_prep_decision(state: WizardState) -> Prompt:
2026
+ item = _current_design_prep_item(state)
2027
+ proposal = item.get("aiProposal") or {}
2028
+ t = _p(
2029
+ state.workspace_root,
2030
+ S_DESIGN_PREP_DECISION,
2031
+ item_id=str(item["id"]),
2032
+ title=str(item["title"]),
2033
+ stage_refs=json.dumps(item.get("stageRefs") or [], ensure_ascii=False),
2034
+ proposal_summary=str(proposal.get("summary") or ""),
2035
+ details=json.dumps(proposal.get("details") or [], ensure_ascii=False),
2036
+ assumptions=json.dumps(proposal.get("assumptions") or [], ensure_ascii=False),
2037
+ guardrails=json.dumps(item.get("guardrails") or [], ensure_ascii=False),
2038
+ request_path=str(item.get("requestPath") or ""),
2039
+ )
2040
+ return Prompt(
2041
+ step=S_DESIGN_PREP_DECISION,
2042
+ kind="pick",
2043
+ label=t["label"],
2044
+ options=[_opt(value, label) for value, label in _static_options(t)],
2045
+ echo_template=t["echo_template"],
2046
+ )
2047
+
2048
+
2049
+ def _submit_design_prep_decision(
2050
+ state: WizardState,
2051
+ value: str,
2052
+ ) -> Optional[str]:
2053
+ t = _p(state.workspace_root, S_DESIGN_PREP_DECISION,
2054
+ item_id="", title="", stage_refs="", proposal_summary="",
2055
+ details="", assumptions="", guardrails="", request_path="")
2056
+ if value not in ("accept-draft", "modify-draft", "reject-draft", "later"):
2057
+ raise WizardError(t["errors"]["invalid_decision"])
2058
+ if value == "later":
2059
+ item_id = state.design_prep_current
2060
+ _advance_design_prep_item(state)
2061
+ return t["echo_variants"]["later"].format(item_id=item_id)
2062
+ state.design_prep_decision = value
2063
+ state.design_prep_overrides_json = ""
2064
+ state.design_prep_notes = ""
2065
+ return t["echo_template"].format(value=value)
2066
+
2067
+
2068
+ def _design_prep_overrides_applies(state: WizardState) -> bool:
2069
+ if state.design_prep_decision == "modify-draft":
2070
+ return not state.design_prep_overrides_json
2071
+ if state.design_prep_decision == "reject-draft":
2072
+ return not state.design_prep_notes
2073
+ return False
2074
+
2075
+
2076
+ def _build_design_prep_overrides(state: WizardState) -> Prompt:
2077
+ t = _p(
2078
+ state.workspace_root,
2079
+ S_DESIGN_PREP_OVERRIDES,
2080
+ item_id=state.design_prep_current,
2081
+ decision=state.design_prep_decision,
2082
+ )
2083
+ return Prompt(
2084
+ step=S_DESIGN_PREP_OVERRIDES,
2085
+ kind="text",
2086
+ label=t["label"],
2087
+ echo_template=t["echo_template"],
2088
+ )
2089
+
2090
+
2091
+ def _submit_design_prep_overrides(
2092
+ state: WizardState,
2093
+ value: str,
2094
+ ) -> Optional[str]:
2095
+ t = _p(state.workspace_root, S_DESIGN_PREP_OVERRIDES,
2096
+ item_id=state.design_prep_current,
2097
+ decision=state.design_prep_decision)
2098
+ if state.design_prep_decision == "reject-draft":
2099
+ if not value.strip():
2100
+ raise WizardError(t["errors"]["note_required"])
2101
+ state.design_prep_notes = value.strip()
2102
+ return t["echo_variants"]["note"]
2103
+ try:
2104
+ overrides = json.loads(value)
2105
+ except json.JSONDecodeError as exc:
2106
+ raise WizardError(t["errors"]["invalid_json"].format(error=exc)) from exc
2107
+ if not isinstance(overrides, dict):
2108
+ raise WizardError(t["errors"]["object_required"])
2109
+ state.design_prep_overrides_json = json.dumps(
2110
+ overrides, ensure_ascii=False, sort_keys=True
2111
+ )
2112
+ return t["echo_variants"]["overrides"]
2113
+
2114
+
2115
+ def _design_prep_confirm_applies(state: WizardState) -> bool:
2116
+ if state.design_prep_decision == "accept-draft":
2117
+ return True
2118
+ if state.design_prep_decision == "modify-draft":
2119
+ return bool(state.design_prep_overrides_json)
2120
+ if state.design_prep_decision == "reject-draft":
2121
+ return bool(state.design_prep_notes)
2122
+ return False
2123
+
2124
+
2125
+ def _build_design_prep_confirm(state: WizardState) -> Prompt:
2126
+ item = _current_design_prep_item(state)
2127
+ t = _p(
2128
+ state.workspace_root,
2129
+ S_DESIGN_PREP_CONFIRM,
2130
+ item_id=state.design_prep_current,
2131
+ decision=state.design_prep_decision,
2132
+ item_snapshot=json.dumps(item, ensure_ascii=False, indent=2, sort_keys=True),
2133
+ overrides=state.design_prep_overrides_json or "{}",
2134
+ notes=state.design_prep_notes or "(none)",
2135
+ )
2136
+ return Prompt(
2137
+ step=S_DESIGN_PREP_CONFIRM,
2138
+ kind="pick",
2139
+ label=t["label"],
2140
+ options=[_opt(value, label) for value, label in _static_options(t)],
2141
+ echo_template=t["echo_template"],
2142
+ )
2143
+
2144
+
2145
+ def _submit_design_prep_confirm(
2146
+ state: WizardState,
2147
+ value: str,
2148
+ ) -> Optional[str]:
2149
+ item_id = state.design_prep_current
2150
+ t = _p(state.workspace_root, S_DESIGN_PREP_CONFIRM,
2151
+ item_id=item_id, decision=state.design_prep_decision,
2152
+ item_snapshot="", overrides="", notes="")
2153
+ if value == "no":
2154
+ state.design_prep_decision = ""
2155
+ state.design_prep_overrides_json = ""
2156
+ state.design_prep_notes = ""
2157
+ return t["echo_variants"]["revise"].format(item_id=item_id)
2158
+ if value != "yes":
2159
+ raise WizardError(t["errors"]["confirmation_required"])
2160
+ overrides = json.loads(state.design_prep_overrides_json or "{}")
2161
+ try:
2162
+ path = write_design_prep_input(
2163
+ Path(state.approved_plan_path),
2164
+ item_id,
2165
+ state.design_prep_decision,
2166
+ overrides,
2167
+ state.design_prep_notes,
2168
+ captured_by="okstra-wizard",
2169
+ )
2170
+ except DesignPrepError as exc:
2171
+ raise WizardError(str(exc)) from exc
2172
+ match = re.search(r"-r(\d+)-([0-9a-f-]{36})\.md$", path.name)
2173
+ if match is None:
2174
+ raise WizardError(f"design preparation writer returned invalid path: {path}")
2175
+ echo = t["echo_variants"]["written"].format(
2176
+ item_id=item_id,
2177
+ revision=int(match.group(1)),
2178
+ input_id=match.group(2),
2179
+ path=path,
2180
+ )
2181
+ _advance_design_prep_item(state)
2182
+ return echo
2183
+
2184
+
1942
2185
  def _build_stage_pick(state: WizardState) -> Prompt:
1943
2186
  """Parse the Stage Map from the approved plan and build the stage picker."""
1944
2187
  t = _p(state.workspace_root, "stage_pick")
@@ -3116,6 +3359,27 @@ STEPS: list[Step] = [
3116
3359
  and S_APPROVE_PLAN_CONFIRM not in s.answered),
3117
3360
  build=_build_approve_plan_confirm, submit=_submit_approve_plan_confirm,
3118
3361
  owns=("approve_plan_candidate",)),
3362
+ Step(S_DESIGN_PREP_DECISION,
3363
+ applies=_design_prep_decision_applies,
3364
+ build=_build_design_prep_decision,
3365
+ submit=_submit_design_prep_decision,
3366
+ owns=("design_prep_queue", "design_prep_current",
3367
+ "design_prep_decision", "design_prep_overrides_json",
3368
+ "design_prep_notes"),
3369
+ repeatable=True),
3370
+ Step(S_DESIGN_PREP_OVERRIDES,
3371
+ applies=_design_prep_overrides_applies,
3372
+ build=_build_design_prep_overrides,
3373
+ submit=_submit_design_prep_overrides,
3374
+ owns=("design_prep_overrides_json", "design_prep_notes"),
3375
+ repeatable=True),
3376
+ Step(S_DESIGN_PREP_CONFIRM,
3377
+ applies=_design_prep_confirm_applies,
3378
+ build=_build_design_prep_confirm,
3379
+ submit=_submit_design_prep_confirm,
3380
+ owns=("design_prep_decision", "design_prep_overrides_json",
3381
+ "design_prep_notes"),
3382
+ repeatable=True),
3119
3383
  Step(S_STAGE_PICK,
3120
3384
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
3121
3385
  and bool(s.approved_plan_path)
@@ -3374,6 +3638,9 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3374
3638
  "base_ref_pending_text": False, "approved_plan_path": "",
3375
3639
  "approved_plan_pending_text": False, "approve_plan_candidate": "",
3376
3640
  "html_approval_sidecar": "", "html_approval_option": "",
3641
+ "design_prep_queue": [], "design_prep_current": "",
3642
+ "design_prep_decision": "", "design_prep_overrides_json": "",
3643
+ "design_prep_notes": "",
3377
3644
  "selected_stage": "auto",
3378
3645
  "selected_stages": "",
3379
3646
  "handoff_mode": "", "handoff_stages": "",
@@ -3424,9 +3691,9 @@ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
3424
3691
  """
3425
3692
  members: list[Prompt] = []
3426
3693
  for sid in PROMPT_GROUPS[group_id]:
3427
- if sid in state.answered:
3428
- continue
3429
3694
  step = STEP_BY_ID[sid]
3695
+ if sid in state.answered and not step.repeatable:
3696
+ continue
3430
3697
  if not step.applies(state):
3431
3698
  continue
3432
3699
  members.append(step.build(state))
@@ -3445,7 +3712,7 @@ def next_prompt(state: WizardState) -> Prompt:
3445
3712
  if state.confirmed:
3446
3713
  return Prompt(step=S_DONE, kind="done")
3447
3714
  for step in STEPS:
3448
- if step.id in state.answered:
3715
+ if step.id in state.answered and not step.repeatable:
3449
3716
  continue
3450
3717
  if step.applies(state):
3451
3718
  group_id = _STEP_TO_GROUP.get(step.id)
@@ -3488,6 +3755,19 @@ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
3488
3755
  if q.step not in state.answered:
3489
3756
  state.answered.append(q.step)
3490
3757
  return
3758
+ if prompt.step == S_DESIGN_PREP_CONFIRM:
3759
+ _advance_design_prep_item(state)
3760
+ if prompt.step not in state.answered:
3761
+ state.answered.append(prompt.step)
3762
+ return
3763
+ if prompt.step == S_DESIGN_PREP_OVERRIDES:
3764
+ if state.design_prep_decision == "modify-draft":
3765
+ state.design_prep_overrides_json = "{}"
3766
+ else:
3767
+ state.design_prep_notes = "simulation"
3768
+ if prompt.step not in state.answered:
3769
+ state.answered.append(prompt.step)
3770
+ return
3491
3771
  STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
3492
3772
  if prompt.step not in state.answered:
3493
3773
  state.answered.append(prompt.step)
@@ -14,6 +14,10 @@ _HERE = Path(__file__).resolve().parent.parent
14
14
  if str(_HERE) not in sys.path:
15
15
  sys.path.insert(0, str(_HERE))
16
16
 
17
+ from okstra_ctl.design_prep import ( # noqa: E402
18
+ DesignPrepError,
19
+ materialize_design_prep_requests,
20
+ )
17
21
  from okstra_ctl.final_report_paths import final_report_markdown_path # noqa: E402
18
22
  from okstra_ctl.render_final_report import ( # noqa: E402
19
23
  FinalReportRenderError,
@@ -216,4 +220,11 @@ def populate_and_render(
216
220
  bytes_written = render_to_file(data_path, output_path)
217
221
  except FinalReportRenderError as exc:
218
222
  raise SubstituteRefusedError(f"render after substitute failed: {exc}") from exc
223
+ if data_path.name.startswith("final-report-implementation-planning-"):
224
+ try:
225
+ materialize_design_prep_requests(data_path)
226
+ except DesignPrepError as exc:
227
+ raise SubstituteRefusedError(
228
+ f"design-prep request materialization failed: {exc}"
229
+ ) from exc
219
230
  return changes, bytes_written