okstra 0.56.1 → 0.58.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.56.1",
3
+ "version": "0.58.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.56.1",
3
- "builtAt": "2026-06-08T16:46:40.339Z",
2
+ "package": "0.58.0",
3
+ "builtAt": "2026-06-08T17:51:22.479Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -95,13 +95,16 @@
95
95
  }
96
96
  },
97
97
  "brief_path_pick": {
98
- "label": "task brief markdown 경로를 선택해주세요",
98
+ "label": "task brief markdown 선택해주세요",
99
99
  "echo_template": "brief(pick): {value}",
100
100
  "options": {
101
101
  "__existing__": "기존 brief 사용: {existing}",
102
102
  "__standard__": "표준 경로 사용: {standard}",
103
103
  "__free_input__": "직접 입력"
104
104
  },
105
+ "labels": {
106
+ "brief_candidate": "같은 task-group brief: {path}"
107
+ },
105
108
  "errors": {
106
109
  "existing_missing": "기존 brief 가 없습니다. 다른 옵션을 선택하세요."
107
110
  }
@@ -233,14 +236,20 @@
233
236
  "label": "추가 critic 패스를 돌릴까요? (놓친 finding/blocker 를 캐는 검증 패스 — opt-in)",
234
237
  "echo_template": "critic: {value}",
235
238
  "options": {
236
- "off": "사용 안 함 (기본·추천)",
237
- "claude": "claude critic (추천)",
238
- "__free_input__": "직접 입력 (codex / gemini)"
239
+ "off": "사용 안 함 (기본·추천)"
240
+ },
241
+ "labels": {
242
+ "provider_recommended": "{provider} critic (추천)",
243
+ "provider": "{provider} critic"
239
244
  }
240
245
  },
241
246
  "critic_text": {
242
- "label": "critic provider 를 직접 입력하세요 (codex / gemini)",
243
- "echo_template": "critic: {value}"
247
+ "label": "critic provider 를 선택하세요",
248
+ "echo_template": "critic: {value}",
249
+ "labels": {
250
+ "provider_recommended": "{provider} critic (추천)",
251
+ "provider": "{provider} critic"
252
+ }
244
253
  },
