okstra 0.72.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 (44) hide show
  1. package/docs/kr/architecture.md +2 -1
  2. package/docs/kr/cli.md +1 -1
  3. package/docs/kr/performance-improvement-plan-v2.md +27 -7
  4. package/docs/project-structure-overview.md +1 -0
  5. package/docs/superpowers/plans/2026-06-12-html-plan-approval.md +1000 -0
  6. package/docs/superpowers/specs/2026-06-12-html-plan-approval-design.md +85 -0
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +3 -3
  10. package/runtime/agents/workers/codex-worker.md +5 -5
  11. package/runtime/agents/workers/gemini-worker.md +5 -5
  12. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  13. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  14. package/runtime/prompts/profiles/error-analysis.md +1 -1
  15. package/runtime/prompts/profiles/final-verification.md +1 -1
  16. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  17. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  18. package/runtime/prompts/wizard/prompts.ko.json +11 -2
  19. package/runtime/python/okstra_ctl/clarification_items.py +32 -22
  20. package/runtime/python/okstra_ctl/context_cost.py +32 -5
  21. package/runtime/python/okstra_ctl/md_table.py +56 -0
  22. package/runtime/python/okstra_ctl/render_final_report.py +8 -1
  23. package/runtime/python/okstra_ctl/report_views.py +218 -26
  24. package/runtime/python/okstra_ctl/run.py +4 -4
  25. package/runtime/python/okstra_ctl/user_response.py +55 -0
  26. package/runtime/python/okstra_ctl/wizard.py +116 -11
  27. package/runtime/python/okstra_token_usage/claude.py +22 -0
  28. package/runtime/python/okstra_token_usage/collect.py +51 -3
  29. package/runtime/python/okstra_token_usage/cursor.py +3 -1
  30. package/runtime/schemas/final-report-v1.0.schema.json +2 -1
  31. package/runtime/skills/okstra-convergence/SKILL.md +8 -4
  32. package/runtime/skills/okstra-inspect/SKILL.md +20 -0
  33. package/runtime/skills/okstra-report-writer/SKILL.md +4 -3
  34. package/runtime/skills/okstra-run/SKILL.md +1 -1
  35. package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
  36. package/runtime/templates/reports/final-report.template.md +57 -52
  37. package/runtime/templates/reports/report.css +21 -7
  38. package/runtime/templates/reports/report.js +56 -8
  39. package/runtime/templates/reports/user-response.template.md +15 -0
  40. package/runtime/validators/validate-implementation-plan-stages.py +9 -2
  41. package/runtime/validators/validate-report-views.py +2 -1
  42. package/runtime/validators/validate-run.py +6 -17
  43. package/runtime/validators/validate-schedule.py +9 -2
  44. 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
@@ -109,6 +118,8 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
109
118
 
110
119
  title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
111
120
  response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
121
+ sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
122
+ sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
112
123
 
113
124
  return (
114
125
  f"<!DOCTYPE html>\n"
@@ -125,8 +136,11 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
125
136
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
126
137
  f"</header>\n"
127
138
  f"<main>{body_html}</main>\n"
139
+ f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
128
140
  f"<footer class=\"report-footer\">\n"
129
141
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
142
+ f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
143
+ f"<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n"
130
144
  f" <pre id=\"user-response-output\" aria-live=\"polite\"></pre>\n"
131
145
  f" <button type=\"button\" data-action=\"copy-user-response\">Copy</button>\n"
132
146
  f"</footer>\n"
@@ -317,7 +331,15 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
317
331
  rows: list[list[str]] = []
318
332
  i = start + 2 # skip header + separator
319
333
  while i < len(lines) and lines[i].lstrip().startswith("|"):
320
- 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)
321
343
  i += 1
322
344
 
323
345
  # §1 Clarification Items is the only interactive table. Its short columns
@@ -353,6 +375,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
353
375
  (j for j, h in enumerate(header_cells) if h.startswith("Statement")),
354
376
  -1,
355
377
  )
378
+ expected_form_col = next(
379
+ (j for j, h in enumerate(header_cells) if h.startswith("Expected form")),
380
+ -1,
381
+ )
356
382
  user_input_col = (
357
383
  header_cells.index("User input") if "User input" in header_cells else -1
358
384
  )
@@ -367,11 +393,14 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
367
393
  statement = (
368
394
  row[statement_col] if 0 <= statement_col < len(row) else ""
369
395
  )
