okstra 0.89.0 → 0.91.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 +3 -2
- package/docs/project-structure-overview.md +3 -1
- package/docs/superpowers/plans/2026-06-18-subagent-pane-reclaim.md +483 -0
- package/docs/superpowers/specs/2026-06-18-subagent-pane-reclaim-design.md +150 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +1 -0
- package/runtime/bin/okstra-codex-exec.sh +1 -0
- package/runtime/bin/okstra-subagent-reclaim.sh +26 -0
- package/runtime/bin/okstra-trace-cleanup.sh +41 -13
- package/runtime/prompts/lead/report-writer.md +9 -1
- package/runtime/prompts/profiles/_common-contract.md +8 -7
- package/runtime/prompts/profiles/_implementation-executor.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/python/okstra_ctl/pane_reclaim.py +55 -0
- package/runtime/python/okstra_ctl/report_views.py +66 -15
- package/runtime/python/okstra_ctl/wizard.py +104 -18
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/skills/okstra-run/SKILL.md +6 -2
- package/runtime/templates/reports/final-report.template.md +12 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/report.css +10 -1
- package/runtime/templates/reports/settings.template.json +20 -0
- package/runtime/validators/validate-run.py +40 -0
- package/src/doctor.mjs +12 -5
- package/src/install.mjs +1 -0
|
@@ -140,7 +140,7 @@ def render_html(
|
|
|
140
140
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
141
141
|
f"</header>\n"
|
|
142
142
|
f"<main>{body_html}</main>\n"
|
|
143
|
-
f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
|
|
143
|
+
f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
|
|
144
144
|
f"<footer class=\"report-footer\">\n"
|
|
145
145
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
146
146
|
f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
|
|
@@ -490,6 +490,11 @@ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
|
|
|
490
490
|
|
|
491
491
|
_RECOMMENDED_CUE = "Recommended:"
|
|
492
492
|
_ALTERNATIVES_CUE = "Alternatives:"
|
|
493
|
+
# Authors sometimes append a pick-one answer-space summary like
|
|
494
|
+
# "(A / B 중 택1)" to the Expected form. The rendered <select> already
|
|
495
|
+
# enforces single choice, so this annotation must never reach an option
|
|
496
|
+
# label — strip a trailing parenthetical that contains "택<n>".
|
|
497
|
+
_PICK_ONE_ANNOTATION = re.compile(r"\s*\([^()]*택\s*\d+\s*\)\s*$")
|
|
493
498
|
|
|
494
499
|
|
|
495
500
|
def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
|
|
@@ -500,6 +505,7 @@ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
|
|
|
500
505
|
``Recommended:`` cue — the caller falls back to the statement enum."""
|
|
501
506
|
if not expected_form:
|
|
502
507
|
return []
|
|
508
|
+
expected_form = _PICK_ONE_ANNOTATION.sub("", expected_form)
|
|
503
509
|
rec_idx = expected_form.find(_RECOMMENDED_CUE)
|
|
504
510
|
if rec_idx < 0:
|
|
505
511
|
return []
|
|
@@ -821,10 +827,13 @@ def report_has_clarification_items(src_md: str) -> bool:
|
|
|
821
827
|
@dataclass(frozen=True)
|
|
822
828
|
class PlanApprovalContext:
|
|
823
829
|
"""Plan Approval 위젯 렌더 입력. ``disabled_reason`` 이 비어 있지 않으면
|
|
824
|
-
위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준).
|
|
830
|
+
위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준).
|
|
831
|
+
``blocker_ids`` 는 §1 미해소 승인-차단 항목 ID 목록 — 화면 안내가 사용자에게
|
|
832
|
+
'어느 항목을 답해야 하는지' 를 보여주는 데 쓴다 (unreadable 사유면 빈 튜플)."""
|
|
825
833
|
option_names: tuple[str, ...]
|
|
826
834
|
recommended_option: str
|
|
827
835
|
disabled_reason: str
|
|
836
|
+
blocker_ids: tuple[str, ...]
|
|
828
837
|
|
|
829
838
|
|
|
830
839
|
def plan_approval_context(
|
|
@@ -853,28 +862,71 @@ def plan_approval_context(
|
|
|
853
862
|
)
|
|
854
863
|
if not names:
|
|
855
864
|
return None
|
|
856
|
-
recommended = ""
|
|
857
865
|
rec = planning.get("recommendedOption")
|
|
858
|
-
if isinstance(rec, dict)
|
|
859
|
-
|
|
860
|
-
if recommended not in names:
|
|
861
|
-
recommended = names[0]
|
|
866
|
+
rec_name = rec.get("name") or "" if isinstance(rec, dict) else ""
|
|
867
|
+
recommended = _resolve_recommended_option(rec_name, names)
|
|
862
868
|
scan = scan_approval_gate(src_text)
|
|
869
|
+
blocker_ids: tuple[str, ...] = ()
|
|
863
870
|
if scan.unreadable_reason:
|
|
864
871
|
reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
|
|
865
872
|
elif scan.blockers:
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
"resume-clarification 으로 해소한 다음 보고서에서 승인하세요."
|
|
869
|
-
)
|
|
873
|
+
blocker_ids = tuple(b.row_id for b in scan.blockers)
|
|
874
|
+
reason = f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소"
|
|
870
875
|
else:
|
|
871
876
|
reason = ""
|
|
872
877
|
return PlanApprovalContext(
|
|
873
|
-
option_names=names,
|
|
878
|
+
option_names=names,
|
|
879
|
+
recommended_option=recommended,
|
|
880
|
+
disabled_reason=reason,
|
|
881
|
+
blocker_ids=blocker_ids,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
|
|
886
|
+
"""``recommendedOption.name`` 을 후보 이름에 맞춘다. 정확 일치가 없으면
|
|
887
|
+
접두 일치(후보가 ``(RECOMMENDED)`` 같은 표식 접미를 더 달고 있는 흔한 경우)를
|
|
888
|
+
허용하고, 그래도 없으면 첫 후보로 폴백한다 — 항상 정확히 한 옵션이 selected 가
|
|
889
|
+
되어 브라우저 자동선택분의 묵시 Export 를 차단한다."""
|
|
890
|
+
if rec_name in names:
|
|
891
|
+
return rec_name
|
|
892
|
+
if rec_name:
|
|
893
|
+
for name in names:
|
|
894
|
+
if name.startswith(rec_name) or rec_name.startswith(name):
|
|
895
|
+
return name
|
|
896
|
+
return names[0]
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
|
|
900
|
+
"""disabled 사유를 화면에서 바로 따라할 수 있는 단계별 안내로 렌더한다.
|
|
901
|
+
핵심: 이 화면에서 답을 입력하는 것만으로는 승인이 풀리지 않으며,
|
|
902
|
+
Export → 저장 → resume-clarification → 재렌더 라운드트립이 필요하다는 것."""
|
|
903
|
+
if not ctx.blocker_ids: # unreadable 사유 — 재렌더 외에 따라할 단계가 없다.
|
|
904
|
+
return f'<p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
|
|
905
|
+
sidecar_dir = f"runs/{run_meta.task_type}/user-responses/"
|
|
906
|
+
ids = ", ".join(ctx.blocker_ids)
|
|
907
|
+
steps = [
|
|
908
|
+
f"위 <strong>§1 Clarification Items</strong> 표에서 차단 항목 "
|
|
909
|
+
f"<strong>{html.escape(ids)}</strong> 의 'User input' 칸에 답을 입력합니다.",
|
|
910
|
+
"헤더 또는 맨 아래의 <strong>[Export user response]</strong> 버튼을 눌러 응답 파일을 내려받습니다.",
|
|
911
|
+
f"내려받은 파일을 <code>{html.escape(sidecar_dir)}</code> 에 그대로 저장합니다.",
|
|
912
|
+
f"터미널에서 <code>scripts/okstra.sh --resume-clarification --task-key "
|
|
913
|
+
f"{html.escape(run_meta.task_key)}</code> (Claude Code 에서는 "
|
|
914
|
+
f"<code>/okstra-run resume-clarification task-key={html.escape(run_meta.task_key)}</code>) "
|
|
915
|
+
"을 실행합니다.",
|
|
916
|
+
"명령이 끝나면 <strong>새로 생성된 보고서</strong>를 여세요 — "
|
|
917
|
+
"그 보고서에서 이 체크박스가 활성화됩니다.",
|
|
918
|
+
]
|
|
919
|
+
lis = "".join(f"<li>{s}</li>" for s in steps)
|
|
920
|
+
return (
|
|
921
|
+
'<div class="approval-disabled-reason">'
|
|
922
|
+
f"<p><strong>{html.escape(ctx.disabled_reason)}</strong>라 아직 승인할 수 없습니다. "
|
|
923
|
+
"이 화면에서 답만 입력해서는 풀리지 않습니다 — 아래 순서로 해소하세요:</p>"
|
|
924
|
+
f'<ol class="approval-steps">{lis}</ol>'
|
|
925
|
+
"</div>"
|
|
874
926
|
)
|
|
875
927
|
|
|
876
928
|
|
|
877
|
-
def _plan_approval_section(ctx: PlanApprovalContext) -> str:
|
|
929
|
+
def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
|
|
878
930
|
disabled = " disabled" if ctx.disabled_reason else ""
|
|
879
931
|
opts: list[str] = []
|
|
880
932
|
for name in ctx.option_names:
|
|
@@ -883,8 +935,7 @@ def _plan_approval_section(ctx: PlanApprovalContext) -> str:
|
|
|
883
935
|
attrs = ' data-recommended="true" selected' if is_rec else ""
|
|
884
936
|
opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
|
|
885
937
|
reason_html = (
|
|
886
|
-
|
|
887
|
-
if ctx.disabled_reason else ""
|
|
938
|
+
"\n " + _approval_blocked_guidance(ctx, run_meta) if ctx.disabled_reason else ""
|
|
888
939
|
)
|
|
889
940
|
return (
|
|
890
941
|
'<section id="plan-approval">\n'
|
|
@@ -18,7 +18,9 @@ imports this module directly.
|
|
|
18
18
|
"""
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import copy
|
|
21
22
|
import json
|
|
23
|
+
import math
|
|
22
24
|
import re
|
|
23
25
|
import subprocess
|
|
24
26
|
from dataclasses import asdict, dataclass, field
|
|
@@ -1926,24 +1928,19 @@ def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
1926
1928
|
|
|
1927
1929
|
|
|
1928
1930
|
def _suggest_latest_final_report(state: WizardState) -> str:
|
|
1929
|
-
"""
|
|
1931
|
+
"""clarification carry-in 으로 추천할 직전 final-report 의 relpath.
|
|
1930
1932
|
|
|
1931
|
-
|
|
1933
|
+
resume-clarification 은 "같은 phase 를 답변과 함께 재실행" 이므로 재실행
|
|
1934
|
+
task-type **자신의** 보고서(``runs/<task-type>/reports/final-report-*.md``)를
|
|
1935
|
+
우선한다. 그 phase 에 보고서가 없을 때만(예: 다음 phase 로 진행) 전체 phase 의
|
|
1936
|
+
mtime 최신으로 폴백한다 — 과거엔 무조건 전체 mtime 최신이라 더 최근에 재렌더된
|
|
1937
|
+
다른 phase 보고서를 잘못 집을 수 있었다. 못 찾으면 빈 문자열.
|
|
1932
1938
|
"""
|
|
1933
1939
|
if not state.task_group or not state.task_id or not state.project_root:
|
|
1934
1940
|
return ""
|
|
1935
1941
|
runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1936
1942
|
if not runs_base.is_dir():
|
|
1937
1943
|
return ""
|
|
1938
|
-
# run 산출물 구조는 runs/<task-type>/reports/final-report-*.md (1단계).
|
|
1939
|
-
# 과거 `*/*/reports/...` (2단계) glob 은 실제 구조와 어긋나 항상 빈 결과여서
|
|
1940
|
-
# clarification 단계가 직전 final-report 를 추천하지 못했다.
|
|
1941
|
-
candidates = [
|
|
1942
|
-
p for p in runs_base.glob("*/reports/final-report-*.md")
|
|
1943
|
-
if p.is_file()
|
|
1944
|
-
]
|
|
1945
|
-
if not candidates:
|
|
1946
|
-
return ""
|
|
1947
1944
|
|
|
1948
1945
|
def _mtime_safe(p: Path) -> float:
|
|
1949
1946
|
try:
|
|
@@ -1951,7 +1948,19 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
1951
1948
|
except OSError:
|
|
1952
1949
|
return -1.0
|
|
1953
1950
|
|
|
1954
|
-
|
|
1951
|
+
def _newest(glob_pattern: str) -> Optional[Path]:
|
|
1952
|
+
cands = [p for p in runs_base.glob(glob_pattern) if p.is_file()]
|
|
1953
|
+
return max(cands, key=_mtime_safe) if cands else None
|
|
1954
|
+
|
|
1955
|
+
# 산출물 구조는 runs/<task-type>/reports/final-report-*.md (1단계).
|
|
1956
|
+
best: Optional[Path] = None
|
|
1957
|
+
if state.task_type:
|
|
1958
|
+
seg = slugify_task_segment(state.task_type)
|
|
1959
|
+
best = _newest(f"{seg}/reports/final-report-*.md")
|
|
1960
|
+
if best is None:
|
|
1961
|
+
best = _newest("*/reports/final-report-*.md")
|
|
1962
|
+
if best is None:
|
|
1963
|
+
return ""
|
|
1955
1964
|
try:
|
|
1956
1965
|
return str(best.relative_to(Path(state.project_root)))
|
|
1957
1966
|
except ValueError:
|
|
@@ -3235,6 +3244,83 @@ def next_prompt(state: WizardState) -> Prompt:
|
|
|
3235
3244
|
return Prompt(step=S_DONE, kind="done")
|
|
3236
3245
|
|
|
3237
3246
|
|
|
3247
|
+
def _passed_screens(state: WizardState) -> int:
|
|
3248
|
+
"""이미 답한 화면 수. pick_group 멤버는 GROUP_MAX_TABS 묶음당 1화면으로 환산."""
|
|
3249
|
+
count = 0
|
|
3250
|
+
group_hits: dict[str, int] = {}
|
|
3251
|
+
for sid in state.answered:
|
|
3252
|
+
gid = _STEP_TO_GROUP.get(sid)
|
|
3253
|
+
if gid is None:
|
|
3254
|
+
count += 1
|
|
3255
|
+
else:
|
|
3256
|
+
group_hits[gid] = group_hits.get(gid, 0) + 1
|
|
3257
|
+
for hits in group_hits.values():
|
|
3258
|
+
count += math.ceil(hits / GROUP_MAX_TABS)
|
|
3259
|
+
return count
|
|
3260
|
+
|
|
3261
|
+
|
|
3262
|
+
def _sim_answer(prompt: Prompt) -> str:
|
|
3263
|
+
"""분모 추정 시뮬레이션의 기본답: pick 은 첫 옵션(추천), text 는 빈 값."""
|
|
3264
|
+
if prompt.kind == "pick" and prompt.options:
|
|
3265
|
+
return prompt.options[0].value
|
|
3266
|
+
return ""
|
|
3267
|
+
|
|
3268
|
+
|
|
3269
|
+
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
3270
|
+
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
3271
|
+
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
3272
|
+
try:
|
|
3273
|
+
if prompt.kind == "pick_group":
|
|
3274
|
+
for q in prompt.questions:
|
|
3275
|
+
STEP_BY_ID[q.step].submit(state, _sim_answer(q))
|
|
3276
|
+
for q in prompt.questions:
|
|
3277
|
+
if q.step not in state.answered:
|
|
3278
|
+
state.answered.append(q.step)
|
|
3279
|
+
return
|
|
3280
|
+
STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
|
|
3281
|
+
if prompt.step not in state.answered:
|
|
3282
|
+
state.answered.append(prompt.step)
|
|
3283
|
+
except Exception:
|
|
3284
|
+
# 추천 경로가 막히는 드문 text 분기: answered 마킹만으로 전진시킨다.
|
|
3285
|
+
members = prompt.questions if prompt.kind == "pick_group" else [prompt]
|
|
3286
|
+
for p in members:
|
|
3287
|
+
if p.step not in state.answered:
|
|
3288
|
+
state.answered.append(p.step)
|
|
3289
|
+
|
|
3290
|
+
|
|
3291
|
+
def _remaining_screens(state: WizardState) -> int:
|
|
3292
|
+
"""현재 화면부터 confirm(=done 직전)까지 남은 화면 수를 시뮬레이션으로 센다."""
|
|
3293
|
+
sim = copy.deepcopy(state)
|
|
3294
|
+
screens = 0
|
|
3295
|
+
for _ in range(len(STEPS) * 2 + 5): # Edit 루프 등에 대한 상한 가드
|
|
3296
|
+
try:
|
|
3297
|
+
prompt = next_prompt(sim)
|
|
3298
|
+
except Exception:
|
|
3299
|
+
break
|
|
3300
|
+
if prompt.kind in ("done", "aborted"):
|
|
3301
|
+
break
|
|
3302
|
+
screens += 1
|
|
3303
|
+
_sim_advance(sim, prompt)
|
|
3304
|
+
if prompt.step == S_CONFIRM: # confirm 이후는 done — Edit 는 가정하지 않는다
|
|
3305
|
+
break
|
|
3306
|
+
return screens
|
|
3307
|
+
|
|
3308
|
+
|
|
3309
|
+
def _screen_progress(state: WizardState) -> dict[str, int]:
|
|
3310
|
+
passed = _passed_screens(state)
|
|
3311
|
+
total = passed + _remaining_screens(state)
|
|
3312
|
+
index = passed + 1
|
|
3313
|
+
return {"index": index, "total": total, "remaining": max(0, total - index)}
|
|
3314
|
+
|
|
3315
|
+
|
|
3316
|
+
def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
|
|
3317
|
+
"""Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
|
|
3318
|
+
out = prompt.to_json()
|
|
3319
|
+
if prompt.kind not in ("done", "aborted"):
|
|
3320
|
+
out["progress"] = _screen_progress(state)
|
|
3321
|
+
return out
|
|
3322
|
+
|
|
3323
|
+
|
|
3238
3324
|
def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
|
|
3239
3325
|
"""pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.
|
|
3240
3326
|
|
|
@@ -3258,7 +3344,7 @@ def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, A
|
|
|
3258
3344
|
if q.step not in state.answered:
|
|
3259
3345
|
state.answered.append(q.step)
|
|
3260
3346
|
nxt = next_prompt(state)
|
|
3261
|
-
return {"echo": "; ".join(echoes), "next": nxt
|
|
3347
|
+
return {"echo": "; ".join(echoes), "next": prompt_payload(state, nxt)}
|
|
3262
3348
|
|
|
3263
3349
|
|
|
3264
3350
|
def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
@@ -3269,7 +3355,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
3269
3355
|
"""
|
|
3270
3356
|
prompt = next_prompt(state)
|
|
3271
3357
|
if prompt.kind in ("done", "aborted"):
|
|
3272
|
-
return {"echo": "", "next": prompt
|
|
3358
|
+
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
3273
3359
|
if prompt.kind == "pick_group":
|
|
3274
3360
|
return _submit_group(state, prompt, value)
|
|
3275
3361
|
step = STEP_BY_ID[prompt.step]
|
|
@@ -3277,7 +3363,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
3277
3363
|
if prompt.step not in state.answered:
|
|
3278
3364
|
state.answered.append(prompt.step)
|
|
3279
3365
|
nxt = next_prompt(state)
|
|
3280
|
-
return {"echo": echo or "", "next": nxt
|
|
3366
|
+
return {"echo": echo or "", "next": prompt_payload(state, nxt)}
|
|
3281
3367
|
|
|
3282
3368
|
|
|
3283
3369
|
def render_args(state: WizardState) -> dict[str, str]:
|
|
@@ -3471,7 +3557,7 @@ def _cli(argv: list[str]) -> int:
|
|
|
3471
3557
|
state.critic = args.critic
|
|
3472
3558
|
save_state_file(state_path, state)
|
|
3473
3559
|
first = next_prompt(state)
|
|
3474
|
-
print(json.dumps({"ok": True, "next": first
|
|
3560
|
+
print(json.dumps({"ok": True, "next": prompt_payload(state, first)},
|
|
3475
3561
|
ensure_ascii=False, indent=2))
|
|
3476
3562
|
return 0
|
|
3477
3563
|
|
|
@@ -3497,13 +3583,13 @@ def _cli(argv: list[str]) -> int:
|
|
|
3497
3583
|
return 2
|
|
3498
3584
|
try:
|
|
3499
3585
|
if args.no_submit:
|
|
3500
|
-
result = {"echo": "", "next": next_prompt(state)
|
|
3586
|
+
result = {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
3501
3587
|
else:
|
|
3502
3588
|
result = submit(state, args.answer)
|
|
3503
3589
|
except WizardError as exc:
|
|
3504
3590
|
save_state_file(state_path, state)
|
|
3505
3591
|
print(json.dumps({"ok": False, "error": str(exc),
|
|
3506
|
-
"current": next_prompt(state)
|
|
3592
|
+
"current": prompt_payload(state, next_prompt(state))},
|
|
3507
3593
|
ensure_ascii=False, indent=2))
|
|
3508
3594
|
return 0
|
|
3509
3595
|
save_state_file(state_path, state)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"frontmatter",
|
|
11
11
|
"header",
|
|
12
12
|
"verdictCard",
|
|
13
|
+
"rationale",
|
|
13
14
|
"summary",
|
|
14
15
|
"executionStatus",
|
|
15
16
|
"tokenUsage",
|
|
@@ -153,6 +154,19 @@
|
|
|
153
154
|
}
|
|
154
155
|
},
|
|
155
156
|
|
|
157
|
+
"rationale": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"description": "## 작업 배경과 근거 — reviewer-facing narrative answering the four questions in order. Each field is prose (not a table) and MUST carry at least one evidence reference (path:line, a report ID such as C-001/F-013/§5.4, or another cited source) OR an explicit insufficiency marker (e.g. '근거 불충분'). validate-run.py fails a field with neither. Required for every task-type.",
|
|
160
|
+
"required": ["motivation", "problem", "approach", "justification"],
|
|
161
|
+
"additionalProperties": false,
|
|
162
|
+
"properties": {
|
|
163
|
+
"motivation": { "type": "string", "minLength": 1, "description": "왜 이 작업을 하는가 — 목표·맥락." },
|
|
164
|
+
"problem": { "type": "string", "minLength": 1, "description": "왜 이게 문제인가 — 현재 상태의 결함을 증거로." },
|
|
165
|
+
"approach": { "type": "string", "minLength": 1, "description": "그래서 어떤 작업이 필요한가 — 택한 방향." },
|
|
166
|
+
"justification": { "type": "string", "minLength": 1, "description": "왜 이게 합리적 선택인가 — 대안 대비 근거." }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
156
170
|
"summary": {
|
|
157
171
|
"type": "array",
|
|
158
172
|
"description": "## Summary of the Problem or Verification Target. 3-5 rows.",
|
|
@@ -28,9 +28,12 @@ Every wizard call returns JSON. The two shapes you'll see:
|
|
|
28
28
|
|
|
29
29
|
```json
|
|
30
30
|
{ "ok": true, "echo": "task-group: backend-api",
|
|
31
|
-
"next": { "step": "task_id", "kind": "text", "label": "...", "options": [], "echoTemplate": "..."
|
|
31
|
+
"next": { "step": "task_id", "kind": "text", "label": "...", "options": [], "echoTemplate": "...",
|
|
32
|
+
"progress": { "index": 5, "total": 11, "remaining": 6 } } }
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
Every non-terminal `next` carries a `progress` object (`done` / `aborted` omit it). `index` is the 1-based number of the **current screen** (pick_group counts as one screen), `total` is the wizard's estimate of the full screen count, and `remaining` = `total − index`. The total is a forward estimate from the current answers, so it may grow by a few when the user opens a branch (e.g. choosing *Customize* adds the model screen). **Always suffix the rendered prompt with the progress marker** — see Step 3.
|
|
36
|
+
|
|
34
37
|
```json
|
|
35
38
|
{ "ok": false, "error": "approved plan has no APPROVED marker: ...",
|
|
36
39
|
"current": { "step": "approved_plan", "kind": "text", "label": "..." } }
|
|
@@ -93,7 +96,7 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
|
|
|
93
96
|
|
|
94
97
|
Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How the wizard talks to you"):
|
|
95
98
|
|
|
96
|
-
1. **Render** the prompt according to `kind` (and `multi` for pick):
|
|
99
|
+
1. **Render** the prompt according to `kind` (and `multi` for pick). **Always append the progress marker to the rendered question label** (the `AskUserQuestion` question text, or the `text`-prompt message): suffix `label` with ` (Step <index>/<total> · 남은 <remaining>단계)`, using `next.progress`. When `remaining == 0` (the final `confirm` screen), use `(Step <index>/<total> · 마지막 단계)` instead. Example: `모델을 선택하세요 (Step 8/11 · 남은 3단계)`. Re-prompts after `ok: false` reuse `current.progress` the same way. The progress marker is presentation-only — never send it back to the wizard as part of an answer.
|
|
97
100
|
- `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
|
|
98
101
|
- `pick` + `multi: true` → `AskUserQuestion` with `multiSelect: true`, `label`, and `options`. Join the selected `value`s with `,` into a single literal CSV string (e.g. `"claude,codex,antigravity"`) and submit it as a single `--answer "claude,codex,antigravity"`. Empty selection submits `--answer ""` and the wizard re-prompts.
|
|
99
102
|
- `pick_group` → one `AskUserQuestion` with one question per `questions[]` entry (tab). Map each tab's selected `value` back by `questions[].step`, assemble a JSON object, and submit it as a single literal `--answer '<json>'`.
|
|
@@ -124,6 +127,7 @@ That is the entire interactive flow. The wizard handles:
|
|
|
124
127
|
- `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 가 일치하는, 보고서에서 내보낸 `## APPROVAL` sidecar 를 감지하면 approve-confirm 단계가 3-옵션(`yes_apply` 내보낸 기록대로 승인+옵션 적용 추천 / `yes` 승인만 / `no` 중단)으로 확장된다 — `yes_apply` 는 옵션을 plan 의 `optionCandidates` 에 대해 검증한 뒤 기존 승인·옵션 경로로 적용한다,
|
|
125
128
|
- `release-handoff`-only sub-flow: approved plan 자동 해소 후 `handoff_stage_pick` 멀티선택 — eligible stage 묶음(stage-group) 또는 전체 task(accepted whole-task 검증 보고서 존재 시) 선택; 결과는 render-args 의 `stages` 키(csv, whole-task 면 빈 값)로 나간다,
|
|
126
129
|
- `Use defaults / Customize` branch with profile-aware worker/model questions,
|
|
130
|
+
- **resume-clarification (in-session 등가)** — 셸의 `okstra.sh --resume-clarification` 에 대응하는 별도 모드나 플래그는 없고, 표준 흐름의 두 단계가 그 실질을 수행한다. (1) `reuse_previous` (직전 run 설정 재사용 예/아니오 — `requirements-discovery` / `error-analysis` / `implementation-planning` 에서, 직전 run-inputs 가 있을 때만): YES 면 워커·모델·directive·related-tasks 를 한 번에 prefill 한다. (2) `clarification_pick`: 재실행하는 **task-type 자신의** 직전 `final-report` 가 있으면 그것을 carry-in 입력으로 자동 추천하고(없으면 전체 phase 중 mtime 최신으로 폴백), 같은 run 의 `user-responses/` 사이드카(사용자가 채운 답변)를 함께 첨부한다. 선택된 경로는 prepare 의 `--clarification-response` 로 전달된다 — 사용자는 보고서의 `Export user response` 로 사이드카를 만들어 `runs/<task-type>/user-responses/` 에 둔 뒤 같은 phase 를 다시 실행하면 된다,
|
|
127
131
|
- `release-handoff` PR template override + persist scope,
|
|
128
132
|
- final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
|
|
129
133
|
|
|
@@ -67,6 +67,18 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
|
|
|
67
67
|
| Approval Required? | `{% if verdictCard.approvalRequired %}yes — {{ t("verdictCard.approvalRequiredSuffix") }}{% else %}no{% endif %}` |
|
|
68
68
|
| Next Step | {{ verdictCard.nextStep | mdcell }} |
|
|
69
69
|
|
|
70
|
+
## {{ t("rationale.heading") }}
|
|
71
|
+
|
|
72
|
+
{{ t("rationale.intro") }}
|
|
73
|
+
|
|
74
|
+
**{{ t("rationale.motivationLabel") }}** — {{ rationale.motivation }}
|
|
75
|
+
|
|
76
|
+
**{{ t("rationale.problemLabel") }}** — {{ rationale.problem }}
|
|
77
|
+
|
|
78
|
+
**{{ t("rationale.approachLabel") }}** — {{ rationale.approach }}
|
|
79
|
+
|
|
80
|
+
**{{ t("rationale.justificationLabel") }}** — {{ rationale.justification }}
|
|
81
|
+
|
|
70
82
|
## 1. Clarification Items
|
|
71
83
|
|
|
72
84
|
{{ t("sectionIntro.clarificationItems") }}
|
|
@@ -62,6 +62,14 @@
|
|
|
62
62
|
"rationaleLabel": "Rationale summary",
|
|
63
63
|
"nextStepLabel": "Next step"
|
|
64
64
|
},
|
|
65
|
+
"rationale": {
|
|
66
|
+
"heading": "Background and Rationale",
|
|
67
|
+
"intro": "Why this work, what it changes, and why the chosen approach is sound — written so a reviewer can follow it. Each item cites its evidence (path:line, report ID, etc.).",
|
|
68
|
+
"motivationLabel": "Why this work",
|
|
69
|
+
"problemLabel": "Why it is a problem",
|
|
70
|
+
"approachLabel": "What work is needed",
|
|
71
|
+
"justificationLabel": "Why this is the reasonable choice"
|
|
72
|
+
},
|
|
65
73
|
"ticketCoverage": {
|
|
66
74
|
"intro": "The tickets this run covered, with the sections and related items where each ticket appears.",
|
|
67
75
|
"columnSections": "Sections",
|
|
@@ -62,6 +62,14 @@
|
|
|
62
62
|
"rationaleLabel": "근거 요약",
|
|
63
63
|
"nextStepLabel": "다음 단계"
|
|
64
64
|
},
|
|
65
|
+
"rationale": {
|
|
66
|
+
"heading": "작업 배경과 근거",
|
|
67
|
+
"intro": "이 작업을 왜·무엇을·왜 그렇게 하는지 검토자가 따라갈 수 있도록 정리한다. 각 항목은 근거(파일:줄, 보고서 ID 등)를 인용한다.",
|
|
68
|
+
"motivationLabel": "왜 이 작업을 하는가",
|
|
69
|
+
"problemLabel": "왜 이게 문제인가",
|
|
70
|
+
"approachLabel": "그래서 어떤 작업이 필요한가",
|
|
71
|
+
"justificationLabel": "왜 이게 합리적 선택인가"
|
|
72
|
+
},
|
|
65
73
|
"ticketCoverage": {
|
|
66
74
|
"intro": "이 run 이 다룬 ticket 과, 각 ticket 이 등장한 섹션·관련 항목 목록입니다.",
|
|
67
75
|
"columnSections": "등장 섹션",
|
|
@@ -207,7 +207,16 @@ button[data-action]:hover { background: color-mix(in srgb, Highlight 20%, Button
|
|
|
207
207
|
#plan-approval h2 { margin-top: 0; }
|
|
208
208
|
#plan-approval label { display: block; margin: 0.4em 0; }
|
|
209
209
|
#plan-approval select { font: inherit; max-width: 100%; }
|
|
210
|
-
.approval-disabled-reason {
|
|
210
|
+
.approval-disabled-reason {
|
|
211
|
+
margin: 0.6em 0 0;
|
|
212
|
+
padding: 0.7em 0.9em;
|
|
213
|
+
border-radius: 4px;
|
|
214
|
+
background: color-mix(in srgb, Highlight 12%, transparent);
|
|
215
|
+
border: 1px solid color-mix(in srgb, Highlight 45%, transparent);
|
|
216
|
+
}
|
|
217
|
+
.approval-disabled-reason p { margin: 0 0 0.5em; }
|
|
218
|
+
.approval-steps { margin: 0.3em 0 0; padding-left: 1.4em; }
|
|
219
|
+
.approval-steps li { margin: 0.25em 0; }
|
|
211
220
|
|
|
212
221
|
@media print {
|
|
213
222
|
.report-header, .report-footer { position: static; }
|
|
@@ -48,6 +48,26 @@
|
|
|
48
48
|
}
|
|
49
49
|
]
|
|
50
50
|
}
|
|
51
|
+
],
|
|
52
|
+
"SubagentStop": [
|
|
53
|
+
{
|
|
54
|
+
"hooks": [
|
|
55
|
+
{
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"TaskCompleted": [
|
|
63
|
+
{
|
|
64
|
+
"hooks": [
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
51
71
|
]
|
|
52
72
|
}
|
|
53
73
|
}
|
|
@@ -1462,10 +1462,50 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
|
|
|
1462
1462
|
for err in errors:
|
|
1463
1463
|
failures.append(f"final-report data.json: {err}")
|
|
1464
1464
|
|
|
1465
|
+
_validate_rationale_evidence(data, failures)
|
|
1466
|
+
|
|
1465
1467
|
if (data.get("header") or {}).get("taskType") == "final-verification":
|
|
1466
1468
|
_validate_final_verification_consistency(data, failures)
|
|
1467
1469
|
|
|
1468
1470
|
|
|
1471
|
+
# path:line (foo.service.ts:268), report ID (C-001 / F-013 / G-crit-002 / RC-1),
|
|
1472
|
+
# or a section reference (§5.4) — any one satisfies "this claim is anchored".
|
|
1473
|
+
_EVIDENCE_TOKEN = re.compile(
|
|
1474
|
+
r"[\w./-]+\.\w+:\d+|\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b|§\s*\d")
|
|
1475
|
+
# Explicit "I don't know" escapes — anti-fabrication's other valid answer.
|
|
1476
|
+
_INSUFFICIENCY_MARKERS = (
|
|
1477
|
+
"근거 불충분", "근거가 불충분", "증거 불충분", "증거가 불충분",
|
|
1478
|
+
"증거 없음", "증거가 없", "모름", "알 수 없", "확인 불가",
|
|
1479
|
+
"i don't know", "unknown", "insufficient evidence", "no evidence",
|
|
1480
|
+
)
|
|
1481
|
+
_RATIONALE_FIELDS = ("motivation", "problem", "approach", "justification")
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
|
|
1485
|
+
"""Every `## 작업 배경과 근거` field must anchor its claim: carry at least
|
|
1486
|
+
one evidence reference (path:line, report ID, §section) OR an explicit
|
|
1487
|
+
insufficiency marker. Neither present → unverifiable narrative, which is
|
|
1488
|
+
the fabrication the section exists to prevent. Schema guarantees the
|
|
1489
|
+
fields are present and non-empty; this enforces they are *grounded*."""
|
|
1490
|
+
rationale = data.get("rationale")
|
|
1491
|
+
if not isinstance(rationale, dict):
|
|
1492
|
+
return # absence/shape is the schema's job; don't double-report.
|
|
1493
|
+
for field in _RATIONALE_FIELDS:
|
|
1494
|
+
text = rationale.get(field)
|
|
1495
|
+
if not isinstance(text, str):
|
|
1496
|
+
continue
|
|
1497
|
+
if _EVIDENCE_TOKEN.search(text):
|
|
1498
|
+
continue
|
|
1499
|
+
if any(m in text.lower() for m in _INSUFFICIENCY_MARKERS):
|
|
1500
|
+
continue
|
|
1501
|
+
failures.append(
|
|
1502
|
+
f"final-report data.json: rationale.{field} cites no evidence "
|
|
1503
|
+
f"(expected a path:line, a report ID like C-001/F-013, or a §"
|
|
1504
|
+
f"section reference) and gives no explicit insufficiency marker "
|
|
1505
|
+
f"(e.g. '근거 불충분'). Anchor the claim or state what is unknown."
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
|
|
1469
1509
|
def _validate_final_verification_consistency(data: dict, failures: list[str]) -> None:
|
|
1470
1510
|
"""Enforce verdict ↔ blocker/condition/routing consistency on the
|
|
1471
1511
|
final-verification data.json (SSOT). The schema guarantees field SHAPE;
|
package/src/doctor.mjs
CHANGED
|
@@ -194,11 +194,13 @@ export async function run(args) {
|
|
|
194
194
|
env: process.env,
|
|
195
195
|
capabilities: { tmuxAvailable: Boolean(process.env.TMUX) },
|
|
196
196
|
});
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
// doctor is an environment diagnostic, not a dispatch path. A failed host
|
|
198
|
+
// auto-detection only means we cannot attribute the optional skill checks to a
|
|
199
|
+
// runtime, so degrade it to a warning and keep running the host-independent
|
|
200
|
+
// checks. Dispatch callers of resolveRuntime stay fail-closed by design — this
|
|
201
|
+
// leniency lives only here. With resolvedRuntime null the skill checks skip
|
|
202
|
+
// (requiredSkillNamesForRuntime → []); pass --runtime to include them.
|
|
203
|
+
const resolvedRuntime = runtimeResolution.ok ? runtimeResolution.resolvedRuntime : null;
|
|
202
204
|
|
|
203
205
|
const results = [
|
|
204
206
|
await check("python3", checkPython3),
|
|
@@ -258,6 +260,11 @@ export async function run(args) {
|
|
|
258
260
|
return allOk ? 0 : 1;
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
if (!runtimeResolution.ok) {
|
|
264
|
+
process.stdout.write(
|
|
265
|
+
` [WARN] runtime host: ${runtimeResolution.reason} skill checks skipped — pass --runtime <claude-code|codex|external> to include them.\n`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
261
268
|
for (const r of results) {
|
|
262
269
|
const mark = r.ok ? "OK " : "FAIL";
|
|
263
270
|
process.stdout.write(` [${mark}] ${r.name}: ${r.detail}\n`);
|
package/src/install.mjs
CHANGED