okstra 0.73.0 → 0.74.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 (30) hide show
  1. package/docs/project-structure-overview.md +1 -0
  2. package/docs/superpowers/plans/2026-06-12-html-plan-approval.md +1000 -0
  3. package/docs/superpowers/specs/2026-06-12-html-plan-approval-design.md +85 -0
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/SKILL.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +5 -5
  8. package/runtime/agents/workers/gemini-worker.md +5 -5
  9. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  10. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  11. package/runtime/prompts/wizard/prompts.ko.json +11 -2
  12. package/runtime/python/okstra_ctl/clarification_items.py +6 -22
  13. package/runtime/python/okstra_ctl/md_table.py +56 -0
  14. package/runtime/python/okstra_ctl/render_final_report.py +8 -1
  15. package/runtime/python/okstra_ctl/report_views.py +155 -20
  16. package/runtime/python/okstra_ctl/user_response.py +55 -0
  17. package/runtime/python/okstra_ctl/wizard.py +116 -11
  18. package/runtime/python/okstra_token_usage/collect.py +5 -3
  19. package/runtime/skills/okstra-report-writer/SKILL.md +3 -2
  20. package/runtime/skills/okstra-run/SKILL.md +1 -1
  21. package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
  22. package/runtime/templates/reports/final-report.template.md +55 -50
  23. package/runtime/templates/reports/report.css +21 -7
  24. package/runtime/templates/reports/report.js +34 -7
  25. package/runtime/templates/reports/user-response.template.md +15 -0
  26. package/runtime/validators/validate-implementation-plan-stages.py +9 -2
  27. package/runtime/validators/validate-report-views.py +2 -1
  28. package/runtime/validators/validate-run.py +6 -17
  29. package/runtime/validators/validate-schedule.py +9 -2
  30. package/runtime/validators/validate_improvement_report.py +2 -1
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import hashlib
24
24
  import html
25
+ import json
25
26
  import re
26
27
  from dataclasses import dataclass
27
28
  from pathlib import Path
@@ -56,12 +57,13 @@ def _strip_leading_frontmatter(text: str) -> str:
56
57
  return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
57
58
 
58
59
  from .clarification_items import (
59
- _is_separator_row,
60
60
  _section_1_slice,
61
61
  _split_pipe_row,
62
62
  parse_clarification_items,
63
63
  parse_meta_cell,
64
+ scan_approval_gate,
64
65
  )
66
+ from .md_table import is_separator_row as _is_separator_row
65
67
 
66
68
 
67
69
  # --------------------------------------------------------------------------- #
@@ -91,7 +93,14 @@ class RunMeta:
91
93
  source_report: str # relative path of the .md the HTML is derived from
92
94
 
93
95
 
94
- def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
96
+ def render_html(
97
+ src_md: str,
98
+ *,
99
+ run_meta: RunMeta,
100
+ css: str,
101
+ js: str,
102
+ approval_ctx: PlanApprovalContext | None = None,
103
+ ) -> str:
95
104
  """Return a single self-contained HTML document for ``src_md``.
96
105
 
97
106
  ``css`` / ``js`` are inlined verbatim. No external URLs are written
@@ -127,6 +136,7 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
127
136
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
128
137
  f"</header>\n"
129
138
  f"<main>{body_html}</main>\n"
139
+ f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
130
140
  f"<footer class=\"report-footer\">\n"
131
141
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
132
142
  f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
@@ -321,7 +331,15 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
321
331
  rows: list[list[str]] = []
322
332
  i = start + 2 # skip header + separator
323
333
  while i < len(lines) and lines[i].lstrip().startswith("|"):
324
- rows.append(_split_pipe_row(lines[i]))
334
+ cells = _split_pipe_row(lines[i])
335
+ # A row with more cells than the header means an unescaped `|`
336
+ # inside cell text (the writer should have used `\|`). Merge the
337
+ # overflow back into the last header column instead of spilling
338
+ # extra <td>s outside the table border.
339
+ if len(cells) > len(header_cells):
340
+ keep = len(header_cells) - 1
341
+ cells = cells[:keep] + ["|".join(cells[keep:])]
342
+ rows.append(cells)
325
343
  i += 1
326
344
 
327
345
  # §1 Clarification Items is the only interactive table. Its short columns
@@ -620,6 +638,22 @@ def _plain_len(raw_cell: str) -> int:
620
638
  return len(_INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip())
621
639
 
622
640
 
641
+ # Under report.css `td.td-narrow { white-space: nowrap }` a cell's longest
642
+ # <br>-separated line dictates the column's intrinsic width. Lines past half
643
+ # of `main`'s 120ch budget would crush or push the prose neighbours off-screen,
644
+ # so such a column must not be pinned narrow even when its header is
645
+ # whitelisted.
646
+ _NARROW_WHITELIST_MAX_LINE_LEN = 60
647
+ _BR_TAG_RE = re.compile(r"<br\s*/?\s*>", re.IGNORECASE)
648
+
649
+
650
+ def _max_plain_line_len(raw_cell: str) -> int:
651
+ return max(
652
+ (_plain_len(segment) for segment in _BR_TAG_RE.split(raw_cell or "")),
653
+ default=0,
654
+ )
655
+
656
+
623
657
  def _matches_narrow_whitelist(header: str) -> bool:
624
658
  plain = _INLINE_MD_STRIP_RE.sub("", header or "").strip().lower()
625
659
  return any(plain.startswith(p) for p in _NARROW_HEADER_PREFIXES)
@@ -634,8 +668,11 @@ def _narrow_columns(
634
668
  A column qualifies when EITHER:
635
669
  (a) its header matches `_NARROW_HEADER_PREFIXES` — these are
636
670
  ``ID`` / ``출처`` / ``에이전트`` etc. whose values are
637
- conventionally short across every final-report even when a
638
- single row drops in 60+ chars of prose; OR
671
+ conventionally short (or <br>-stacked into short lines) across
672
+ every final-report UNLESS some cell carries a single line
673
+ longer than ``_NARROW_WHITELIST_MAX_LINE_LEN``: nowrap would
674
+ let that line dictate the column width and starve the prose
675
+ neighbours; OR
639
676
  (b) every cell in the column (header + body) fits within
640
677
  ``_NARROW_COL_MAX_PLAIN_LEN`` plain chars — this catches
641
678
  ad-hoc short columns that the whitelist did not anticipate.
@@ -645,14 +682,13 @@ def _narrow_columns(
645
682
  """
