okstra 0.30.3 → 0.31.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.
@@ -107,6 +107,19 @@ def _strip_leading_digest_comment(text: str) -> str:
107
107
  return rest
108
108
  return text
109
109
 
110
+
111
+ _LEADING_FRONTMATTER_RE = re.compile(
112
+ r"\A---\s*\n.*?\n---\s*\n?", re.DOTALL
113
+ )
114
+
115
+
116
+ def _strip_leading_frontmatter(text: str) -> str:
117
+ """Remove a leading YAML frontmatter block (``---\\n…\\n---\\n``) if
118
+ present. Used by ``render_html`` so the HTML view does not surface
119
+ the Obsidian-side frontmatter as a paragraph. The slim markdown
120
+ keeps the frontmatter for Obsidian/Templater consumers."""
121
+ return _LEADING_FRONTMATTER_RE.sub("", text, count=1)
122
+
110
123
  from .clarification_items import (
111
124
  _is_separator_row,
112
125
  _section_5_slice,
@@ -350,9 +363,11 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
350
363
  # recomputes the same digest from the current MD and compares it to
351
364
  # the value embedded in run-meta below.
352
365
  src_md = _strip_leading_digest_comment(src_md)
353
- body_html = _markdown_to_html(src_md)
354
- response_ids = sorted({item.row_id for item in (parse_clarification_items(src_md) or [])})
355
366
  digest = source_digest(src_md)
367
+ body_md = _strip_leading_frontmatter(src_md)
368
+ body_html, toc_headings = _markdown_to_html(body_md)
369
+ body_html = _inject_toc(body_html, toc_headings)
370
+ response_ids = sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
356
371
 
357
372
  title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
358
373
  response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
@@ -391,15 +406,22 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
391
406
  )
392
407
 
393
408
 
394
- def _markdown_to_html(src_md: str) -> str:
409
+ def _markdown_to_html(
410
+ src_md: str,
411
+ ) -> tuple[str, list[tuple[int, str, str]]]:
395
412
  """Tiny line-based markdown→HTML emitter. Handles only what the
396
413
  final-report template uses: headings, paragraphs, pipe tables,
397
414
  fenced code blocks, blockquotes, ordered+unordered lists, inline
398
415
  code/bold/links. Anything outside that surface is passed through
399
416
  as escaped text inside a paragraph — there are no extension points.
417
+
418
+ Returns ``(body_html, toc_headings)`` where ``toc_headings`` is a
419
+ list of ``(level, slug, text)`` for every emitted heading in source
420
+ order. Callers use this to build a navigable table of contents.
400
421
  """
401
422
  lines = src_md.splitlines()
402
423
  out: list[str] = []
424
+ headings: list[tuple[int, str, str]] = []
403
425
  i = 0
404
426
  n = len(lines)
405
427
  current_section_path: list[str] = [] # ['## 5. ...', '### 5.1 ...'] etc.
@@ -414,6 +436,7 @@ def _markdown_to_html(src_md: str) -> str:
414
436
  slug = _slugify(text)
415
437
  current_section_path = _update_section_path(current_section_path, level, line)
416
438
  out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
439
+ headings.append((level, slug, text))
417
440
  i += 1
418
441
  continue
419
442
 
@@ -472,7 +495,48 @@ def _markdown_to_html(src_md: str) -> str:
472
495
  if para_lines:
473
496
  out.append("<p>" + _inline(" ".join(para_lines)) + "</p>")
474
497
 