245
254
  "defaults_or_custom": {
246
255
  "label": "역할별로 어떤 모델을 쓸지 정하는 단계입니다 (참여 워커 구성을 바꾸는 게 아닙니다).\n· 기본값으로 진행 — lead·실행자/워커·report-writer 를 모두 추천 모델로 두고 바로 진행합니다.\n· 커스터마이즈 — 역할별 모델을 직접 고르고, 추가 directive·관련 task 도 지정합니다.",
@@ -99,6 +99,7 @@ PICK_USE_SUGGESTED = "__use_suggested__"
99
99
  PICK_TYPE_CUSTOM = "__free_input__"
100
100
  _RECENT_PREFIX = "__recent:"
101
101
  _REPORT_PREFIX = "__report:"
102
+ _BRIEF_PREFIX = "__brief:"
102
103
 
103
104
  # Lines of `key: value` we pull from a brief markdown frontmatter. The
104
105
  # parser is intentionally lightweight (no yaml dep) and tolerant — a
@@ -176,6 +177,38 @@ def _brief_suggestions(path: Path) -> tuple[str, str]:
176
177
  return tg, tid
177
178
 
178
179
 
180
+ def _project_relative_path(path: Path, project_root: Path) -> str:
181
+ try:
182
+ return str(path.relative_to(project_root))
183
+ except ValueError:
184
+ return str(path)
185
+
186
+
187
+ def _accept_brief_path(state: WizardState, path: Path) -> None:
188
+ """Record a validated brief and derive safe identity suggestions.
189
+
190
+ New-task runs now ask for ``task_group`` before the brief so the wizard
191
+ can offer same-group brief candidates. If the selected brief carries a
192
+ conflicting frontmatter ``task-group``, fail fast; otherwise keep using
193
+ the brief's ``brief-id`` as the task-id suggestion.
194
+ """
195
+ tg_suggestion, tid_suggestion = _brief_suggestions(path)
196
+ if state.task_group and tg_suggestion:
197
+ suggested_group = _slug_or_die(tg_suggestion, "task_group")
198
+ if suggested_group != state.task_group:
199
+ raise WizardError(
200
+ "brief task-group does not match selected task-group: "
201
+ f"{tg_suggestion!r} != {state.task_group!r} ({path})"
202
+ )
203
+ state.brief_path = str(path)
204
+ state.brief_path_pending_text = False
205
+ if state.is_new_task:
206
+ if not state.task_group:
207
+ state.task_group_suggestion = tg_suggestion
208
+ if not state.task_id:
209
+ state.task_id_suggestion = tid_suggestion
210
+
211
+
179
212
  # ---- Step IDs ------------------------------------------------------------
180
213
 
181
214
  S_TASK_PICK = "task_pick"
@@ -771,6 +804,39 @@ def _suggest_recent_task_ids(state: WizardState, limit: int = 2) -> list[str]:
771
804
  return seen
772
805
 
773
806
 
807
+ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
808
+ """Return recent okstra-brief outputs for the selected task-group.
809
+
810
+ ``okstra-brief`` writes to ``.okstra/briefs/<task-group>/**/*.md``. The
811
+ wizard exposes those paths after task-group selection so users can pick a
812
+ generated brief instead of typing its path. Paths are project-relative.
813
+ """
814
+ if not state.project_root or not state.task_group:
815
+ return []
816
+ project_root = Path(state.project_root)
817
+ root = project_root / ".okstra" / "briefs" / state.task_group
818
+ if not root.is_dir():
819
+ return []
820
+ candidates: list[tuple[float, str]] = []
821
+ for path in root.rglob("*.md"):
822
+ if not path.is_file():
823
+ continue
824
+ tg_suggestion, _ = _brief_suggestions(path)
825
+ if tg_suggestion:
826
+ try:
827
+ if _slug_or_die(tg_suggestion, "task_group") != state.task_group:
828
+ continue
829
+ except WizardError:
830
+ continue
831
+ try:
832
+ mtime = path.stat().st_mtime
833
+ except OSError:
834
+ mtime = 0.0
835
+ candidates.append((mtime, _project_relative_path(path, project_root)))
836
+ candidates.sort(key=lambda item: (-item[0], item[1]))
837
+ return [rel for _, rel in candidates[:limit]]
838
+
839
+
774
840
  def _build_task_group(state: WizardState) -> Prompt:
775
841
  sugg = state.task_group_suggestion
776
842
  if sugg:
@@ -1000,6 +1066,12 @@ def _build_brief_path_pick(state: WizardState) -> Prompt:
1000
1066
  if standard and standard != existing:
1001
1067
  options.append(_opt("__standard__",
1002
1068
  t["options"]["__standard__"].format(standard=standard)))
1069
+ brief_label = t["labels"].get("brief_candidate", "{path}")
1070
+ for relpath in _suggest_group_briefs(state):
1071
+ if relpath in (existing, standard):
1072
+ continue
1073
+ options.append(_opt(f"{_BRIEF_PREFIX}{relpath}",
1074
+ brief_label.format(path=relpath)))
1003
1075
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1004
1076
  return Prompt(step=S_BRIEF_PATH_PICK, kind="pick",
1005
1077
  label=t["label"], options=options,
@@ -1013,20 +1085,24 @@ def _submit_brief_path_pick(state: WizardState, value: str) -> Optional[str]:
1013
1085
  t = _p(state.workspace_root, "brief_path_pick")
1014
1086
  raise WizardError(t["errors"]["existing_missing"])
1015
1087
  p = _require_file(existing, Path(state.project_root), "task brief")
1016
- state.brief_path = str(p)
1017
- state.brief_path_pending_text = False
1088
+ _accept_brief_path(state, p)
1018
1089
  return f"brief: {p}"
1019
1090
  if value == "__standard__":
1020
1091
  p = _require_file(standard, Path(state.project_root), "task brief")
1021
- state.brief_path = str(p)
1022
- state.brief_path_pending_text = False
1092
+ _accept_brief_path(state, p)
1093
+ return f"brief: {p}"
1094
+ if value.startswith(_BRIEF_PREFIX):
1095
+ relpath = value[len(_BRIEF_PREFIX):]
1096
+ p = _require_file(relpath, Path(state.project_root), "task brief")
1097
+ _accept_brief_path(state, p)
1023
1098
  return f"brief: {p}"
1024
1099
  if value == PICK_TYPE_CUSTOM:
1025
1100
  state.brief_path_pending_text = True
1026
1101
  state.brief_path = ""
1027
1102
  return None
1028
1103
  raise WizardError(
1029
- f"expected '__existing__', '__standard__', or {PICK_TYPE_CUSTOM!r}, "
1104
+ f"expected '__existing__', '__standard__', '{_BRIEF_PREFIX}<path>', "
1105
+ f"or {PICK_TYPE_CUSTOM!r}, "
1030
1106
  f"got: {value!r}"
1031
1107
  )
1032
1108
 
@@ -1042,16 +1118,7 @@ def _build_brief_path(state: WizardState) -> Prompt:
1042
1118
 
1043
1119
  def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
1044
1120
  p = _require_file(value, Path(state.project_root), "task brief")
1045
- state.brief_path = str(p)
1046
- state.brief_path_pending_text = False
1047
- # When the user is starting a brand-new task, pull task-group /
1048
- # task-id candidates from the brief frontmatter so the next two
1049
- # prompts can offer them as a one-click pick instead of forcing a
1050
- # free-text retype of what the brief already declares.
1051
- if state.is_new_task and not state.task_group and not state.task_id:
1052
- tg, tid = _brief_suggestions(p)
1053
- state.task_group_suggestion = tg
1054
- state.task_id_suggestion = tid
1121
+ _accept_brief_path(state, p)
1055
1122
  return f"brief: {p}"
1056
1123
 
1057
1124
 
@@ -1505,17 +1572,29 @@ def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
1505
1572
  return _submit_optional_cached_pick(state, value, _PR_TEMPLATE_PICK_SPEC)
1506
1573
 
1507
1574
 
1508
- CRITIC_CHOICES = ["off", "claude", "codex", "gemini"]
1575
+ def _critic_provider_choices() -> list[str]:
1576
+ return list(EXECUTORS)
1577
+
1578
+
1579
+ def _critic_choices() -> list[str]:
1580
+ return ["off", *_critic_provider_choices()]
1581
+
1582
+
1583
+ def _critic_provider_label(provider: str, t: dict) -> str:
1584
+ labels = t.get("labels", {})
1585
+ if provider == "claude":
1586
+ template = labels.get("provider_recommended", "{provider} critic (recommended)")
1587
+ else:
1588
+ template = labels.get("provider", "{provider} critic")
1589
+ return template.format(provider=provider)
1509
1590
 
1510
1591
 
1511
1592
  def _build_critic_pick(state: WizardState) -> Prompt:
1512
1593
  t = _p(state.workspace_root, "critic_pick")
1513
- options: list[Option] = []
1514
- for k, v in t["options"].items():
1515
- if not k.startswith("_"):
1516
- options.append(_opt(k, v))
1517
- custom_label = t["options"].get(PICK_TYPE_CUSTOM, PICK_TYPE_CUSTOM)
1518
- options.append(_opt(PICK_TYPE_CUSTOM, custom_label))
1594
+ off_label = t["options"].get("off", "off")
1595
+ options = [_opt("off", off_label)]
1596
+ for provider in _critic_provider_choices():
1597
+ options.append(_opt(provider, _critic_provider_label(provider, t)))
1519
1598
  return Prompt(
1520
1599
  step=S_CRITIC_PICK, kind="pick",
1521
1600
  label=t["label"],
@@ -1525,12 +1604,10 @@ def _build_critic_pick(state: WizardState) -> Prompt:
1525
1604
 
1526
1605
 
1527
1606
  def _submit_critic_pick(state: WizardState, value: str) -> Optional[str]:
1528
- if value == PICK_TYPE_CUSTOM:
1529
- state.critic_pending_text = True
1530
- return None
1531
1607
  choice = (value or "").strip().lower()
1532
- if choice not in CRITIC_CHOICES:
1533
- raise WizardError(f"critic must be one of {CRITIC_CHOICES}, got: {value!r}")
1608
+ choices = _critic_choices()
1609
+ if choice not in choices:
1610
+ raise WizardError(f"critic must be one of {choices}, got: {value!r}")
1534
1611
  state.critic = choice
1535
1612
  state.critic_pending_text = False
1536
1613
  return f"critic: {choice}"
@@ -1538,17 +1615,23 @@ def _submit_critic_pick(state: WizardState, value: str) -> Optional[str]:
1538
1615
 
1539
1616
  def _build_critic_text(state: WizardState) -> Prompt:
1540
1617
  t = _p(state.workspace_root, "critic_text")
1618
+ options = [
1619
+ _opt(provider, _critic_provider_label(provider, t))
1620
+ for provider in _critic_provider_choices()
1621
+ ]
1541
1622
  return Prompt(
1542
- step=S_CRITIC_TEXT, kind="text",
1623
+ step=S_CRITIC_TEXT, kind="pick",
1543
1624
  label=t["label"],
1625
+ options=options,
1544
1626
  echo_template=t["echo_template"],
1545
1627
  )
1546
1628
 
1547
1629
 
1548
1630
  def _submit_critic_text(state: WizardState, value: str) -> Optional[str]:
1549
1631
  choice = (value or "").strip().lower()
1550
- if choice not in CRITIC_CHOICES:
1551
- raise WizardError(f"critic must be one of {CRITIC_CHOICES}, got: {value!r}")
1632
+ providers = _critic_provider_choices()
1633
+ if choice not in providers:
1634
+ raise WizardError(f"critic must be one of {providers}, got: {value!r}")
1552
1635
  state.critic = choice
1553
1636
  state.critic_pending_text = False
1554
1637
  return f"critic: {choice}"
@@ -1907,13 +1990,25 @@ STEPS: list[Step] = [
1907
1990
  "profile_optional_workers",
1908
1991
  "task_group_suggestion", "task_id_suggestion",
1909
1992
  "task_group_pending_text", "task_id_pending_text")),
1993
+ Step(S_TASK_GROUP,
1994
+ applies=lambda s: (bool(s.is_new_task)
1995
+ and not s.task_group
1996
+ and not s.task_group_pending_text),
1997
+ build=_build_task_group, submit=_submit_task_group,
1998
+ owns=("task_group", "task_group_pending_text")),
1999
+ Step(S_TASK_GROUP_TEXT,
2000
+ applies=lambda s: (bool(s.is_new_task)
2001
+ and not s.task_group
2002
+ and s.task_group_pending_text),
2003
+ build=_build_task_group_text, submit=_submit_task_group_text,
2004
+ owns=("task_group", "task_group_pending_text")),
1910
2005
  Step(S_BRIEF_PATH_PICK,
1911
2006
  applies=lambda s: (
1912
2007
  not s.brief_path
1913
2008
  and not s.brief_path_pending_text
1914
2009
  and S_BRIEF_PATH_PICK not in s.answered
1915
2010
  and (
1916
- (s.is_new_task is True)
2011
+ (s.is_new_task is True and bool(s.task_group))
1917
2012
  or (s.is_new_task is False
1918
2013
  and S_TASK_TYPE in s.answered
1919
2014
  and (s.keep_existing_brief is False
@@ -1927,20 +2022,6 @@ STEPS: list[Step] = [
1927
2022
  and S_BRIEF_PATH not in s.answered,
1928
2023
  build=_build_brief_path, submit=_submit_brief_path,
1929
2024
  owns=("brief_path", "task_group_suggestion", "task_id_suggestion")),
1930
- Step(S_TASK_GROUP,
1931
- applies=lambda s: (bool(s.is_new_task)
1932
- and bool(s.brief_path)
1933
- and not s.task_group
1934
- and not s.task_group_pending_text),
1935
- build=_build_task_group, submit=_submit_task_group,
1936
- owns=("task_group", "task_group_pending_text")),
1937
- Step(S_TASK_GROUP_TEXT,
1938
- applies=lambda s: (bool(s.is_new_task)
1939
- and bool(s.brief_path)
1940
- and not s.task_group
1941
- and s.task_group_pending_text),
1942
- build=_build_task_group_text, submit=_submit_task_group_text,
1943
- owns=("task_group", "task_group_pending_text")),
1944
2025
  Step(S_TASK_ID,
1945
2026
  applies=lambda s: (bool(s.is_new_task)
1946
2027
  and bool(s.brief_path)
@@ -120,7 +120,7 @@ That is the entire interactive flow. The wizard handles:
120
120
 
121
121
  - new-vs-existing task split, task-group / task-id slug validation,
122
122
  - task-type pick (with `nextRecommendedPhase` surfaced as recommended for existing tasks),
123
- - brief path (with `유지 / 변경` for existing tasks),
123
+ - brief path after task-group selection (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing tasks),
124
124
  - base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
125
125
  - `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
126
126
  - `Use defaults / Customize` branch with profile-aware worker/model questions,