646
683
  narrow: set[int] = set()
647
684
  for col, header in enumerate(header_cells):
685
+ column_cells = [header] + [row[col] for row in rows if col < len(row)]
648
686
  if _matches_narrow_whitelist(header):
649
- narrow.add(col)
687
+ longest_line = max(_max_plain_line_len(c) for c in column_cells)
688
+ if longest_line <= _NARROW_WHITELIST_MAX_LINE_LEN:
689
+ narrow.add(col)
650
690
  continue
651
- max_len = _plain_len(header)
652
- for row in rows:
653
- if col < len(row):
654
- max_len = max(max_len, _plain_len(row[col]))
655
- if max_len <= _NARROW_COL_MAX_PLAIN_LEN:
691
+ if max(_plain_len(c) for c in column_cells) <= _NARROW_COL_MAX_PLAIN_LEN:
656
692
  narrow.add(col)
657
693
  return narrow
658
694
 
@@ -697,11 +733,20 @@ class UserResponseEntry:
697
733
  rationale: Optional[str] = None
698
734
 
699
735
 
736
+ @dataclass(frozen=True)
737
+ class UserResponseApproval:
738
+ """HTML Plan Approval 위젯의 Export 결과. ``implementation_option`` 이 빈
739
+ 문자열이면 라인을 생략한다 (소비 측은 Recommended Option 폴백)."""
740
+ approved: bool
741
+ implementation_option: str = ""
742
+
743
+
700
744
  def serialize_user_response(
701
745
  *,
702
746
  run_meta: RunMeta,
703
747
  entries: list[UserResponseEntry],
704
748
  created_at: str,
749
+ approval: UserResponseApproval | None = None,
705
750
  ) -> str:
