okstra 0.88.2 → 0.90.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 +1 -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/agents/TODO.md +36 -0
- 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/okstra-lead-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +18 -12
- package/runtime/prompts/profiles/implementation-planning.md +12 -1
- package/runtime/python/okstra_ctl/clarification_items.py +1 -1
- package/runtime/python/okstra_ctl/pane_reclaim.py +55 -0
- package/runtime/python/okstra_ctl/render.py +23 -3
- package/runtime/python/okstra_ctl/report_views.py +74 -17
- package/runtime/python/okstra_ctl/wizard.py +85 -6
- package/runtime/skills/okstra-run/SKILL.md +5 -2
- package/runtime/templates/reports/report.css +10 -1
- package/runtime/templates/reports/settings.template.json +20 -0
- package/src/doctor.mjs +12 -5
- package/src/install.mjs +1 -0
|
@@ -57,6 +57,7 @@ def _strip_leading_frontmatter(text: str) -> str:
|
|
|
57
57
|
return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
|
|
58
58
|
|
|
59
59
|
from .clarification_items import (
|
|
60
|
+
_CELL_ANCHOR_RE,
|
|
60
61
|
_section_1_slice,
|
|
61
62
|
_split_pipe_row,
|
|
62
63
|
parse_clarification_items,
|
|
@@ -139,7 +140,7 @@ def render_html(
|
|
|
139
140
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
140
141
|
f"</header>\n"
|
|
141
142
|
f"<main>{body_html}</main>\n"
|
|
142
|
-
f"{_plan_approval_section(approval_ctx) if approval_ctx else ''}"
|
|
143
|
+
f"{_plan_approval_section(approval_ctx, run_meta) if approval_ctx else ''}"
|
|
143
144
|
f"<footer class=\"report-footer\">\n"
|
|
144
145
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
145
146
|
f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
|
|
@@ -336,6 +337,12 @@ def _section_forbids_form(path: Iterable[str]) -> bool:
|
|
|
336
337
|
def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[str, int]:
|
|
337
338
|
header_cells = _split_pipe_row(lines[start])
|
|
338
339
|
rows: list[list[str]] = []
|
|
340
|
+
# `_split_pipe_row` strips the ID-defining scroll anchor (`<a id="c-001">`)
|
|
341
|
+
# out of every first cell, so the lowercase fragment that the ID-Index and
|
|
342
|
+
# in-body references link to (`[C-001](#c-001)`) would have no landing
|
|
343
|
+
# element. Capture each row's stripped anchor id here and re-attach it to
|
|
344
|
+
# the emitted `<tr>` below so those `href="#…"` links actually jump.
|
|
345
|
+
row_anchor_ids: list[Optional[str]] = []
|
|
339
346
|
i = start + 2 # skip header + separator
|
|
340
347
|
while i < len(lines) and lines[i].lstrip().startswith("|"):
|
|
341
348
|
cells = _split_pipe_row(lines[i])
|
|
@@ -347,6 +354,8 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
347
354
|
keep = len(header_cells) - 1
|
|
348
355
|
cells = cells[:keep] + ["|".join(cells[keep:])]
|
|
349
356
|
rows.append(cells)
|
|
357
|
+
anchor = _CELL_ANCHOR_RE.search(lines[i])
|
|
358
|
+
row_anchor_ids.append(anchor.group(1).lower() if anchor else None)
|
|
350
359
|
i += 1
|
|
351
360
|
|
|
352
361
|
# §1 Clarification Items is the only interactive table. Its short columns
|
|
@@ -390,7 +399,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
390
399
|
header_cells.index("User input") if "User input" in header_cells else -1
|
|
391
400
|
)
|
|
392
401
|
body_rows: list[str] = []
|
|
393
|
-
for row in rows:
|
|
402
|
+
for row, anchor_id in zip(rows, row_anchor_ids):
|
|
394
403
|
meta = parse_meta_cell(row[0]) if (is_clarification_table and row) else None
|
|
395
404
|
if (
|
|
396
405
|
meta is not None
|
|
@@ -422,8 +431,11 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
422
431
|
+ "</tr>"
|
|
423
432
|
)
|
|
424
433
|
else:
|
|
434
|
+
tr_open = (
|
|
435
|
+
f'<tr id="{html.escape(anchor_id)}">' if anchor_id else "<tr>"
|
|
436
|
+
)
|
|
425
437
|
body_rows.append(
|
|
426
|
-
|
|
438
|
+
tr_open
|
|
427
439
|
+ "".join(
|
|
428
440
|
f"<td{_col_class(idx, narrow_cols)}>{_inline(c)}</td>"
|
|
429
441
|
for idx, c in enumerate(row)
|
|
@@ -809,10 +821,13 @@ def report_has_clarification_items(src_md: str) -> bool:
|
|
|
809
821
|
@dataclass(frozen=True)
|
|
810
822
|
class PlanApprovalContext:
|
|
811
823
|
"""Plan Approval 위젯 렌더 입력. ``disabled_reason`` 이 비어 있지 않으면
|
|
812
|
-
위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준).
|
|
824
|
+
위젯은 disabled 로 렌더된다 (run-prep 승인 게이트와 동일한 fail-closed 기준).
|
|
825
|
+
``blocker_ids`` 는 §1 미해소 승인-차단 항목 ID 목록 — 화면 안내가 사용자에게
|
|
826
|
+
'어느 항목을 답해야 하는지' 를 보여주는 데 쓴다 (unreadable 사유면 빈 튜플)."""
|
|
813
827
|
option_names: tuple[str, ...]
|
|
814
828
|
recommended_option: str
|
|
815
829
|
disabled_reason: str
|
|
830
|
+
blocker_ids: tuple[str, ...]
|
|
816
831
|
|
|
817
832
|
|
|
818
833
|
def plan_approval_context(
|
|
@@ -841,28 +856,71 @@ def plan_approval_context(
|
|
|
841
856
|
)
|
|
842
857
|
if not names:
|
|
843
858
|
return None
|
|
844
|
-
recommended = ""
|
|
845
859
|
rec = planning.get("recommendedOption")
|
|
846
|
-
if isinstance(rec, dict)
|
|
847
|
-
|
|
848
|
-
if recommended not in names:
|
|
849
|
-
recommended = names[0]
|
|
860
|
+
rec_name = rec.get("name") or "" if isinstance(rec, dict) else ""
|
|
861
|
+
recommended = _resolve_recommended_option(rec_name, names)
|
|
850
862
|
scan = scan_approval_gate(src_text)
|
|
863
|
+
blocker_ids: tuple[str, ...] = ()
|
|
851
864
|
if scan.unreadable_reason:
|
|
852
865
|
reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
|
|
853
866
|
elif scan.blockers:
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
"resume-clarification 으로 해소한 다음 보고서에서 승인하세요."
|
|
857
|
-
)
|
|
867
|
+
blocker_ids = tuple(b.row_id for b in scan.blockers)
|
|
868
|
+
reason = f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소"
|
|
858
869
|
else:
|
|
859
870
|
reason = ""
|
|
860
871
|
return PlanApprovalContext(
|
|
861
|
-
option_names=names,
|
|
872
|
+
option_names=names,
|
|
873
|
+
recommended_option=recommended,
|
|
874
|
+
disabled_reason=reason,
|
|
875
|
+
blocker_ids=blocker_ids,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
|
|
880
|
+
"""``recommendedOption.name`` 을 후보 이름에 맞춘다. 정확 일치가 없으면
|
|
881
|
+
접두 일치(후보가 ``(RECOMMENDED)`` 같은 표식 접미를 더 달고 있는 흔한 경우)를
|
|
882
|
+
허용하고, 그래도 없으면 첫 후보로 폴백한다 — 항상 정확히 한 옵션이 selected 가
|
|
883
|
+
되어 브라우저 자동선택분의 묵시 Export 를 차단한다."""
|
|
884
|
+
if rec_name in names:
|
|
885
|
+
return rec_name
|
|
886
|
+
if rec_name:
|
|
887
|
+
for name in names:
|
|
888
|
+
if name.startswith(rec_name) or rec_name.startswith(name):
|
|
889
|
+
return name
|
|
890
|
+
return names[0]
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
|
|
894
|
+
"""disabled 사유를 화면에서 바로 따라할 수 있는 단계별 안내로 렌더한다.
|
|
895
|
+
핵심: 이 화면에서 답을 입력하는 것만으로는 승인이 풀리지 않으며,
|
|
896
|
+
Export → 저장 → resume-clarification → 재렌더 라운드트립이 필요하다는 것."""
|
|
897
|
+
if not ctx.blocker_ids: # unreadable 사유 — 재렌더 외에 따라할 단계가 없다.
|
|
898
|
+
return f'<p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
|
|
899
|
+
sidecar_dir = f"runs/{run_meta.task_type}/user-responses/"
|
|
900
|
+
ids = ", ".join(ctx.blocker_ids)
|
|
901
|
+
steps = [
|
|
902
|
+
f"위 <strong>§1 Clarification Items</strong> 표에서 차단 항목 "
|
|
903
|
+
f"<strong>{html.escape(ids)}</strong> 의 'User input' 칸에 답을 입력합니다.",
|
|
904
|
+
"헤더 또는 맨 아래의 <strong>[Export user response]</strong> 버튼을 눌러 응답 파일을 내려받습니다.",
|
|
905
|
+
f"내려받은 파일을 <code>{html.escape(sidecar_dir)}</code> 에 그대로 저장합니다.",
|
|
906
|
+
f"터미널에서 <code>scripts/okstra.sh --resume-clarification --task-key "
|
|
907
|
+
f"{html.escape(run_meta.task_key)}</code> (Claude Code 에서는 "
|
|
908
|
+
f"<code>/okstra-run resume-clarification task-key={html.escape(run_meta.task_key)}</code>) "
|
|
909
|
+
"을 실행합니다.",
|
|
910
|
+
"명령이 끝나면 <strong>새로 생성된 보고서</strong>를 여세요 — "
|
|
911
|
+
"그 보고서에서 이 체크박스가 활성화됩니다.",
|
|
912
|
+
]
|
|
913
|
+
lis = "".join(f"<li>{s}</li>" for s in steps)
|
|
914
|
+
return (
|
|
915
|
+
'<div class="approval-disabled-reason">'
|
|
916
|
+
f"<p><strong>{html.escape(ctx.disabled_reason)}</strong>라 아직 승인할 수 없습니다. "
|
|
917
|
+
"이 화면에서 답만 입력해서는 풀리지 않습니다 — 아래 순서로 해소하세요:</p>"
|
|
918
|
+
f'<ol class="approval-steps">{lis}</ol>'
|
|
919
|
+
"</div>"
|
|
862
920
|
)
|
|
863
921
|
|
|
864
922
|
|
|
865
|
-
def _plan_approval_section(ctx: PlanApprovalContext) -> str:
|
|
923
|
+
def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
|
|
866
924
|
disabled = " disabled" if ctx.disabled_reason else ""
|
|
867
925
|
opts: list[str] = []
|
|
868
926
|
for name in ctx.option_names:
|
|
@@ -871,8 +929,7 @@ def _plan_approval_section(ctx: PlanApprovalContext) -> str:
|
|
|
871
929
|
attrs = ' data-recommended="true" selected' if is_rec else ""
|
|
872
930
|
opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
|
|
873
931
|
reason_html = (
|
|
874
|
-
|
|
875
|
-
if ctx.disabled_reason else ""
|
|
932
|
+
"\n " + _approval_blocked_guidance(ctx, run_meta) if ctx.disabled_reason else ""
|
|
876
933
|
)
|
|
877
934
|
return (
|
|
878
935
|
'<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
|
|
@@ -3235,6 +3237,83 @@ def next_prompt(state: WizardState) -> Prompt:
|
|
|
3235
3237
|
return Prompt(step=S_DONE, kind="done")
|
|
3236
3238
|
|
|
3237
3239
|
|
|
3240
|
+
def _passed_screens(state: WizardState) -> int:
|
|
3241
|
+
"""이미 답한 화면 수. pick_group 멤버는 GROUP_MAX_TABS 묶음당 1화면으로 환산."""
|
|
3242
|
+
count = 0
|
|
3243
|
+
group_hits: dict[str, int] = {}
|
|
3244
|
+
for sid in state.answered:
|
|
3245
|
+
gid = _STEP_TO_GROUP.get(sid)
|
|
3246
|
+
if gid is None:
|
|
3247
|
+
count += 1
|
|
3248
|
+
else:
|
|
3249
|
+
group_hits[gid] = group_hits.get(gid, 0) + 1
|
|
3250
|
+
for hits in group_hits.values():
|
|
3251
|
+
count += math.ceil(hits / GROUP_MAX_TABS)
|
|
3252
|
+
return count
|
|
3253
|
+
|
|
3254
|
+
|
|
3255
|
+
def _sim_answer(prompt: Prompt) -> str:
|
|
3256
|
+
"""분모 추정 시뮬레이션의 기본답: pick 은 첫 옵션(추천), text 는 빈 값."""
|
|
3257
|
+
if prompt.kind == "pick" and prompt.options:
|
|
3258
|
+
return prompt.options[0].value
|
|
3259
|
+
return ""
|
|
3260
|
+
|
|
3261
|
+
|
|
3262
|
+
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
3263
|
+
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
3264
|
+
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
3265
|
+
try:
|
|
3266
|
+
if prompt.kind == "pick_group":
|
|
3267
|
+
for q in prompt.questions:
|
|
3268
|
+
STEP_BY_ID[q.step].submit(state, _sim_answer(q))
|
|
3269
|
+
for q in prompt.questions:
|
|
3270
|
+
if q.step not in state.answered:
|
|
3271
|
+
state.answered.append(q.step)
|
|
3272
|
+
return
|
|
3273
|
+
STEP_BY_ID[prompt.step].submit(state, _sim_answer(prompt))
|
|
3274
|
+
if prompt.step not in state.answered:
|
|
3275
|
+
state.answered.append(prompt.step)
|
|
3276
|
+
except Exception:
|
|
3277
|
+
# 추천 경로가 막히는 드문 text 분기: answered 마킹만으로 전진시킨다.
|
|
3278
|
+
members = prompt.questions if prompt.kind == "pick_group" else [prompt]
|
|
3279
|
+
for p in members:
|
|
3280
|
+
if p.step not in state.answered:
|
|
3281
|
+
state.answered.append(p.step)
|
|
3282
|
+
|
|
3283
|
+
|
|
3284
|
+
def _remaining_screens(state: WizardState) -> int:
|
|
3285
|
+
"""현재 화면부터 confirm(=done 직전)까지 남은 화면 수를 시뮬레이션으로 센다."""
|
|
3286
|
+
sim = copy.deepcopy(state)
|
|
3287
|
+
screens = 0
|
|
3288
|
+
for _ in range(len(STEPS) * 2 + 5): # Edit 루프 등에 대한 상한 가드
|
|
3289
|
+
try:
|
|
3290
|
+
prompt = next_prompt(sim)
|
|
3291
|
+
except Exception:
|
|
3292
|
+
break
|
|
3293
|
+
if prompt.kind in ("done", "aborted"):
|
|
3294
|
+
break
|
|
3295
|
+
screens += 1
|
|
3296
|
+
_sim_advance(sim, prompt)
|
|
3297
|
+
if prompt.step == S_CONFIRM: # confirm 이후는 done — Edit 는 가정하지 않는다
|
|
3298
|
+
break
|
|
3299
|
+
return screens
|
|
3300
|
+
|
|
3301
|
+
|
|
3302
|
+
def _screen_progress(state: WizardState) -> dict[str, int]:
|
|
3303
|
+
passed = _passed_screens(state)
|
|
3304
|
+
total = passed + _remaining_screens(state)
|
|
3305
|
+
index = passed + 1
|
|
3306
|
+
return {"index": index, "total": total, "remaining": max(0, total - index)}
|
|
3307
|
+
|
|
3308
|
+
|
|
3309
|
+
def prompt_payload(state: WizardState, prompt: Prompt) -> dict[str, Any]:
|
|
3310
|
+
"""Prompt JSON 에 진행 카운터를 덧붙인다. done/aborted 는 progress 를 생략."""
|
|
3311
|
+
out = prompt.to_json()
|
|
3312
|
+
if prompt.kind not in ("done", "aborted"):
|
|
3313
|
+
out["progress"] = _screen_progress(state)
|
|
3314
|
+
return out
|
|
3315
|
+
|
|
3316
|
+
|
|
3238
3317
|
def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
|
|
3239
3318
|
"""pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.
|
|
3240
3319
|
|
|
@@ -3258,7 +3337,7 @@ def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, A
|
|
|
3258
3337
|
if q.step not in state.answered:
|
|
3259
3338
|
state.answered.append(q.step)
|
|
3260
3339
|
nxt = next_prompt(state)
|
|
3261
|
-
return {"echo": "; ".join(echoes), "next": nxt
|
|
3340
|
+
return {"echo": "; ".join(echoes), "next": prompt_payload(state, nxt)}
|
|
3262
3341
|
|
|
3263
3342
|
|
|
3264
3343
|
def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
@@ -3269,7 +3348,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
3269
3348
|
"""
|
|
3270
3349
|
prompt = next_prompt(state)
|
|
3271
3350
|
if prompt.kind in ("done", "aborted"):
|
|
3272
|
-
return {"echo": "", "next": prompt
|
|
3351
|
+
return {"echo": "", "next": prompt_payload(state, prompt)}
|
|
3273
3352
|
if prompt.kind == "pick_group":
|
|
3274
3353
|
return _submit_group(state, prompt, value)
|
|
3275
3354
|
step = STEP_BY_ID[prompt.step]
|
|
@@ -3277,7 +3356,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
3277
3356
|
if prompt.step not in state.answered:
|
|
3278
3357
|
state.answered.append(prompt.step)
|
|
3279
3358
|
nxt = next_prompt(state)
|
|
3280
|
-
return {"echo": echo or "", "next": nxt
|
|
3359
|
+
return {"echo": echo or "", "next": prompt_payload(state, nxt)}
|
|
3281
3360
|
|
|
3282
3361
|
|
|
3283
3362
|
def render_args(state: WizardState) -> dict[str, str]:
|
|
@@ -3471,7 +3550,7 @@ def _cli(argv: list[str]) -> int:
|
|
|
3471
3550
|
state.critic = args.critic
|
|
3472
3551
|
save_state_file(state_path, state)
|
|
3473
3552
|
first = next_prompt(state)
|
|
3474
|
-
print(json.dumps({"ok": True, "next": first
|
|
3553
|
+
print(json.dumps({"ok": True, "next": prompt_payload(state, first)},
|
|
3475
3554
|
ensure_ascii=False, indent=2))
|
|
3476
3555
|
return 0
|
|
3477
3556
|
|
|
@@ -3497,13 +3576,13 @@ def _cli(argv: list[str]) -> int:
|
|
|
3497
3576
|
return 2
|
|
3498
3577
|
try:
|
|
3499
3578
|
if args.no_submit:
|
|
3500
|
-
result = {"echo": "", "next": next_prompt(state)
|
|
3579
|
+
result = {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
3501
3580
|
else:
|
|
3502
3581
|
result = submit(state, args.answer)
|
|
3503
3582
|
except WizardError as exc:
|
|
3504
3583
|
save_state_file(state_path, state)
|
|
3505
3584
|
print(json.dumps({"ok": False, "error": str(exc),
|
|
3506
|
-
"current": next_prompt(state)
|
|
3585
|
+
"current": prompt_payload(state, next_prompt(state))},
|
|
3507
3586
|
ensure_ascii=False, indent=2))
|
|
3508
3587
|
return 0
|
|
3509
3588
|
save_state_file(state_path, state)
|
|
@@ -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>'`.
|
|
@@ -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
|
}
|
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