475
- return "\n".join(out)
498
+ return "\n".join(out), headings
499
+
500
+
501
+ _INDEX_BLOCK_RE = re.compile(
502
+ r'<h2 id="index">Index</h2>\n<ul>.*?</ul>', re.DOTALL
503
+ )
504
+
505
+
506
+ def _build_toc(headings: list[tuple[int, str, str]]) -> str:
507
+ """Render a ``<nav class="toc">`` block from collected h2/h3 entries.
508
+ h1 (the report title) is omitted; h4+ are too granular for the TOC."""
509
+ items: list[str] = []
510
+ for level, slug, text in headings:
511
+ if level not in (2, 3):
512
+ continue
513
+ if slug == "index": # skip the source "Index" heading itself
514
+ continue
515
+ cls = "toc-h2" if level == 2 else "toc-h3"
516
+ items.append(
517
+ f'<li class="{cls}"><a href="#{slug}">{_inline(text)}</a></li>'
518
+ )
519
+ if not items:
520
+ return ""
521
+ return '<nav class="toc" aria-label="Table of contents">' \
522
+ + '<div class="toc-title">목차</div>' \
523
+ + "<ul>" + "".join(items) + "</ul>" \
524
+ + "</nav>"
525
+
526
+
527
+ def _inject_toc(body_html: str, headings: list[tuple[int, str, str]]) -> str:
528
+ """If the source had an ``## Index`` section followed by a bullet
529
+ list (the template's static TOC), replace it with the auto-built
530
+ nav. Otherwise prepend the nav at the top of the body so readers
531
+ still get a navigation aid. If no h2/h3 headings exist the body is
532
+ returned unchanged.
533
+ """
534
+ nav = _build_toc(headings)
535
+ if not nav:
536
+ return body_html
537
+ if _INDEX_BLOCK_RE.search(body_html):
538
+ return _INDEX_BLOCK_RE.sub(nav, body_html, count=1)
539
+ return nav + "\n" + body_html
476
540
 
477
541
 
478
542
  def _update_section_path(path: list[str], level: int, line: str) -> list[str]:
@@ -509,7 +573,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
509
573
 
510
574
  head = (
511
575
  "<thead><tr>"
512
- + "".join(f"<th>{_inline(c)}</th>" for c in header_cells)
576
+ + "".join(f"<th{_cell_class(c)}>{_inline(c)}</th>" for c in header_cells)
513
577
  + "</tr></thead>"
514
578
  )
515
579
 
@@ -517,6 +581,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
517
581
  id_col = header_cells.index("ID") if "ID" in header_cells else -1
518
582
  kind_col = header_cells.index("Kind") if "Kind" in header_cells else -1
519
583
  status_col = header_cells.index("Status") if "Status" in header_cells else -1
584
+ statement_col = next(
585
+ (i for i, h in enumerate(header_cells) if h.startswith("Statement")),
586
+ -1,
587
+ )
520
588
  user_input_col = (
521
589
  header_cells.index("User input") if "User input" in header_cells else -1
522
590
  )
@@ -531,14 +599,19 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
531
599
  response_id = row[id_col]
532
600
  kind = row[kind_col] if 0 <= kind_col < len(row) else ""
533
601
  status = row[status_col] if 0 <= status_col < len(row) else ""
602
+ statement = (
603
+ row[statement_col]
604
+ if 0 <= statement_col < len(row)
605
+ else ""
606
+ )
534
607
  cells_html: list[str] = []
535
608
  for idx, cell in enumerate(row):
536
609
  if idx == user_input_col:
537
610
  cells_html.append(
538
- f"<td>{_form_control(response_id, kind, status, cell)}</td>"
611
+ f"<td>{_form_control(response_id, kind, status, cell, statement)}</td>"
539
612
  )
540
613
  else:
541
- cells_html.append(f"<td>{_inline(cell)}</td>")
614
+ cells_html.append(f"<td{_cell_class(cell)}>{_inline(cell)}</td>")
542
615
  body_rows.append(
543
616
  f'<tr data-response-id="{html.escape(response_id)}" '
544
617
  f'data-kind="{html.escape(kind)}" '
@@ -548,22 +621,106 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
548
621
  )
549
622
  else:
550
623
  body_rows.append(
551
- "<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in row) + "</tr>"
624
+ "<tr>"
625
+ + "".join(f"<td{_cell_class(c)}>{_inline(c)}</td>" for c in row)
626
+ + "</tr>"
552
627
  )