706
751
  """Return the canonical markdown text the HTML 'Export user
707
752
  response' button must produce. Used by validators to confirm that
@@ -720,6 +765,7 @@ def serialize_user_response(
720
765
  "\n"
721
766
  "# User Response\n"
722
767
  )
768
+ has_approval = approval is not None and approval.approved
723
769
  body_chunks: list[str] = []
724
770
  for e in entries:
725
771
  chunk = (
@@ -731,8 +777,13 @@ def serialize_user_response(
731
777
  if e.rationale:
732
778
  chunk += f"- Rationale: {e.rationale.strip()}\n"
733
779
  body_chunks.append(chunk)
734
- if not entries:
780
+ if not entries and not has_approval:
735
781
  body_chunks.append("\n_(No user responses recorded.)_\n")
782
+ if has_approval:
783
+ chunk = "\n## APPROVAL\n- Approved: true\n"
784
+ if approval.implementation_option:
785
+ chunk += f"- Implementation-Option: {approval.implementation_option.strip()}\n"
786
+ body_chunks.append(chunk)
736
787
  return head + "".join(body_chunks)
737
788
 
738
789
 
@@ -754,6 +805,86 @@ def report_has_clarification_items(src_md: str) -> bool:
754
805
  )
755
806
 
756
807
 
808
+ @dataclass(frozen=True)
809
+ class PlanApprovalContext:
810
+ """Plan Approval 위젯 렌더 입력. ``disabled_reason`` 이 비어 있지 않으면
811
+ 위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준)."""
812
+ option_names: tuple[str, ...]
813
+ recommended_option: str
814
+ disabled_reason: str
815
+
816
+
817
+ def plan_approval_context(
818
+ src_md_path: Path, src_text: str
819
+ ) -> PlanApprovalContext | None:
820
+ """implementation-planning 보고서 + sibling data.json 의 optionCandidates 가
821
+ 있을 때만 컨텍스트를 만든다. planning 여부는 task-type 문자열이 아니라
822
+ data.json 의 ``implementationPlanning`` 키(SSOT)로 판정한다 — renderer 와
823
+ validator 가 같은 판정을 공유한다. recommendedOption 이 비거나 후보에 없으면
824
+ 첫 후보로 폴백한다 — 항상 정확히 한 옵션이 selected 가 되어 브라우저 자동선택분의
825
+ 묵시 Export 를 차단한다."""
826
+ data_path = src_md_path.with_name(src_md_path.stem + ".data.json")
827
+ if not data_path.is_file():
828
+ return None
829
+ try:
830
+ data = json.loads(data_path.read_text(encoding="utf-8"))
831
+ except (OSError, json.JSONDecodeError):
832
+ return None
833
+ planning = data.get("implementationPlanning")
834
+ if not isinstance(planning, dict):
835
+ return None
836
+ names = tuple(
837
+ c.get("name", "")
838
+ for c in (planning.get("optionCandidates") or [])
839
+ if isinstance(c, dict) and c.get("name")
840
+ )
841
+ if not names:
842
+ return None
843
+ recommended = ""
844
+ rec = planning.get("recommendedOption")
845
+ if isinstance(rec, dict):
846
+ recommended = rec.get("name") or ""
847
+ if recommended not in names:
848
+ recommended = names[0]
849
+ scan = scan_approval_gate(src_text)
850
+ if scan.unreadable_reason:
851
+ reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
852
+ elif scan.blockers:
853
+ reason = (
854
+ f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소 — 답변을 채워 Export 한 뒤 "
855
+ "resume-clarification 으로 해소한 다음 보고서에서 승인하세요."
856
+ )
857
+ else:
858
+ reason = ""
859
+ return PlanApprovalContext(
860
+ option_names=names, recommended_option=recommended, disabled_reason=reason
861
+ )
862
+
863
+
864
+ def _plan_approval_section(ctx: PlanApprovalContext) -> str:
865
+ disabled = " disabled" if ctx.disabled_reason else ""
866
+ opts: list[str] = []
867
+ for name in ctx.option_names:
868
+ is_rec = name == ctx.recommended_option
869
+ label = f"{name} (권장)" if is_rec else name
870
+ attrs = ' data-recommended="true" selected' if is_rec else ""
871
+ opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
872
+ reason_html = (
873
+ f'\n <p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
874
+ if ctx.disabled_reason else ""
875
+ )
876
+ return (
877
+ '<section id="plan-approval">\n'
878
+ " <h2>Plan Approval</h2>\n"
879
+ f' <label>구현 옵션: <select id="approval-option"{disabled}>{"".join(opts)}</select></label>\n'
880
+ f' <label><input type="checkbox" id="approval-checkbox"{disabled}> '
881
+ "이 plan 을 승인합니다 — Export 시 sidecar 에 APPROVAL 블록으로 기록되고, "
882
+ "implementation 시작 마법사가 확인 후 적용합니다.</label>"
883
+ f"{reason_html}\n"
884
+ "</section>\n"
885
+ )
886
+
887
+
757
888
  def render_html_view(
758
889
  src_md_path: Path,
759
890
  *,
@@ -762,19 +893,23 @@ def render_html_view(
762
893
  js: str,
763
894
  ) -> Path | None:
764
895
  """Write ``<stem>.html`` next to ``src_md_path`` and return its path,
765
- or return ``None`` when generation is skipped because the report has
766
- no §1 clarification rows (see ``report_has_clarification_items``).
896
+ or return ``None`` when the report needs no interactive view — §1
897
+ clarification rows 없고 Plan Approval 위젯 대상도 아닐 때
898
+ (``report_has_clarification_items`` / ``plan_approval_context``).
767
899
  Idempotent — overwrites an existing html sibling, and removes a stale
768
- one when a previously-clarification-bearing report no longer has rows."""
900
+ one when the report no longer needs a view."""
769
901
  src_text = src_md_path.read_text(encoding="utf-8")
770
902
  html_path = src_md_path.with_name(src_md_path.stem + ".html")
771
- if not report_has_clarification_items(src_text):
772
- # Conditional generation: no interactive forms to render. Drop any
773
- # stale html left over from a prior clarification-bearing run so the
774
- # validator's "no rows → no html" branch stays consistent.
903
+ approval_ctx = plan_approval_context(src_md_path, src_text)
904
+ if not report_has_clarification_items(src_text) and approval_ctx is None:
775
905
  if html_path.is_file():
776
906
  html_path.unlink()
777
907
  return None
