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.
- package/docs/kr/architecture.md +2 -1
- package/docs/kr/cli.md +1 -1
- package/docs/kr/performance-improvement-plan-v2.md +27 -7
- 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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +3 -3
- 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 +1 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +11 -2
- package/runtime/python/okstra_ctl/clarification_items.py +32 -22
- package/runtime/python/okstra_ctl/context_cost.py +32 -5
- 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 +218 -26
- package/runtime/python/okstra_ctl/run.py +4 -4
- 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/claude.py +22 -0
- package/runtime/python/okstra_token_usage/collect.py +51 -3
- package/runtime/python/okstra_token_usage/cursor.py +3 -1
- package/runtime/schemas/final-report-v1.0.schema.json +2 -1
- package/runtime/skills/okstra-convergence/SKILL.md +8 -4
- package/runtime/skills/okstra-inspect/SKILL.md +20 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +4 -3
- 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 +57 -52
- package/runtime/templates/reports/report.css +21 -7
- package/runtime/templates/reports/report.js +56 -8
- 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
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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="{
|
|
465
|
-
f
|
|
466
|
-
for
|
|
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
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
709
|
-
|
|
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
|
|
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
|
-
|
|
715
|
-
|
|
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(
|
|
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
|
-
|
|
1780
|
-
inp.clarification_response_path,
|
|
1781
|
-
|
|
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=
|
|
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,
|