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.
@@ -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
- "<tr>"
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
- recommended = rec.get("name") or ""
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
- reason = (
855
- f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소 — 답변을 채워 Export 한 뒤 "
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, recommended_option=recommended, disabled_reason=reason
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
- f'\n <p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
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.to_json()}
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.to_json()}
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.to_json()}
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.to_json()},
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).to_json()}
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).to_json()},
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 { 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
  }
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",