778
- html_text = render_html(src_text, run_meta=run_meta, css=css, js=js)
908
+ html_text = render_html(
909
+ src_text, run_meta=run_meta, css=css, js=js, approval_ctx=approval_ctx
910
+ )
779
911
  html_path.write_text(html_text, encoding="utf-8")
912
+ # 사용자 확인(폼/승인)이 필요한 보고서 — Export 파일의 저장 위치를 미리
913
+ # 만들어 사용자가 디렉토리를 만들 필요가 없게 한다 (reports/ 의 sibling).
914
+ (src_md_path.parent.parent / "user-responses").mkdir(parents=True, exist_ok=True)
780
915
  return html_path
@@ -0,0 +1,55 @@
1
+ """Parse the user-response sidecar files exported from the final-report HTML view.
2
+
3
+ The sidecar format is documented in ``templates/reports/user-response.template.md``
4
+ and produced byte-identically by ``report_views.serialize_user_response`` (Python)
5
+ and ``templates/reports/report.js`` (browser). This module owns the read side of
6
+ the ``## APPROVAL`` block — the implementation wizard consumes it to offer
7
+ "승인 + 옵션 적용" at the approve-confirm step.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+
15
+ _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
16
+ _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class UserResponseApprovalRecord:
21
+ """sidecar 의 ``## APPROVAL`` 블록 + 매칭에 필요한 frontmatter 필드."""
22
+ approved: bool
23
+ implementation_option: str
24
+ source_report: str
25
+ seq: str
26
+
27
+
28
+ def parse_user_response_approval(
29
+ sidecar_text: str,
30
+ ) -> Optional[UserResponseApprovalRecord]:
31
+ """``## APPROVAL`` 블록이 ``- Approved: true`` 를 가질 때만 record 를
32
+ 반환한다. 옵션 라인은 블록 내부에서만 읽는다 (다른 응답 본문의 우연한
33
+ 동일 문구를 옵션으로 오인하지 않도록).
34
+
35
+ strictness 는 의도적이다: producer 출력과 byte-identical 한 소문자
36
+ ``true`` 만 인정하며, 손편집 변형(``TRUE``/``True``)은 fail-closed 로
37
+ 불인정한다."""
38
+ m = _APPROVAL_HEADING_RE.search(sidecar_text)
39
+ if not m:
40
+ return None
41
+ block = sidecar_text[m.end():]
42
+ nxt = _NEXT_RESPONSE_HEADING_RE.search(block)
43
+ if nxt:
44
+ block = block[: nxt.start()]
45
+ if re.search(r"^- Approved:\s*true\s*$", block, re.MULTILINE) is None:
46
+ return None
47
+ om = re.search(r"^- Implementation-Option:\s*(\S.*?)\s*$", block, re.MULTILINE)
48
+ sm = re.search(r"^seq:\s*(\S+)\s*$", sidecar_text, re.MULTILINE)
49
+ rm = re.search(r"^source-report:\s*(\S.*?)\s*$", sidecar_text, re.MULTILINE)
50
+ return UserResponseApprovalRecord(
51
+ approved=True,
52
+ implementation_option=om.group(1) if om else "",
53
+ source_report=rm.group(1) if rm else "",
54
+ seq=sm.group(1) if sm else "",
55
+ )
@@ -35,12 +35,18 @@ from okstra_ctl.clarification_items import scan_approval_gate
35
35
  from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
36
36
  from okstra_ctl.run import (
37
37
  APPROVED_FRONTMATTER_PATTERN,
38
+ PrepareError,
39
+ _apply_cli_implementation_option,
38
40
  _extract_frontmatter_block,
39
41
  _load_final_report_data_if_present,
40
42
  _reject_blocking_plan_body_gate,
41
43
  _set_data_json_approved_true_if_present,
42
44
  _validate_data_json_approval_consistency,
43
45
  )
