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.
@@ -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
- recommended = rec.get("name") or ""
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
- reason = (
867
- f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소 — 답변을 채워 Export 한 뒤 "
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, recommended_option=recommended, disabled_reason=reason
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
- f'\n <p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
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
- """task 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
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
- best = max(candidates, key=_mtime_safe)
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.to_json()}
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.to_json()}
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.to_json()}
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.to_json()},
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).to_json()}
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).to_json()},
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 { color: GrayText; margin: 0.4em 0 0; }
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
- if (!runtimeResolution.ok) {
198
- process.stderr.write(`error: ${runtimeResolution.reason}\n`);
199
- return 2;
200
- }
201
- const resolvedRuntime = runtimeResolution.resolvedRuntime;
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
@@ -32,6 +32,7 @@ const BIN_ENTRYPOINTS = [
32
32
  "okstra-claude-exec.sh",
33
33
  "okstra-antigravity-exec.sh",
34
34
  "okstra-trace-cleanup.sh",
35
+ "okstra-subagent-reclaim.sh",
35
36
  "okstra-team-reconcile.sh",
36
37
  "okstra-central.sh",
37
38
  "okstra-token-usage.py",