553
628
 
554
629
  body = "<tbody>" + "".join(body_rows) + "</tbody>"
555
630
  return f"<table>{head}{body}</table>", i - start
556
631
 
557
632
 
558
- def _form_control(response_id: str, kind: str, status: str, current_value: str) -> str:
633
+ _ENUM_LETTERS = "abcde"
634
+ _ENUM_CUE_WORDS: tuple[str, ...] = (
635
+ "권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
636
+ )
637
+
638
+
639
+ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
640
+ """Return ``[(letter, text)]`` pairs if ``statement`` looks like a
641
+ ``(a) X (b) Y (c) Z`` enumeration, else ``[]``. Stops at the first
642
+ recommendation cue (``권장``/``추천``/``사유``/``근거``) so the
643
+ "(c) — recommended because ..." tail doesn't leak into the last
644
+ option's text. Requires ≥ 2 options to trigger select rendering.
645
+ """
646
+ if not statement:
647
+ return []
648
+ out: list[tuple[str, str]] = []
649
+ cursor = 0
650
+ for i, letter in enumerate(_ENUM_LETTERS):
651
+ anchor = f"({letter}) "
652
+ idx = statement.find(anchor, cursor)
653
+ if idx < 0:
654
+ break
655
+ start = idx + len(anchor)
656
+ end_candidates: list[int] = []
657
+ if i + 1 < len(_ENUM_LETTERS):
658
+ n_idx = statement.find(f"({_ENUM_LETTERS[i + 1]}) ", start)
659
+ if n_idx >= 0:
660
+ end_candidates.append(n_idx)
661
+ for cue in _ENUM_CUE_WORDS:
662
+ c_idx = statement.find(cue, start)
663
+ if c_idx >= 0:
664
+ end_candidates.append(c_idx)
665
+ end = min(end_candidates) if end_candidates else len(statement)
666
+ text = statement[start:end].strip(" .,—-")
667
+ if not text:
668
+ break
669
+ out.append((letter, text))
670
+ cursor = end
671
+ return out if len(out) >= 2 else []
672
+
673
+
674
+ def _form_control(
675
+ response_id: str,
676
+ kind: str,
677
+ status: str,
678
+ current_value: str,
679
+ statement: str = "",
680
+ ) -> str:
559
681
  rid = html.escape(response_id)
560
682
  disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
561
683
  safe_value = html.escape(current_value or "")