46
+ from okstra_ctl.user_response import (
47
+ UserResponseApprovalRecord,
48
+ parse_user_response_approval,
49
+ )
44
50
  from okstra_ctl.workers import (
45
51
  ALLOWED_WORKERS,
46
52
  WorkersError,
@@ -341,6 +347,11 @@ class WizardState:
341
347
  # A plan that is approvable (gate ok, no blockers) but not yet `approved`.
342
348
  # Set when the user selects such a plan; the approve-confirm step reads it.
343
349
  approve_plan_candidate: str = ""
350
+ # HTML Plan Approval 위젯이 남긴 sidecar 감지 결과. 선택된 plan 과
351
+ # source-report·seq 가 일치하는 APPROVAL 블록이 있을 때만 채워지고,
352
+ # approve-confirm 단계가 3-옵션(yes_apply/yes/no)으로 확장된다.
353
+ html_approval_sidecar: str = ""
354
+ html_approval_option: str = ""
344
355
  selected_stage: str = "auto"
345
356
  executor: str = ""
346
357
  critic: str = ""
@@ -553,6 +564,58 @@ def _approve_plan_in_place(plan_path: Path) -> None:
553
564
  plan_path.write_text(flipped, encoding="utf-8")
554
565
 
555
566
 
567
+ def _find_html_approval_sidecar(
568
+ plan_path: Path,
569
+ ) -> Optional[tuple[Path, UserResponseApprovalRecord]]:
570
+ """plan 의 run 디렉토리 sibling ``user-responses/`` 에서 APPROVAL 블록을
571
+ 가진 sidecar 를 찾는다. source-report 파일명과 seq 가 plan 과 일치해야
572
+ 하며, 복수면 mtime 최신을 택한다."""
573
+ responses_dir = plan_path.parent.parent / "user-responses"
574
+ if not responses_dir.is_dir():
575
+ return None
576
+ m = re.search(r"-(\d+)\.md$", plan_path.name)
577
+ plan_seq = m.group(1) if m else ""
578
+ best: Optional[tuple[float, Path, UserResponseApprovalRecord]] = None
579
+ for f in sorted(responses_dir.glob("user-response-*.md")):
580
+ try:
581
+ text = f.read_text(encoding="utf-8", errors="replace")
582
+ except OSError:
583
+ continue
584
+ rec = parse_user_response_approval(text)
585
+ if rec is None or rec.seq != plan_seq:
586
+ continue
587
+ if Path(rec.source_report).name != plan_path.name:
588
+ continue
589
+ mtime = f.stat().st_mtime
590
+ if best is None or mtime > best[0]:
591
+ best = (mtime, f, rec)
592
+ return (best[1], best[2]) if best else None
593
+
594
+
595
+ def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict) -> None:
596
+ """sidecar 의 옵션 이름이 plan data.json 의 optionCandidates 에 있는지
597
+ 검증한다. 없으면 유효 후보를 나열하며 거부한다 (fail-closed)."""
598
+ data_path = plan_path.with_name(plan_path.name[: -len(".md")] + ".data.json")
599
+ candidates: list[str] = []
600
+ if data_path.is_file():
601
+ try:
602
+ data = json.loads(data_path.read_text(encoding="utf-8"))
603
+ planning = data.get("implementationPlanning") or {}
604
+ candidates = [
605
+ c.get("name", "") for c in planning.get("optionCandidates") or []
606
+ if isinstance(c, dict) and c.get("name")
607
+ ]
608
+ except (OSError, json.JSONDecodeError):
609
+ candidates = []
610
+ if option_name not in candidates:
611
+ raise WizardError(
612
+ errors_t["unknown_option"].format(
613
+ option=option_name,
614
+ candidates=", ".join(candidates) if candidates else "(없음)",
615
+ )
616
+ )
617
+
618
+
556
619
  def _stage_plan_for_confirmation(
557
620
  state: WizardState, path_str: str, *, suffix: str = ""
558
621
  ) -> Optional[str]:
