okstra 0.30.2 → 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.
- package/bin/okstra +4 -0
- package/docs/kr/architecture.md +2 -2
- package/docs/kr/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +4 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +23 -6
- package/runtime/agents/workers/gemini-worker.md +23 -6
- package/runtime/agents/workers/report-writer-worker.md +2 -1
- package/runtime/bin/okstra-codex-exec.sh +31 -0
- package/runtime/bin/okstra-gemini-exec.sh +26 -0
- package/runtime/python/lib/okstra/globals.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +2 -2
- package/runtime/python/okstra_ctl/models.py +2 -0
- package/runtime/python/okstra_ctl/report_views.py +186 -10
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/wizard.py +53 -14
- package/runtime/python/okstra_ctl/workers.py +45 -11
- package/runtime/python/okstra_token_usage/pricing.py +1 -0
- package/runtime/skills/okstra-logs/SKILL.md +2 -6
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/skills/okstra-run/SKILL.md +6 -4
- package/runtime/skills/okstra-team-contract/SKILL.md +30 -8
- package/runtime/templates/reports/final-report.template.md +14 -8
- package/runtime/templates/reports/report.css +51 -4
- package/runtime/templates/reports/report.js +63 -7
- package/runtime/templates/reports/settings.template.json +1 -0
- package/src/install.mjs +1 -0
- package/src/token-usage.mjs +51 -0
|
@@ -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(
|
|
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>"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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="
|
|
938
|
-
label=(
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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(
|
|
951
|
-
|
|
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", "
|
|
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
|
|
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
|
|
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": [], "
|
|
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
|
|
26
|
-
"""`
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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],
|
|
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: {
|
|
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
|
|
|
@@ -64,14 +64,10 @@ find <LOGS_ROOT> -type f -path '*/runs/*/prompts/*.log' \
|
|
|
64
64
|
|
|
65
65
|
The columns produced are `size_bytes | mtime_epoch | path`.
|
|
66
66
|
|
|
67
|
-
On macOS, `find -printf` is unavailable. Fall back to
|
|
67
|
+
On macOS, `find -printf` is unavailable. Fall back to `-exec stat` — again substitute the literal `<LOGS_ROOT>`. The `-exec ... {} +` form contains no shell variables and no `$(...)`, so the `Bash(find:*)` permission match holds:
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
find <LOGS_ROOT> -type f -path '*/runs/*/prompts/*.log' 2>/dev/null
|
|
71
|
-
| while IFS= read -r p; do
|
|
72
|
-
stat -f '%z%t%m%t%N' "$p"
|
|
73
|
-
done \
|
|
74
|
-
| sort -k1,1nr
|
|
70
|
+
find <LOGS_ROOT> -type f -path '*/runs/*/prompts/*.log' -exec stat -f '%z%t%m%t%N' {} + 2>/dev/null | sort -k1,1nr
|
|
75
71
|
```
|
|
76
72
|
|
|
77
73
|
If the result is empty, report `No wrapper log files found under <projectRoot>` and exit.
|
|
@@ -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
|
|
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
|
|
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
|