684
+ kind_lc = kind.lower()
562
685
  placeholder = {
563
686
  "material": "파일 경로 또는 본문",
564
687
  "decision": "선택 또는 짧은 응답",
565
688
  "data-point": "값",
566
- }.get(kind.lower(), "응답")
689
+ }.get(kind_lc, "응답")
690
+
691
+ # decision 인 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
692
+ if kind_lc == "decision":
693
+ opts = _parse_enum_options(statement)
694
+ if opts:
695
+ select_opts = "".join(
696
+ f'<option value="{letter}">'
697
+ f'{_inline(f"({letter}) " + text)}</option>'
698
+ for letter, text in opts
699
+ )
700
+ select_html = (
701
+ f'<select name="{rid}" data-response-id="{rid}" '
702
+ f'data-kind="{html.escape(kind)}"{disabled}>'
703
+ '<option value="">(선택)</option>'
704
+ f"{select_opts}"
705
+ '<option value="__other__">기타 (직접 입력)</option>'
706
+ "</select>"
707
+ )
708
+ other_html = (
709
+ f' <input type="text" data-other-for="{rid}" '
710
+ f'placeholder="기타 응답"{disabled} hidden value="{safe_value}">'
711
+ )
712
+ return select_html + other_html
713
+
714
+ # material/data-point 류는 짧은 단일값. 텍스트 인풋으로.
715
+ if kind_lc in ("material", "data-point"):
716
+ return (
717
+ f'<input type="text" name="{rid}" data-response-id="{rid}" '
718
+ f'data-kind="{html.escape(kind)}" '
719
+ f'placeholder="{html.escape(placeholder)}"{disabled} '
720
+ f'value="{safe_value}">'
721
+ )
722
+
723
+ # 그 외 (decision-without-enum 포함) — 자유 본문은 textarea 유지.
567
724
  return (
568
725
  f'<textarea name="{rid}" data-response-id="{rid}" '
569
726
  f'data-kind="{html.escape(kind)}" rows="2" '
@@ -599,6 +756,25 @@ def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
599
756
  return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start
600
757
 
601
758
 
759
+ _TIGHT_CELL_MAX_PLAIN_LEN = 20
760
+
761
+ _INLINE_MD_STRIP_RE = re.compile(r"[`*_]")
762
+
763
+
764
+ def _cell_class(raw_cell: str) -> str:
765
+ """Return ``" class=\\"td-tight\\""`` when the cell's plain content
766
+ is short enough to fit on one line (<= 20 chars after stripping
767
+ inline markdown punctuation). Empty string otherwise.
768
+
769
+ Threshold mirrors the user-visible rule "td columns under 20 chars
770
+ should render on a single line".
771
+ """
772
+ plain = _INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip()
773
+ if 0 < len(plain) <= _TIGHT_CELL_MAX_PLAIN_LEN:
774
+ return ' class="td-tight"'
775
+ return ""
776
+
777
+
602
778
  def _inline(text: str) -> str:
603
779
  out = html.escape(text)
604
780
  # Restore inline markdown after escaping (we re-process the escaped
@@ -549,7 +549,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
549
549
  pr_template_source = resolved_tpl.source
550
550
 
551
551
  # ---- model assignments ----
552
- lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus")
552
+ lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", "opus-4-6")
553
553
  claude_default = _default("OKSTRA_DEFAULT_CLAUDE_MODEL", "sonnet")
554
554
  codex_default = _default("OKSTRA_DEFAULT_CODEX_MODEL", "gpt-5.5")
555
555
  gemini_default = _default("OKSTRA_DEFAULT_GEMINI_MODEL", "auto")
@@ -37,6 +37,7 @@ from okstra_ctl.workers import (
37
37
  ALLOWED_WORKERS,
38
38
  WorkersError,
39
39
  normalize_workers,
40
+ resolve_optional_workers,
40
41
  resolve_profile_workers,
41
42
  validate_workers_against_profile,
42
43
  )
@@ -215,6 +216,7 @@ class WizardState:
215
216
  # task-type + dependents
216
217
  task_type: str = ""
217
218
  profile_workers: list[str] = field(default_factory=list)
219
+ profile_optional_workers: list[str] = field(default_factory=list)
218
220
 
219
221
  # brief
220
222
  keep_existing_brief: Optional[bool] = None
@@ -278,6 +280,7 @@ class Prompt:
278
280
  options: list[Option] = field(default_factory=list)
279
281
  help: str = ""
280
282
  echo_template: str = "" # e.g. "task-group: {value}"
283
+ multi: bool = False # only meaningful when kind == "pick"
281
284
 
282
285
  def to_json(self) -> dict[str, Any]:
283
286
  return {
@@ -287,6 +290,7 @@ class Prompt:
287
290
  "options": [asdict(o) for o in self.options],
288
291
  "help": self.help,
289
292
  "echoTemplate": self.echo_template,
293
+ "multi": self.multi,
290
294
  }
291
295
 
292
296
 
@@ -401,6 +405,12 @@ def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
401
405
  return resolve_profile_workers(_profile_path(workspace_root, task_type))
402
406
 
403
407
 
408
+ def _load_profile_optional_workers(
409
+ workspace_root: Path, task_type: str
410
+ ) -> list[str]:
411
+ return resolve_optional_workers(_profile_path(workspace_root, task_type))
412
+
413
+
404
414
  def _resolved_roster(state: WizardState) -> list[str]:
405
415
  """Effective worker list AFTER override. Implementation: profile default
406
416
  (caller never asks for override). Others: override or profile default."""
@@ -628,6 +638,9 @@ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
628
638
  state.profile_workers = _load_profile_workers(
629
639
  Path(state.workspace_root), value
630
640
  )
641
+ state.profile_optional_workers = _load_profile_optional_workers(
642
+ Path(state.workspace_root), value
643
+ )
631
644
  # Reuse-worktree is decided once identity is final. Recompute here so
632
645
  # subsequent base-ref step knows whether to apply.
633
646
  state.reuse_worktree = _resolve_reuse_worktree(state)
@@ -932,25 +945,43 @@ def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
932
945
 
933
946
 
934
947
  def _build_workers_override(state: WizardState) -> Prompt:
935
- csv = ",".join(state.profile_workers)
948
+ """분석 워커 멀티픽. report-writer 는 옵션에서 빼고 항상 결과에 강제
949
+ 포함시킨다(프로필이 report-writer 를 Required 로 가질 때)."""
950
+ analyser_choices = [
951
+ w for w in (state.profile_workers + state.profile_optional_workers)
952
+ if w != "report-writer"
953
+ ]
954
+ options: list[Option] = []
955
+ for w in analyser_choices:
956
+ is_optional = w in state.profile_optional_workers
957
+ label = f"{w} (옵션)" if is_optional else w
958
+ options.append(_opt(value=w, label=label))
936
959
  return Prompt(
937
- step=S_WORKERS_OVERRIDE, kind="text",
938
- label=(f"참여 워커 목록을 쉼표로 구분해서 적어주세요. "
939
- f" 줄이면 프로필 기본값 [{csv}] 을 그대로 씁니다. "
940
- f"사용 가능한 워커: {csv}"),
960
+ step=S_WORKERS_OVERRIDE, kind="pick", multi=True,
961
+ label=("참여시킬 분석 워커를 선택해주세요 (최소 1개). "
962
+ "report-writer 항상 포함됩니다."),
963
+ options=options,
941
964
  echo_template="workers: {value}",
942
965
  )
943
966
 
944
967
 
945
968
  def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
946
- if not (value or "").strip():
947
- state.workers_override = ""
948
- return f"workers: (profile default: {','.join(state.profile_workers)})"
969
+ raw = (value or "").strip()
949
970
  try:
950
- chosen = normalize_workers(value)
951
- validate_workers_against_profile(chosen, state.profile_workers)
971
+ chosen = normalize_workers(raw) if raw else []
972
+ if not chosen:
973
+ raise WizardError("워커를 최소 1개 선택해주세요")
974
+ validate_workers_against_profile(
975
+ chosen,
976
+ state.profile_workers,
977
+ state.profile_optional_workers,
978
+ )
952
979
  except WorkersError as exc:
953
980
  raise WizardError(str(exc))
981
+ # report-writer 는 프로필이 Required 로 선언했을 때만 강제 포함.
982
+ if ("report-writer" in state.profile_workers
983
+ and "report-writer" not in chosen):
984
+ chosen.append("report-writer")
954
985
  state.workers_override = ",".join(chosen)
955
986
  return f"workers: {state.workers_override}"
956
987
 
@@ -1173,6 +1204,7 @@ STEPS: list[Step] = [
1173
1204
  build=_build_task_pick, submit=_submit_task_pick,
1174
1205
  owns=("is_new_task", "task_group", "task_id", "task_type",
1175
1206
  "existing_brief_path", "profile_workers",
1207
+ "profile_optional_workers",
1176
1208
  "task_group_suggestion", "task_id_suggestion",
1177
1209
  "task_group_pending_text", "task_id_pending_text")),
1178
1210
  Step(S_BRIEF_PATH,
@@ -1228,7 +1260,8 @@ STEPS: list[Step] = [
1228
1260
  and (s.is_new_task is False or bool(s.task_id))
1229
1261
  and S_TASK_TYPE not in s.answered),
1230
1262
  build=_build_task_type, submit=_submit_task_type,
1231
- owns=("task_type", "profile_workers", "reuse_worktree")),
1263
+ owns=("task_type", "profile_workers", "profile_optional_workers",
1264
+ "reuse_worktree")),
1232
1265
  Step(S_BRIEF_KEEP,
1233
1266
  applies=lambda s: (not s.is_new_task
1234
1267
  and bool(s.existing_brief_path)
@@ -1281,11 +1314,16 @@ STEPS: list[Step] = [
1281
1314
  and s.use_defaults is None),
1282
1315
  build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
1283
1316
  owns=("use_defaults",)),
1284
- # Customize branch — workers override only when non-empty profile + not impl.
1317
+ # Customize branch — workers override only when the profile actually
1318
+ # has analyser-candidate workers (required ∪ optional, minus report-writer).
1285
1319
  Step(S_WORKERS_OVERRIDE,
1286
1320
  applies=lambda s: (s.use_defaults is False
1287
1321
  and s.task_type != "implementation"
1288
- and bool(s.profile_workers)
1322
+ and any(
1323
+ w != "report-writer"
1324
+ for w in (s.profile_workers
1325
+ + s.profile_optional_workers)
1326
+ )
1289
1327
  and S_WORKERS_OVERRIDE not in s.answered),
1290
1328
  build=_build_workers_override, submit=_submit_workers_override,
1291
1329
  owns=("workers_override",)),
@@ -1446,7 +1484,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
1446
1484
  "existing_brief_path": "", "task_type": "",
1447
1485
  "task_group_suggestion": "", "task_id_suggestion": "",
1448
1486
  "task_group_pending_text": False, "task_id_pending_text": False,
1449
- "profile_workers": [], "keep_existing_brief": None,
1487
+ "profile_workers": [], "profile_optional_workers": [],
1488
+ "keep_existing_brief": None,
1450
1489
  "brief_path": "", "reuse_worktree": None, "base_ref": "",
1451
1490
  "base_ref_pending_text": False, "approved_plan_path": "",
1452
1491
  "approved_plan_pending_text": False,
@@ -16,16 +16,20 @@ PROFILE_BULLET_HEADERS = {
16
16
  "- Reviewers:",
17
17
  "- Analysers:",
18
18
  }
19
+ PROFILE_BULLET_HEADERS_OPTIONAL = {
20
+ "- Optional workers:",
21
+ "- Optional workers (opt-in via `--workers`):",
22
+ }
19
23
 
20
24
 
21
25
  class WorkersError(Exception):
22
26
  """invalid worker selection — surface to user."""
23
27
 
24
28
 
25
- def resolve_profile_workers(profile_path: Path) -> list[str]:
26
- """`prompts/profiles/<task-type>.md` 본문의 `- Workers:` 섹션 아래
27
- sub-bullet 들을 worker id 리스트로 돌려준다.
28
- profile 파일이 없거나 섹션이 없으면 리스트.
29
+ def _resolve_workers_under(profile_path: Path, headers: set[str]) -> list[str]:
30
+ """Generic parser: collect ` - <id> …` sub-bullets under any of `headers`
31
+ until the next top-level bullet. Returns first token (before any
32
+ ` ` / ` -- ` / whitespace) of each captured line.
29
33
  """
30
34
  if not Path(profile_path).is_file():
31
35
  return []
@@ -33,7 +37,7 @@ def resolve_profile_workers(profile_path: Path) -> list[str]:
33
37
  out: list[str] = []
34
38
  for line in Path(profile_path).read_text(encoding="utf-8").splitlines():
35
39
  stripped = line.strip()
36
- if stripped in PROFILE_BULLET_HEADERS:
40
+ if stripped in headers:
37
41
  capturing = True
38
42
  continue
39
43
  if not capturing:
@@ -41,13 +45,40 @@ def resolve_profile_workers(profile_path: Path) -> list[str]:
41
45
  if line.startswith("- "):
42
46
  break
43
47
  if line.startswith(" - "):
44
- out.append(line[4:].strip())
48
+ body = line[4:].strip()
49
+ token = body.split(" — ", 1)[0].split(" -- ", 1)[0].split()[0]
50
+ out.append(token)
45
51
  continue
46
52
  if stripped:
47
53
  break
48
54
  return out
49
55
 
50
56
 
57
+ def resolve_profile_workers(profile_path: Path) -> list[str]:
58
+ """`prompts/profiles/<task-type>.md` 본문의 `- Required workers:` (또는
59
+ `- Workers:` / `- Reviewers:` / `- Analysers:`) 섹션 아래 sub-bullet 들을
60
+ worker id 리스트로 돌려준다. profile 파일이 없거나 섹션이 없으면 빈 리스트.
61
+ """
62
+ return _resolve_workers_under(profile_path, PROFILE_BULLET_HEADERS)
63
+
64
+
65
+ def resolve_optional_workers(profile_path: Path) -> list[str]:
66
+ r"""`- Optional workers (opt-in via \`--workers\`):` 섹션 아래 sub-bullet
67
+ 들에서 worker id 만 추출한다. (` - gemini — when added …` → `gemini`)
68
+ Required 와 중복되는 항목은 제거. ALLOWED_WORKERS 밖 토큰도 제거.
69
+ """
70
+ raw = _resolve_workers_under(profile_path, PROFILE_BULLET_HEADERS_OPTIONAL)
71
+ required = set(resolve_profile_workers(profile_path))
72
+ seen: set[str] = set()
73
+ out: list[str] = []
74
+ for w in raw:
75
+ if w in required or w in seen or w not in ALLOWED_WORKERS:
76
+ continue
77
+ seen.add(w)
78
+ out.append(w)
79
+ return out
80
+
81
+
51
82
  def normalize_workers(value: str) -> list[str]:
52
83
  """CSV 입력을 정규화한다.
53
84
 
@@ -71,20 +102,23 @@ def normalize_workers(value: str) -> list[str]:
71
102
 
72
103
 
73
104
  def validate_workers_against_profile(
74
- workers: list[str], profile_workers: list[str]
105
+ workers: list[str],
106
+ profile_workers: list[str],
107
+ optional_workers: list[str] | None = None,
75
108
  ) -> None:
76
109
  """프로파일이 `Required workers:` 로 로스터를 선언했다면, 사용자
77
- override 가 부분집합인지 검증한다.
110
+ override 가 (required ∪ optional) 의 부분집합인지 검증한다.
78
111
 
79
112
  `profile_workers` 가 비어 있으면(프로파일이 로스터를 선언하지 않은
80
- 구버전) 검증을 건너뛴다 — 하위 호환을 위해.
113
+ 구버전) 검증을 건너뛴다.
81
114
  """
82
115
  if not profile_workers:
83
116
  return
84
- allowed = set(profile_workers)
117
+ allowed = set(profile_workers) | set(optional_workers or [])
85
118
  extras = [w for w in workers if w not in allowed]
86
119
  if extras:
120
+ allow_str = ",".join(profile_workers + list(optional_workers or []))
87
121
  raise WorkersError(
88
122
  "workers not allowed by profile roster: "
89
- f"{','.join(extras)} (profile allows: {','.join(profile_workers)})"
123
+ f"{','.join(extras)} (profile allows: {allow_str})"
90
124
  )
@@ -47,6 +47,7 @@ CLAUDE_PRICING = {
47
47
 
48
48
  # Claude 4 point releases (explicit so future divergence is easy to see).
49
49
  "opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
50
+ "opus-4-6": (5.0, 6.25, 0.50, 25.0), # Opus 4.6 (legacy; pricing matches 4.7 per Anthropic)
50
51
  "sonnet-4-6": (3.0, 3.75, 0.30, 15.0), # Sonnet 4.6 (cache prices derived from ratios)
51
52
  "haiku-4-5": (1.0, 1.25, 0.10, 5.0), # Haiku 4.5 (cache prices derived from ratios)
52
53
 
@@ -140,11 +140,11 @@ The final report follows the structure below. If `instruction-set/final-report-t
140
140
  ```markdown
141
141
  | Agent | Role | Model | Status | 처리 토큰 | 환산 토큰 | 비용 (USD) | Duration | Summary of Key Findings |
142
142
  |-------|------|-------|--------|-----------|-----------|------------|----------|------------------------|
143
- | Claude Code | Claude lead | opus | completed | 10,479,327 | 1,769,798 | $26.55 | 59m 12s | Final synthesis status |
143
+ | Claude Code | Claude lead | opus-4-6 | completed | 10,479,327 | 1,769,798 | $26.55 | 59m 12s | Final synthesis status |
144
144
  | Claude Code | Claude worker | sonnet | completed | 1,941,396 | 475,136 | $1.43 | 13m 33s | Key findings summary |
145
145
  | Codex | Codex worker | gpt-5.5 | completed | 2,274,011 (CLI: 5,261,833) | 586,223 | $8.79 (+ CLI $4.20) | 22m 06s | Key findings summary |
146
146
  | Gemini | Gemini worker | auto | completed | 3,107,795 | 746,623 | $11.20 | 22m 06s | Key findings summary |
147
- | Claude Code | Report writer | opus | completed | 665,497 | 267,210 | $4.01 | 4m 20s | Report organization |
147
+ | Claude Code | Report writer | opus-4-6 | completed | 665,497 | 267,210 | $4.01 | 4m 20s | Report organization |
148
148
  ```
149
149
 
150
150
  Table Generation Rules:
@@ -38,9 +38,10 @@ Every wizard call returns JSON. The two shapes you'll see:
38
38
 
39
39
  On `ok: false`, re-prompt with the same `current.step` using the error message. The wizard never advances on validation failure; the user retries the same step.
40
40
 
41
- The wizard tells you *which UI to use* via `kind`:
41
+ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag on `pick`):
42
42
 
43
- - `kind: "pick"` → render `AskUserQuestion` with `label` and `options[].label` (use `options[].value` to call `--answer`).
43
+ - `kind: "pick"` + `multi: false` (default) → render `AskUserQuestion` with `label`, `options[].label`, and `multiSelect: false`. Use the chosen `options[].value` (single string) as the answer.
44
+ - `kind: "pick"` + `multi: true` → render `AskUserQuestion` with `label`, `options[].label`, and `multiSelect: true`. Join the chosen `options[].value` entries with `,` into a single CSV string and submit that as `--answer "csv,values"`. If the user selects nothing, still submit `--answer ""` — the wizard will reply `ok: false` and re-prompt the same step (do not skip the call).
44
45
  - `kind: "text"` → write `label` as a plain text message and consume the user's NEXT message as the answer.
45
46
  - `kind: "done"` → input collection finished; move to Step 5.
46
47
 
@@ -90,8 +91,9 @@ Output: the same `{ok, next}` JSON described above. The first `next` is always `
90
91
 
91
92
  Repeat until `next.kind == "done"`:
92
93
 
93
- 1. **Render** the prompt according to `kind`:
94
- - `pick` → `AskUserQuestion` with `label` and `options`. The user's chosen option's `value` is the answer string.
94
+ 1. **Render** the prompt according to `kind` (and `multi` for pick):
95
+ - `pick` + `multi: false` → `AskUserQuestion` with `multiSelect: false`, `label`, and `options`. The user's chosen option's `value` is the answer string.
96
+ - `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,gemini"`) and submit it as a single `--answer "claude,codex,gemini"`. Empty selection submits `--answer ""` and the wizard re-prompts.
95
97
  - `text` → plain text message containing `label`. Consume the user's next reply verbatim as the answer string (empty reply = empty string).
96
98
  2. **Submit** the answer — call `okstra wizard step` with the literal state-file path from Step 2 and the literal user answer (no shell variables, no `$(...)`):
97
99
  ```bash