@@ -564,6 +627,13 @@ def _stage_plan_for_confirmation(
564
627
  state.approved_plan_pending_text = False
565
628
  state.approved_plan_path = ""
566
629
  state.approve_plan_candidate = str(p)
630
+ state.html_approval_sidecar = ""
631
+ state.html_approval_option = ""
632
+ found = _find_html_approval_sidecar(p)
633
+ if found is not None:
634
+ sidecar_path, record = found
635
+ state.html_approval_sidecar = str(sidecar_path)
636
+ state.html_approval_option = record.implementation_option
567
637
  t = _p(state.workspace_root, "approve_plan_confirm", path=str(p))
568
638
  msg = t["echo_variants"]["selected"].format(path=p)
569
639
  return f"{msg} {suffix}".rstrip() if suffix else msg
@@ -711,6 +781,10 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
711
781
  "label": label,
712
782
  "echo_template": raw.get("echo_template", ""),
713
783
  "options": raw.get("options", {}),
784
+ "options_html_approval": raw.get("options_html_approval", {}),
785
+ "html_approval_note": raw.get("html_approval_note", ""),
786
+ "html_approval_note_default_option": raw.get(
787
+ "html_approval_note_default_option", ""),
714
788
  "echo_variants": raw.get("echo_variants", {}),
715
789
  "errors": raw.get("errors", {}),
716
790
  "labels": raw.get("labels", {}),
@@ -1605,17 +1679,27 @@ def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
1605
1679
  def _build_approve_plan_confirm(state: WizardState) -> Prompt:
1606
1680
  t = _p(state.workspace_root, "approve_plan_confirm",
1607
1681
  path=state.approve_plan_candidate)
1682
+ label = t["label"]
1683
+ options_map = t["options"]
1684
+ if state.html_approval_sidecar:
1685
+ label += t["html_approval_note"].format(
1686
+ sidecar=state.html_approval_sidecar,
1687
+ option=state.html_approval_option
1688
+ or t["html_approval_note_default_option"],
1689
+ )
1690
+ options_map = t["options_html_approval"]
1608
1691
  return Prompt(
1609
1692
  step=S_APPROVE_PLAN_CONFIRM, kind="pick",
1610
- label=t["label"],
1611
- options=[_opt(k, v) for k, v in t["options"].items()],
1693
+ label=label,
1694
+ options=[_opt(k, v) for k, v in options_map.items()],
1612
1695
  echo_template=t["echo_template"],
1613
1696
  )
1614
1697
 
1615
1698
 
1616
1699
  def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
1617
- if value not in ("yes", "no"):
1618
- raise WizardError(f"expected 'yes' or 'no', got: {value!r}")
1700
+ allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
1701
+ if value not in allowed:
1702
+ raise WizardError(f"expected one of {allowed}, got: {value!r}")
1619
1703
  candidate = state.approve_plan_candidate
1620
1704
  if not candidate:
1621
1705
  raise WizardError("approve-plan: no candidate plan to approve")
@@ -1624,19 +1708,39 @@ def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str
1624
1708
  # Declining leaves the candidate set so the confirm step re-prompts;
1625
1709
  # implementation cannot proceed without choosing to proceed.
1626
1710
  raise WizardError(t["errors"]["declined"])
1711
+ apply_option = value == "yes_apply" and bool(state.html_approval_option)
1627
1712
  p = Path(candidate)
1713
+ if apply_option:
1714
+ # 승인 플립 전에 옵션 유효성부터 검증한다 — 무효 옵션으로 plan 이
1715
+ # 절반만(승인만) 적용되는 상태를 만들지 않는다.
1716
+ _validate_sidecar_option(p, state.html_approval_option, t["errors"])
1628
1717
  resolved, fully_approved = _classify_approved_plan(
1629
1718
  str(p), Path(state.project_root))
1630
- if not fully_approved:
1631
- # Not yet approved flip data.json (SSOT) + re-render, then re-verify.
1632
- _approve_plan_in_place(p)
1633
- resolved, fully_approved = _classify_approved_plan(
1634
- str(p), Path(state.project_root))
1719
+ # flip / 옵션 적용은 PrepareError 를 던질 수 있는데, 마법사 디스패처는
1720
+ # WizardError 재프롬프트로 처리한다. raw PrepareError 새면 (승인이
1721
+ # 디스크에 반영된 채) state 저장 없이 traceback 으로 죽으므로 여기서 번역한다.
1722
+ try:
1635
1723
  if not fully_approved:
1636
- raise WizardError(t["errors"]["still_unapproved"].format(path=resolved))
1724
+ # Not yet approved → flip data.json (SSOT) + re-render, then re-verify.
1725
+ _approve_plan_in_place(p)
1726
+ resolved, fully_approved = _classify_approved_plan(
1727
+ str(p), Path(state.project_root))
1728
+ if not fully_approved:
1729
+ raise WizardError(
1730
+ t["errors"]["still_unapproved"].format(path=resolved))
1731
+ echo = t["echo_variants"]["approved"].format(path=resolved)
1732
+ if apply_option:
1733
+ _apply_cli_implementation_option(
1734
+ str(resolved), state.html_approval_option)
1735
+ echo = t["echo_variants"]["approved_with_option"].format(
1736
+ path=resolved, option=state.html_approval_option)
1737
+ except PrepareError as exc:
1738
+ raise WizardError(str(exc)) from exc
1637
1739
  state.approved_plan_path = str(resolved)
1638
1740
  state.approve_plan_candidate = ""
1639
- return t["echo_variants"]["approved"].format(path=resolved)
1741
+ state.html_approval_sidecar = ""
1742
+ state.html_approval_option = ""
1743
+ return echo
1640
1744
 
1641
1745
 
1642
1746
  def _build_stage_pick(state: WizardState) -> Prompt:
@@ -3005,6 +3109,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3005
3109
  "reuse_worktree": None, "base_ref": "",
3006
3110
  "base_ref_pending_text": False, "approved_plan_path": "",
3007
3111
  "approved_plan_pending_text": False, "approve_plan_candidate": "",
3112
+ "html_approval_sidecar": "", "html_approval_option": "",
3008
3113
  "selected_stage": "auto",
3009
3114
  "handoff_mode": "", "handoff_stages": "",
3010
3115
  "executor": "", "critic": "", "critic_pending_text": False,
@@ -26,9 +26,11 @@ def match_prefixes(worker_id: str) -> list[str]:
26
26
  `-reverify-r1`, `-impl`, `-2`) when it dispatches the same role multiple
27
27
  times or in different sub-flows. We treat every `agentName` matching one of
28
28
  these prefixes — either exactly or as `<prefix>-<suffix>` — as belonging