396
+ expected_form = (
397
+ row[expected_form_col] if 0 <= expected_form_col < len(row) else ""
398
+ )
370
399
  cells_html: list[str] = []
371
400
  for idx, cell in enumerate(row):
372
401
  if idx == user_input_col:
373
402
  cells_html.append(
374
- f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement)}</td>"
403
+ f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement, expected_form)}</td>"
375
404
  )
376
405
  else:
377
406
  cells_html.append(
@@ -439,12 +468,51 @@ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
439
468
  return out if len(out) >= 2 else []
440
469
 
441
470
 
471
+ _RECOMMENDED_CUE = "Recommended:"
472
+ _ALTERNATIVES_CUE = "Alternatives:"
473
+
474
+
475
+ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
476
+ """Parse the ``Expected form`` contract format
477
+ (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
478
+ `_common-contract.md` §Clarification request policy) into select
479
+ ``(value, label)`` options. Returns ``[]`` when the cell carries no
480
+ ``Recommended:`` cue — the caller falls back to the statement enum."""
481
+ if not expected_form:
482
+ return []
483
+ rec_idx = expected_form.find(_RECOMMENDED_CUE)
484
+ if rec_idx < 0:
485
+ return []
486
+ rec_body = expected_form[rec_idx + len(_RECOMMENDED_CUE):]
487
+ alt_body = ""
488
+ alt_idx = rec_body.find(_ALTERNATIVES_CUE)
489
+ if alt_idx >= 0:
490
+ alt_body = rec_body[alt_idx + len(_ALTERNATIVES_CUE):]
491
+ rec_body = rec_body[:alt_idx]
492
+ # The rationale follows " — "; the option keeps only the answer part.
493
+ answer = rec_body.split(" — ", 1)[0].strip(" .,;—-")
494
+ options: list[tuple[str, str]] = []
495
+ if answer:
496
+ options.append(("recommended", f"추천: {answer}"))
497
+ enum_alts = _parse_enum_options(alt_body)
498
+ if enum_alts:
499
+ options.extend(
500
+ (letter, f"({letter}) {text}") for letter, text in enum_alts
501
+ )
502
+ else:
503
+ alt_text = alt_body.strip(" .,;—-")
504
+ if alt_text:
505
+ options.append(("alternative", alt_text))
506
+ return options
507
+
508
+
442
509
  def _form_control(
443
510
  response_id: str,
444
511
  kind: str,
445
512
  status: str,
446
513
  current_value: str,
447
514
  statement: str = "",
515
+ expected_form: str = "",
448
516
  ) -> str:
449
517
  rid = html.escape(response_id)
450
518
  disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
@@ -456,14 +524,21 @@ def _form_control(
456
524
  "data-point": "값",
457
525
  }.get(kind_lc, "응답")
458
526
 
459
- # decision 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
527
+ # decision 후보는 Expected form `Recommended: …; Alternatives: …`
528
+ # 계약(_common-contract.md §Clarification request policy)이 1순위,
529
+ # statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
460
530
  if kind_lc == "decision":
461
- opts = _parse_enum_options(statement)
531
+ opts = _parse_expected_form_options(expected_form)
532
+ if not opts:
533
+ opts = [
534
+ (letter, f"({letter}) {text}")
535
+ for letter, text in _parse_enum_options(statement)
536
+ ]
462
537
  if opts:
463
538
  select_opts = "".join(
464
- f'<option value="{letter}">'
465
- f'{_inline(f"({letter}) " + text)}</option>'
466
- for letter, text in opts
539
+ f'<option value="{html.escape(value)}">'
540
+ f"{_inline(label)}</option>"
541
+ for value, label in opts
467
542
  )
468
543
  select_html = (
469
544
  f'<select name="{rid}" data-response-id="{rid}" '
@@ -563,6 +638,22 @@ def _plain_len(raw_cell: str) -> int:
563
638
  return len(_INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip())
564
639
 
565
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
+
566
657
  def _matches_narrow_whitelist(header: str) -> bool:
567
658
  plain = _INLINE_MD_STRIP_RE.sub("", header or "").strip().lower()
568
659
  return any(plain.startswith(p) for p in _NARROW_HEADER_PREFIXES)
@@ -577,8 +668,11 @@ def _narrow_columns(
577
668
  A column qualifies when EITHER:
578
669
  (a) its header matches `_NARROW_HEADER_PREFIXES` — these are
579
670
  ``ID`` / ``출처`` / ``에이전트`` etc. whose values are
580
- conventionally short across every final-report even when a
581
- 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
582
676
  (b) every cell in the column (header + body) fits within
583
677
  ``_NARROW_COL_MAX_PLAIN_LEN`` plain chars — this catches
584
678
  ad-hoc short columns that the whitelist did not anticipate.
@@ -588,14 +682,13 @@ def _narrow_columns(
588
682
  """
589
683
  narrow: set[int] = set()
590
684
  for col, header in enumerate(header_cells):
685
+ column_cells = [header] + [row[col] for row in rows if col < len(row)]
591
686
  if _matches_narrow_whitelist(header):
592
- 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)
593
690
  continue
594
- max_len = _plain_len(header)
595
- for row in rows:
596
- if col < len(row):
597
- max_len = max(max_len, _plain_len(row[col]))
598
- if max_len <= _NARROW_COL_MAX_PLAIN_LEN:
691
+ if max(_plain_len(c) for c in column_cells) <= _NARROW_COL_MAX_PLAIN_LEN:
599
692
  narrow.add(col)
600
693
  return narrow
601
694
 
@@ -640,11 +733,20 @@ class UserResponseEntry:
640
733
  rationale: Optional[str] = None
641
734
 
642
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
+
643
744
  def serialize_user_response(
644
745
  *,
645
746
  run_meta: RunMeta,
646
747
  entries: list[UserResponseEntry],
647
748
  created_at: str,
749
+ approval: UserResponseApproval | None = None,
648
750
  ) -> str:
649
751
  """Return the canonical markdown text the HTML 'Export user
650
752
  response' button must produce. Used by validators to confirm that
@@ -663,6 +765,7 @@ def serialize_user_response(
663
765
  "\n"
664
766
  "# User Response\n"
665
767
  )
768
+ has_approval = approval is not None and approval.approved
666
769
  body_chunks: list[str] = []
667
770
  for e in entries:
668
771
  chunk = (
@@ -674,8 +777,13 @@ def serialize_user_response(
674
777
  if e.rationale:
675
778
  chunk += f"- Rationale: {e.rationale.strip()}\n"
676
779
  body_chunks.append(chunk)
677
- if not entries:
780
+ if not entries and not has_approval:
678
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)
679
787
  return head + "".join(body_chunks)
680
788
 
681
789
 
@@ -697,6 +805,86 @@ def report_has_clarification_items(src_md: str) -> bool:
697
805
  )
698
806
 
699
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
+
700
888
  def render_html_view(
701
889
  src_md_path: Path,
702
890
  *,
@@ -705,19 +893,23 @@ def render_html_view(
705
893
  js: str,
706
894
  ) -> Path | None:
707
895
  """Write ``<stem>.html`` next to ``src_md_path`` and return its path,
708
- or return ``None`` when generation is skipped because the report has
709
- 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``).
710
899
  Idempotent — overwrites an existing html sibling, and removes a stale
711
- one when a previously-clarification-bearing report no longer has rows."""
900
+ one when the report no longer needs a view."""
712
901
  src_text = src_md_path.read_text(encoding="utf-8")
713
902
  html_path = src_md_path.with_name(src_md_path.stem + ".html")
714
- if not report_has_clarification_items(src_text):
715
- # Conditional generation: no interactive forms to render. Drop any
716
- # stale html left over from a prior clarification-bearing run so the
717
- # 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:
718
905
  if html_path.is_file():
719
906
  html_path.unlink()
720
907
  return None
721
- 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
+ )
722
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)
723
915
  return html_path
@@ -30,7 +30,7 @@ from okstra_project import project_json_path, upsert_project_json
30
30
  from okstra_project.state import slugify
31
31
  from . import fix_cycles
32
32
  from .analysis_packet import build_analysis_packet
33
- from .clarification_items import scan_approval_gate
33
+ from .clarification_items import clarification_response_with_sidecars, scan_approval_gate
34
34
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
35
35
  from .material import (
36
36
  build_analysis_material,
@@ -1776,9 +1776,9 @@ def _write_instruction_set_sources(
1776
1776
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
1777
1777
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
1778
1778
  if inp.clarification_response_path:
1779
- shutil.copyfile(
1780
- inp.clarification_response_path,
1781
- instruction_set / "clarification-response.md",
1779
+ (instruction_set / "clarification-response.md").write_text(
1780
+ clarification_response_with_sidecars(Path(inp.clarification_response_path)),
1781
+ encoding="utf-8",
1782
1782
  )
1783
1783
  if inp.directive:
1784
1784
  (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
@@ -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,