okstra 0.73.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/project-structure-overview.md +1 -0
- package/docs/superpowers/plans/2026-06-12-html-plan-approval.md +1000 -0
- package/docs/superpowers/specs/2026-06-12-html-plan-approval-design.md +85 -0
- package/docs/superpowers/specs/2026-06-12-stage-discipline-rule-design.md +108 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +1 -1
- package/runtime/agents/workers/codex-worker.md +5 -5
- package/runtime/agents/workers/gemini-worker.md +5 -5
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/_stage-discipline.md +36 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/implementation.md +1 -0
- package/runtime/prompts/wizard/prompts.ko.json +11 -2
- package/runtime/python/okstra_ctl/clarification_items.py +6 -22
- package/runtime/python/okstra_ctl/md_table.py +56 -0
- package/runtime/python/okstra_ctl/render_final_report.py +8 -1
- package/runtime/python/okstra_ctl/report_views.py +155 -20
- package/runtime/python/okstra_ctl/user_response.py +55 -0
- package/runtime/python/okstra_ctl/wizard.py +116 -11
- package/runtime/python/okstra_token_usage/collect.py +5 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -2
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
- package/runtime/templates/reports/final-report.template.md +55 -50
- package/runtime/templates/reports/report.css +21 -7
- package/runtime/templates/reports/report.js +34 -7
- package/runtime/templates/reports/user-response.template.md +15 -0
- package/runtime/validators/validate-implementation-plan-stages.py +9 -2
- package/runtime/validators/validate-report-views.py +2 -1
- package/runtime/validators/validate-run.py +6 -17
- package/runtime/validators/validate-schedule.py +9 -2
- package/runtime/validators/validate_improvement_report.py +2 -1
- package/src/install.mjs +73 -2
- package/src/uninstall.mjs +1 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
766
|
-
|
|
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
|
|
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
|
-
|
|
772
|
-
|
|
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(
|
|
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=
|
|
1611
|
-
options=[_opt(k, v) for k, v in
|
|
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
|
|
1618
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
matching provider
|
|
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
|
|
109
|
-
-
|
|
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>-
|
|
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`.
|