29
- to this worker so its tokens get aggregated. For implementation runs the
30
- executor variant `<provider>-executor` is also attributed back to the
31
- matching provider worker.
29
+ to this worker so its tokens get aggregated. For implementation /
30
+ final-verification runs the role variants `<provider>-executor` and
31
+ `<provider>-verifier` are also attributed back to the matching provider
32
+ worker (the bare `<provider>` prefix's `<prefix>-<suffix>` match covers
33
+ any role suffix Lead assigns).
32
34
  """
33
35
  if not worker_id:
34
36
  return []
@@ -105,8 +105,9 @@ The four steps below MUST execute in this exact order. Reordering them is the re
105
105
  ```
106
106
 
107
107
  Output (idempotent — re-running overwrites):
108
- - `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — single-file self-contained human view, **generated only when the report has at least one §5 `C-*` clarification row**. Those rows with `Status` ∈ {`open`, `answered`} embed form widgets (`<select>` for enum-style decisions, `<input>` for material / data-point kinds, `<textarea>` fallback); an `Export user response` button serialises form values to a markdown sidecar (schema in [`templates/reports/user-response.template.md`](../../templates/reports/user-response.template.md)) and downloads it as `user-response-<task-type>-<seq>.md`; the user saves it to `runs/<task-type>/user-responses/`, and `--resume-clarification` auto-appends every sidecar found there to the next run's `clarification-response.md` (`clarification_items.clarification_response_with_sidecars`). The original final-report MD is **never** mutated by user input — the sidecar is the single write target.
109
- - When the report has **no** `C-*` clarification rows, the html carries no interactive forms (it would only duplicate the MD), so the renderer prints `html: skipped (...)` and writes nothing. This is the expected state for clarification-free runs `validators/validate-report-views.py` treats "no C-* rows + no html" as a pass, not a missing artifact.
108
+ - `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — single-file self-contained human view, **generated when the report has at least one §1 `C-*` clarification row OR an implementation-planning Plan Approval widget target** (a sibling `final-report-*.data.json` carrying `implementationPlanning.optionCandidates`). Clarification rows with `Status` ∈ {`open`, `answered`} embed form widgets (`<select>` for enum-style decisions, `<input>` for material / data-point kinds, `<textarea>` fallback); an `Export user response` button serialises form values to a markdown sidecar (schema in [`templates/reports/user-response.template.md`](../../templates/reports/user-response.template.md)) and downloads it as `user-response-<task-type>-<seq>.md`; the user saves it to `runs/<task-type>/user-responses/` (the renderer pre-creates that directory), and `--resume-clarification` auto-appends every sidecar found there to the next run's `clarification-response.md` (`clarification_items.clarification_response_with_sidecars`). The original final-report MD is **never** mutated by user input — the sidecar is the single write target.
109
+ - implementation-planning 보고서에는 본문 끝에 **Plan Approval** 섹션(구현 옵션 `<select>` + 승인 체크박스) 렌더된다 §1 `Blocks: approval` 행이 미해소면 disabled. 승인을 체크하고 Export 하면 sidecar 본문에 `## APPROVAL` 블록이 실리고, implementation 시작 마법사의 approve-confirm 단계가 그것을 감지해 사용자 확인 기존 `--approve` / `--implementation-option` 경로로 적용한다.
110
+ - When the report has **no** `C-*` clarification rows and is **not** a Plan Approval widget target, the html carries no interactive forms (it would only duplicate the MD), so the renderer prints `html: skipped (...)` and writes nothing. This is the expected state for such runs — `validators/validate-report-views.py` treats "no C-* rows + no approval target + no html" as a pass, not a missing artifact.
110
111
 
111
112
  Must run AFTER step 1 (so token placeholders are substituted in any rendered html) and BEFORE step 2 (so the html artifact, when generated, exists for the validator step that checks it).
112
113
  4. **Phase 7 step 2 — Follow-up task spawner** (BLOCKING when Section 4 is non-empty). Turns the report's `## 4. Follow-up Tasks (후속 작업)` rows into `tasks/<task-group>/<new-task-id>/` stubs.
@@ -123,7 +123,7 @@ That is the entire interactive flow. The wizard handles:
123
123
  - task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
124
124
  - brief path — **entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 질문** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing entry tasks). downstream task-type 은 manifest 의 brief 를 자동 carry-in 하고, 등록 brief 가 없으면 `brief_carry` 3-옵션(entry 전환 추천 / 직접 입력 / 중단)이 뜬다. `release-handoff` 는 brief 단계가 아예 없다 — prepare 가 검증 보고서 인용 input 문서를 생성한다,
125
125
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
126
- - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
126
+ - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick. approved-plan 선택 시 그 run 의 sibling `user-responses/` 에서 plan 과 source-report·seq 가 일치하는 HTML `## APPROVAL` sidecar 를 감지하면 approve-confirm 단계가 3-옵션(`yes_apply` HTML 기록대로 승인+옵션 적용 추천 / `yes` 승인만 / `no` 중단)으로 확장된다 — `yes_apply` 는 옵션을 plan 의 `optionCandidates` 에 대해 검증한 뒤 기존 승인·옵션 경로로 적용한다,
127
127
  - `release-handoff`-only sub-flow: approved plan 자동 해소 후 `handoff_stage_pick` 멀티선택 — eligible stage 묶음(stage-group) 또는 전체 task(accepted whole-task 검증 보고서 존재 시) 선택; 결과는 render-args 의 `stages` 키(csv, whole-task 면 빈 값)로 나간다,
128
128
  - `Use defaults / Customize` branch with profile-aware worker/model questions,
129
129
  - `release-handoff` PR template override + persist scope,
@@ -323,7 +323,7 @@ without proceeding — this is the contractual replacement for the previous
323
323
  empty run-level error logs in production.
324
324
 
325
325
  - `cli-failure` events are recorded by the wrapper subagent itself (Codex / Gemini), but **directly to the run-level error log** via `okstra error-log append-observed --error-type cli-failure ...` — NOT via the sidecar. The sidecar is an in-process tool-failure channel only.
326
- - **Wrapper invocation arity.** Both `okstra-codex-exec.sh` and `okstra-gemini-exec.sh` accept four required positional arguments plus an optional fifth `<role>`: `<project-root> <model> <prompt-path> <worktree-path> [<role>]`. The fourth (worktree) argument is **mandatory for implementation phase** and optional otherwise. For codex it becomes `--add-dir <worktree>` (sandbox write access); for gemini it is appended to `--include-directories`. Omitting it during implementation causes the codex sandbox to reject every Edit/Write targeting the worktree with EPERM. Workers extract the path from the `**Worktree:**` / `EXECUTOR_WORKTREE_PATH` / `cwd for every mutating command:` line in the lead prompt. The optional fifth `<role>` is folded into both the caller (worker) pane title `<cli>-<role>-<pid>` and the sibling trace-pane title `<cli>-<role>-<pid>-trace[from=<caller-pane-id>]` (e.g. `codex-worker-93421` ↔ `codex-worker-93421-trace[from=%5]`). `<pid>` is the wrapper's own PID and disambiguates concurrent dispatches of the same role; the embedded caller pane id keeps the trace worker correlation visible even when the worker pane's title is overwritten by the parent process (Claude Code's TUI emits OSC 2 title escape sequences on its own pane). Always pass the literal string `worker` so the dispatch is self-describing (the wrapper defaults to `worker` if omitted).
326
+ - **Wrapper invocation arity.** Both `okstra-codex-exec.sh` and `okstra-gemini-exec.sh` accept four required positional arguments plus an optional fifth `<role>`: `<project-root> <model> <prompt-path> <worktree-path> [<role>]`. The fourth (worktree) argument is **mandatory for implementation phase** and optional otherwise. For codex it becomes `--add-dir <worktree>` (sandbox write access); for gemini it is appended to `--include-directories`. Omitting it during implementation causes the codex sandbox to reject every Edit/Write targeting the worktree with EPERM. Workers extract the path from the `**Worktree:**` / `EXECUTOR_WORKTREE_PATH` / `cwd for every mutating command:` line in the lead prompt. The optional fifth `<role>` is folded into both the caller (worker) pane title `<cli>-<role>-<pid>` and the sibling trace-pane title `<cli>-<role>-<pid>-tail` (e.g. `codex-executor-93421` ↔ `codex-executor-93421-tail`). `<pid>` is the wrapper's own PID and disambiguates concurrent dispatches of the same role. The role value comes from the dispatch prompt's `**Pane role:**` line: `executor` on an `implementation` Executor dispatch, `verifier` on an `implementation` / `final-verification` verifier dispatch, so the trace pane names the actual job rather than a generic `worker`. When no `**Pane role:**` line is present (analysis phases), pass the literal `worker` (the wrapper also defaults to `worker` if the argument is omitted).
327
327
  - **Background dispatch + polling contract (Codex / Gemini wrappers).** Both wrapper subagents MUST dispatch `okstra-codex-exec.sh` / `okstra-gemini-exec.sh` via `Bash(run_in_background: true)` and poll with `BashOutput(bash_id)` until the shell reports `status == "completed"`, capped at 30 minutes (1800s) of wall-clock elapsed time. `BashOutput` itself is the wait primitive — call it back-to-back; do NOT insert a standalone `sleep` between polls. The Claude Code harness blocks `sleep` calls of 5 seconds or longer as a circumvention vector and explicitly forbids chaining shorter sleeps inside until-loops to work around the block. Workers that hit the contract bug must NOT self-recover with `until ...; do sleep 2; done` wrappers — that path violates the harness anti-circumvention rule, even though it superficially "works". The legacy "single foreground `Bash` with 120000ms timeout" rule, and the subsequent "60-second cadence with `sleep 60` between polls" rule, are both retired. The current rule applies in **every phase** (analysis runs typically complete in 1–2 `BashOutput` calls, so there is no regression for short jobs). Recording responsibilities:
328
328
  - Successful completion: return the wrapper's accumulated stdout from the final `BashOutput`. No log entry.
329
329
  - Non-zero `exit_code` reported by `BashOutput`: record a `cli-failure` to the run-level error log with the real `exit_code` and observed `duration-ms`.