okstra 0.65.0 → 0.67.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/docs/kr/cli.md CHANGED
@@ -296,7 +296,7 @@ scripts/okstra.sh --task-type implementation-planning --workers claude,codex --p
296
296
  ```
297
297
 
298
298
  > 모든 `--*-model` 플래그는 `scripts/okstra_ctl/models.py` 의 provider 별 mapping 에 등록된 alias 만 허용합니다. 등록되지 않은 값은 `UnknownModelError` 로 즉시 거부됩니다 (manifest 의 `modelExecutionValue` 와 실제 실행값 불일치로 인한 contract-violation 을 사전에 차단). 허용값:
299
- > - Claude (`--lead-model` / `--claude-model` / `--report-writer-model`): `opus`, `opus-4-7`, `claude-opus-4-7`, `opus-4-6`, `claude-opus-4-6`, `sonnet`, `sonnet-4-6`, `claude-sonnet-4-6`, `haiku`, `haiku-4-5`, `claude-haiku-4-5`, `claude-haiku-4-5-20251001`
299
+ > - Claude (`--lead-model` / `--claude-model` / `--report-writer-model`): `fable`, `fable-5`, `claude-fable-5`, `opus`, `opus-4-8`, `claude-opus-4-8`, `opus-4-7`, `claude-opus-4-7`, `opus-4-6`, `claude-opus-4-6`, `sonnet`, `sonnet-4-6`, `claude-sonnet-4-6`, `haiku`, `haiku-4-5`, `claude-haiku-4-5`, `claude-haiku-4-5-20251001`
300
300
  > - Codex (`--codex-model`): `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2`, `codex-auto-review`
301
301
  > - Gemini (`--gemini-model`): `auto`, `pro`, `gemini-3-flash-preview`, `gemini-3-pro-preview` (그리고 `gemini auto` / `gemini pro` 별칭)
302
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.65.0",
3
+ "version": "0.67.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.65.0",
3
- "builtAt": "2026-06-10T08:26:08.228Z",
2
+ "package": "0.67.0",
3
+ "builtAt": "2026-06-10T10:33:26.768Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -225,7 +225,7 @@ Spawn **analysis workers only** in the same turn (Phase 4 in Teams mode; Phase 5
225
225
 
226
226
  **Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([okstra-report-writer](./skills/okstra-report-writer/SKILL.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`); implementation executor/verifier variants keep `<workerId>-worker` / `<workerId>-executor`.
227
227
 
228
- **Agent `model:` on dispatch (BLOCKING — assignment is otherwise ignored).** The `Claude worker` `Agent(...)` call MUST set `model: "<family token of that role's modelExecutionValue>"` (`opus` / `sonnet` / `haiku`), per [okstra-team-contract](./skills/okstra-team-contract/SKILL.md) "Model Assignment Rules" #3–#4. The claude-worker definition is `model: inherit`, so omitting this parameter makes the worker silently run on the lead's model instead of its manifest assignment — the assigned-vs-actual deviation. `Codex worker` / `Gemini worker` are exempt: their CLI model is applied via the wrapper's own `--model` argument, so leave their Agent `model:` at `inherit` (rule #5).
228
+ **Agent `model:` on dispatch (BLOCKING — assignment is otherwise ignored).** The `Claude worker` `Agent(...)` call MUST set `model: "<family token of that role's modelExecutionValue>"` (`fable` / `opus` / `sonnet` / `haiku`), per [okstra-team-contract](./skills/okstra-team-contract/SKILL.md) "Model Assignment Rules" #3–#4. The claude-worker definition is `model: inherit`, so omitting this parameter makes the worker silently run on the lead's model instead of its manifest assignment — the assigned-vs-actual deviation. `Codex worker` / `Gemini worker` are exempt: their CLI model is applied via the wrapper's own `--model` argument, so leave their Agent `model:` at `inherit` (rule #5).
229
229
 
230
230
  The no-`team_name` fallback (Phase 5) is only legal when team-state's `teamCreate.status` is `"error"` for this run. If `teamCreate` is missing or `attempted: false`, the correct action when an Agent dispatch is rejected for a missing team is to GO BACK to Phase 3 and call `TeamCreate` — never to strip `team_name` and continue.
231
231
 
@@ -3,10 +3,10 @@
3
3
  "locale": "ko",
4
4
  "steps": {
5
5
  "task_pick": {
6
- "label": "어느 task?",
6
+ "label": "어느 task? (남은 작업 최신순 추천)",
7
7
  "echo_template": "task: {value}",
8
8
  "options": {
9
- "__new__": "Start a brand-new task",
9
+ "__new__": "직접 입력 (새 작업 또는 목록에 없는 task)",
10
10
  "_LATEST_SUFFIX": " (latest)"
11
11
  }
12
12
  },
@@ -80,9 +80,19 @@
80
80
  "label": "Task type?",
81
81
  "echo_template": "task-type: {value}",
82
82
  "options": {
83
- "_RECOMMENDED_SUFFIX": " (recommended)"
83
+ "_RECOMMENDED_SUFFIX": " (recommended)",
84
+ "_RERUN_SUFFIX": " (현재 phase 재실행)",
85
+ "_NEXT_SUFFIX": " (다음 단계)",
86
+ "__free_input__": "직접 입력"
87
+ },
88
+ "echo_variants": {
89
+ "free_input": "task-type: (직접 입력)"
84
90
  }
85
91
  },
92
+ "task_type_text": {
93
+ "label": "Task type? (입력 가능: requirements-discovery, improvement-discovery, error-analysis, implementation-planning, implementation, final-verification, release-handoff)",
94
+ "echo_template": "task-type: {value}"
95
+ },
86
96
  "brief_keep": {
87
97
  "label": "기존 brief 경로 [{existing_brief_path}] 를 유지할까요?",
88
98
  "echo_template": "brief: {value}",
@@ -8,7 +8,12 @@ from __future__ import annotations
8
8
  from dataclasses import dataclass
9
9
 
10
10
  CLAUDE_MAPPING = {
11
+ "fable": ("fable", "fable"),
12
+ "fable-5": ("fable-5", "claude-fable-5"),
13
+ "claude-fable-5": ("fable-5", "claude-fable-5"),
11
14
  "opus": ("opus", "opus"),
15
+ "opus-4-8": ("opus-4-8", "claude-opus-4-8"),
16
+ "claude-opus-4-8": ("opus-4-8", "claude-opus-4-8"),
12
17
  "opus-4-7": ("opus-4-7", "claude-opus-4-7"),
13
18
  "claude-opus-4-7": ("opus-4-7", "claude-opus-4-7"),
14
19
  "opus-4-6": ("opus-4-6", "claude-opus-4-6"),
@@ -358,7 +358,8 @@ def _enforce_schema(data: dict) -> None:
358
358
  # 구체버전 계열로 돌았는지" 가독성만 준다. CLI 가 실제 고른 버전과 다를 수
359
359
  # 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
360
360
  _DISPLAY_CONCRETE_CLAUDE = {
361
- "opus": "claude-opus-4-7",
361
+ "fable": "claude-fable-5",
362
+ "opus": "claude-opus-4-8",
362
363
  "sonnet": "claude-sonnet-4-6",
363
364
  "haiku": "claude-haiku-4-5",
364
365
  }
@@ -49,6 +49,7 @@ from okstra_ctl.workers import (
49
49
  resolve_profile_workers,
50
50
  validate_workers_against_profile,
51
51
  )
52
+ from okstra_ctl.workflow import PHASE_SEQUENCE
52
53
  from okstra_ctl import worktree_registry
53
54
  from okstra_ctl.worktree import (
54
55
  is_git_work_tree,
@@ -90,13 +91,18 @@ _STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
90
91
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
91
92
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
92
93
 
93
- CLAUDE_MODEL_OPTIONS = ["default", "opus", "sonnet", "haiku"]
94
+ CLAUDE_MODEL_OPTIONS = ["default", "fable", "opus", "sonnet", "haiku"]
94
95
  CODEX_MODEL_OPTIONS = ["default", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
95
96
  GEMINI_MODEL_OPTIONS = ["default", "gemini-3-pro-preview", "gemini-3-flash-preview", "auto"]
96
97
 
97
98
  # special pick value: start a brand-new task
98
99
  TASK_PICK_NEW_TOKEN = "__new__"
99
100
 
101
+ # AskUserQuestion renders at most 4 options per question; the last slot is
102
+ # always reserved for the direct-input option, so recommendation lists from
103
+ # dynamic sources (catalog, manifest) are capped at 3.
104
+ _RECOMMENDATION_CAP = 3
105
+
100
106
  # Pick-vs-free-text tokens shared by suggestion-aware prompts.
101
107
  PICK_USE_SUGGESTED = "__use_suggested__"
102
108
  PICK_TYPE_CUSTOM = "__free_input__"
@@ -220,6 +226,7 @@ S_TASK_GROUP_TEXT = "task_group_text"
220
226
  S_TASK_ID = "task_id"
221
227
  S_TASK_ID_TEXT = "task_id_text"
222
228
  S_TASK_TYPE = "task_type"
229
+ S_TASK_TYPE_TEXT = "task_type_text"
223
230
  S_BRIEF_KEEP = "brief_keep"
224
231
  S_BRIEF_PATH_PICK = "brief_path_pick"
225
232
  S_BRIEF_PATH = "brief_path"
@@ -788,12 +795,15 @@ def _build_task_pick(state: WizardState) -> Prompt:
788
795
  latest = read_latest_task(project_root) or {}
789
796
  latest_key = latest.get("taskKey") or ""
790
797
  latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
798
+ remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"]
791
799
  options: list[Option] = []
792
- for entry in tasks[:16]:
800
+ for entry in remaining[:_RECOMMENDATION_CAP]:
793
801
  key = entry.get("taskKey") or ""
794
802
  ttype = entry.get("taskType") or ""
795
- phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
796
- nxt = (entry.get("workflow") or {}).get("nextRecommendedPhase") or ""
803
+ # catalog entries are flat (render_task_catalog_discovery) there is
804
+ # no nested "workflow" object here, unlike task-manifest.json.
805
+ phase = entry.get("currentPhase") or ttype
806
+ nxt = entry.get("nextRecommendedPhase") or ""
797
807
  suffix = latest_suffix if key == latest_key else ""
798
808
  label = f"{key} · {phase} · next: {nxt}{suffix}"
799
809
  options.append(_opt(value=key, label=label))
@@ -838,7 +848,9 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
838
848
  return f"task: {value}"
839
849
 
840
850
 
841
- def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]:
851
+ def _suggest_recent_task_groups(
852
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
853
+ ) -> list[str]:
842
854
  """프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
843
855
  if not state.project_root:
844
856
  return []
@@ -856,7 +868,9 @@ def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]
856
868
  return seen
857
869
 
858
870
 
859
- def _suggest_recent_task_ids(state: WizardState, limit: int = 2) -> list[str]:
871
+ def _suggest_recent_task_ids(
872
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
873
+ ) -> list[str]:
860
874
  """현재 task_group 내의 최근 task-id 후보를 limit 개까지 반환."""
861
875
  if not state.project_root or not state.task_group:
862
876
  return []
@@ -925,7 +939,7 @@ def _build_task_group(state: WizardState) -> Prompt:
925
939
  echo_template=t["echo_template"],
926
940
  )
927
941
  # suggestion 이 없으면 catalog 의 최근 task-group 을 후보로 노출 + 직접 입력
928
- recent = _suggest_recent_task_groups(state, limit=2)
942
+ recent = _suggest_recent_task_groups(state)
929
943
  t = _p(state.workspace_root, "task_group_no_suggestion")
930
944
  recent_prefix = t.get("recent_label_prefix", "")
931
945
  options: list[Option] = []
@@ -999,7 +1013,7 @@ def _build_task_id(state: WizardState) -> Prompt:
999
1013
  label=t["label"], options=options,
1000
1014
  echo_template=t["echo_template"],
1001
1015
  )
1002
- recent = _suggest_recent_task_ids(state, limit=2)
1016
+ recent = _suggest_recent_task_ids(state)
1003
1017
  t = _p(state.workspace_root, "task_id_no_suggestion")
1004
1018
  recent_prefix = t.get("recent_label_prefix", "")
1005
1019
  options: list[Option] = []
@@ -1058,48 +1072,88 @@ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
1058
1072
  return f"task-id: {state.task_id}"
1059
1073
 
1060
1074
 
1061
- def _existing_task_next_phase(state: WizardState) -> str:
1062
- """ task 로 시작했더라도 입력한 task-key 가 이미 존재하면(=사실상 이어가기)
1063
- 그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
1075
+ def _existing_task_workflow(state: WizardState) -> dict:
1076
+ """현재 task-key 가 이미 존재하면 그 manifest 의 workflow dict 를 반환한다.
1064
1077
 
1065
- 사용자가 picker 에서 기존 task-key고르지 않고 new-task 흐름으로 같은
1066
- task-group/task-id 를 다시 입력한 경우에도 직전 phase 의 추천이 끊기지 않게
1067
- 하는 안전장치."""
1078
+ picker 기존 task 를 고른 경우뿐 아니라, new-task 흐름으로 같은
1079
+ task-group/task-id 를 다시 입력한 경우(=사실상 이어가기)에도 직전 phase
1080
+ 기반 추천이 끊기지 않게 하는 안전장치. 없으면 {}."""
1068
1081
  if not (state.project_id and state.task_group and state.task_id):
1069
- return ""
1082
+ return {}
1070
1083
  key = f"{state.project_id}:{state.task_group}:{state.task_id}"
1071
1084
  root = find_task_root(Path(state.project_root), key)
1072
1085
  if root is None:
1073
- return ""
1086
+ return {}
1074
1087
  workflow = (read_task_manifest(root) or {}).get("workflow") or {}
1075
- nxt = workflow.get("nextRecommendedPhase") or ""
1076
- return nxt if isinstance(nxt, str) else ""
1088
+ return workflow if isinstance(workflow, dict) else {}
1089
+
1090
+
1091
+ def _phase_after(task_type: str) -> str:
1092
+ """라이프사이클(PHASE_SEQUENCE) 상 task_type 바로 다음 단계. 없으면 ''."""
1093
+ try:
1094
+ idx = PHASE_SEQUENCE.index(task_type)
1095
+ except ValueError:
1096
+ return ""
1097
+ return PHASE_SEQUENCE[idx + 1] if idx + 1 < len(PHASE_SEQUENCE) else ""
1098
+
1099
+
1100
+ def _recent_task_types(state: WizardState) -> list[str]:
1101
+ """catalog 최신순으로 이 프로젝트에서 최근 사용된 task-type 목록(중복 제거)."""
1102
+ if not state.project_root:
1103
+ return []
1104
+ try:
1105
+ tasks = list_project_tasks(Path(state.project_root))
1106
+ except (OSError, StateError):
1107
+ return []
1108
+ out: list[str] = []
1109
+ for entry in tasks:
1110
+ tt = entry.get("taskType") or ""
1111
+ if tt and tt not in out:
1112
+ out.append(tt)
1113
+ return out
1077
1114
 
1078
1115
 
1079
1116
  def _build_task_type(state: WizardState) -> Prompt:
1080
1117
  t = _p(state.workspace_root, "task_type")
1081
1118
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
1119
+ rerun_suffix = t["options"].get("_RERUN_SUFFIX", "")
1120
+ next_suffix = t["options"].get("_NEXT_SUFFIX", "")
1121
+ description_by_type = dict(TASK_TYPES)
1082
1122
  options: list[Option] = []
1083
- recommended = state.task_type if not state.is_new_task else ""
1084
- if not recommended and state.is_new_task:
1085
- recommended = _existing_task_next_phase(state)
1086
- seen: list[str] = []
1087
- if recommended and recommended in TASK_TYPE_VALUES:
1088
- d = dict(TASK_TYPES)[recommended]
1089
- options.append(_opt(recommended, f"{recommended}{recommended_suffix}", d))
1090
- seen.append(recommended)
1091
- for tt, desc in TASK_TYPES:
1092
- if tt in seen:
1093
- continue
1094
- options.append(_opt(tt, tt, desc))
1123
+
1124
+ def add(task_type: str, suffix: str = "") -> None:
1125
+ if task_type not in description_by_type:
1126
+ return
1127
+ if any(o.value == task_type for o in options):
1128
+ return
1129
+ if len(options) >= _RECOMMENDATION_CAP:
1130
+ return
1131
+ options.append(_opt(task_type, f"{task_type}{suffix}",
1132
+ description_by_type[task_type]))
1133
+
1134
+ workflow = _existing_task_workflow(state)
1135
+ recommended = state.task_type or workflow.get("nextRecommendedPhase") or ""
1136
+ if not recommended and not workflow:
1137
+ recommended = TASK_TYPE_VALUES[0]
1138
+ add(recommended, recommended_suffix)
1139
+ add(workflow.get("currentPhase") or "", rerun_suffix)
1140
+ add(_phase_after(recommended), next_suffix)
1141
+ for tt in _recent_task_types(state):
1142
+ add(tt)
1143
+ for tt in TASK_TYPE_VALUES:
1144
+ add(tt)
1145
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1095
1146
  return Prompt(step=S_TASK_TYPE, kind="pick",
1096
1147
  label=t["label"], options=options,
1097
1148
  echo_template=t["echo_template"])
1098
1149
 
1099
1150
 
1100
- def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1151
+ def _apply_task_type(state: WizardState, value: str) -> str:
1101
1152
  if value not in TASK_TYPE_VALUES:
1102
- raise WizardError(f"unknown task-type: {value!r}")
1153
+ raise WizardError(
1154
+ f"unknown task-type: {value!r} "
1155
+ f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
1156
+ )
1103
1157
  state.task_type = value
1104
1158
  state.profile_workers = _load_profile_workers(
1105
1159
  Path(state.workspace_root), value
@@ -1113,6 +1167,27 @@ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1113
1167
  return f"task-type: {value}"
1114
1168
 
1115
1169
 
1170
+ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1171
+ # PICK_TYPE_CUSTOM leaves task_type empty while S_TASK_TYPE gets marked
1172
+ # answered — that combination is what gates S_TASK_TYPE_TEXT on.
1173
+ if value == PICK_TYPE_CUSTOM:
1174
+ state.task_type = ""
1175
+ t = _p(state.workspace_root, "task_type")
1176
+ return t["echo_variants"]["free_input"]
1177
+ return _apply_task_type(state, value)
1178
+
1179
+
1180
+ def _build_task_type_text(state: WizardState) -> Prompt:
1181
+ t = _p(state.workspace_root, "task_type_text")
1182
+ return Prompt(step=S_TASK_TYPE_TEXT, kind="text",
1183
+ label=t["label"],
1184
+ echo_template=t["echo_template"])
1185
+
1186
+
1187
+ def _submit_task_type_text(state: WizardState, value: str) -> Optional[str]:
1188
+ return _apply_task_type(state, value.strip())
1189
+
1190
+
1116
1191
  def _build_brief_keep(state: WizardState) -> Prompt:
1117
1192
  t = _p(state.workspace_root, "brief_keep",
1118
1193
  existing_brief_path=state.existing_brief_path)
@@ -2216,6 +2291,7 @@ STEPS: list[Step] = [
2216
2291
  (s.is_new_task is True and bool(s.task_group))
2217
2292
  or (s.is_new_task is False
2218
2293
  and S_TASK_TYPE in s.answered
2294
+ and bool(s.task_type)
2219
2295
  and (s.keep_existing_brief is False
2220
2296
  or not s.existing_brief_path))
2221
2297
  )
@@ -2250,11 +2326,19 @@ STEPS: list[Step] = [
2250
2326
  build=_build_task_type, submit=_submit_task_type,
2251
2327
  owns=("task_type", "profile_workers", "profile_optional_workers",
2252
2328
  "reuse_worktree")),
2329
+ Step(S_TASK_TYPE_TEXT,
2330
+ applies=lambda s: (S_TASK_TYPE in s.answered
2331
+ and not s.task_type
2332
+ and S_TASK_TYPE_TEXT not in s.answered),
2333
+ build=_build_task_type_text, submit=_submit_task_type_text,
2334
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2335
+ "reuse_worktree")),
2253
2336
  Step(S_BRIEF_KEEP,
2254
2337
  applies=lambda s: (not s.is_new_task
2255
2338
  and bool(s.existing_brief_path)
2256
2339
  and s.keep_existing_brief is None
2257
- and S_TASK_TYPE in s.answered),
2340
+ and S_TASK_TYPE in s.answered
2341
+ and bool(s.task_type)),
2258
2342
  build=_build_brief_keep, submit=_submit_brief_keep,
2259
2343
  owns=("keep_existing_brief",)),
2260
2344
  Step(S_BASE_REF_PICK,
@@ -4,9 +4,9 @@ Pricing is matched by substring against the model id recorded in the session
4
4
  transcript, so keys must reflect the *actual* model id form emitted by each
5
5
  provider:
6
6
 
7
- * Anthropic — `claude-opus-4-*`, `claude-sonnet-4-*`, `claude-haiku-4-5-*`,
8
- `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`, `claude-3-opus-*`,
9
- `claude-3-haiku-*`.
7
+ * Anthropic — `claude-fable-5*`, `claude-opus-4-*`, `claude-sonnet-4-*`,
8
+ `claude-haiku-4-5-*`, `claude-3-5-sonnet-*`, `claude-3-5-haiku-*`,
9
+ `claude-3-opus-*`, `claude-3-haiku-*`.
10
10
  * OpenAI / Codex — `gpt-5*`, `gpt-4o*`, `gpt-4*`.
11
11
  * Google / Gemini — `gemini-2.5-pro*`, `gemini-2.5-flash*`, `gemini-2.0-flash*`.
12
12
 
@@ -45,7 +45,11 @@ CLAUDE_PRICING = {
45
45
  "3-sonnet": (3.0, 3.75, 0.30, 15.0), # legacy 3 Sonnet
46
46
  "3-haiku": (0.25, 0.30, 0.03, 1.25), # Haiku 3
47
47
 
48
+ # Claude Fable 5 (tier above Opus).
49
+ "fable-5": (10.0, 12.5, 1.0, 50.0), # Fable 5 (cache prices derived from ratios)
50
+
48
51
  # Claude 4 point releases (explicit so future divergence is easy to see).
52
+ "opus-4-8": (5.0, 6.25, 0.50, 25.0), # Opus 4.8 (cache prices derived from ratios)
49
53
  "opus-4-7": (5.0, 6.25, 0.50, 25.0), # Opus 4.7 (cache prices derived from ratios)
50
54
  "opus-4-6": (5.0, 6.25, 0.50, 25.0), # Opus 4.6 (legacy; pricing matches 4.7 per Anthropic)
51
55
  "sonnet-4-6": (3.0, 3.75, 0.30, 15.0), # Sonnet 4.6 (cache prices derived from ratios)
@@ -118,8 +118,8 @@ Repeat until `next.kind == "done"`:
118
118
 
119
119
  That is the entire interactive flow. The wizard handles:
120
120
 
121
- - new-vs-existing task split, task-group / task-id slug validation,
122
- - task-type pick (with `nextRecommendedPhase` surfaced as recommended for existing tasks),
121
+ - new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (각각 최근 후보 3개 추천 + 직접 입력),
122
+ - task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
123
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,
@@ -56,15 +56,15 @@ Subsequent `okstra <subcmd>` calls self-bootstrap their Python path, so this ski
56
56
 
57
57
  This skill performs cross-task synthesis (multi-task classification, dependency reasoning, phase placement, Gantt/timeline assembly) which benefits substantially from Opus-class reasoning. The frontmatter `model: opus` field above instructs supporting Claude Code harness versions to switch automatically; if the harness ignores it, this gate catches the case explicitly.
58
58
 
59
- 1. Inspect the active session model. The model is shown in the status line, accessible via `/model`, and embedded in the runtime context as the model name (e.g. `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-*`).
60
- 2. If the active model is **Opus-class** (`claude-opus-*`): proceed to Step 1.
59
+ 1. Inspect the active session model. The model is shown in the status line, accessible via `/model`, and embedded in the runtime context as the model name (e.g. `claude-fable-5`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-*`).
60
+ 2. If the active model is **Opus-class or above** (`claude-opus-*`, `claude-fable-*`): proceed to Step 1.
61
61
  3. If the active model is **Sonnet or Haiku-class**: STOP and output the following message verbatim, then wait for user response:
62
62
  ```
63
63
  okstra-schedule는 Opus-class 모델에서 실행하는 것을 권장합니다 (현재: <active-model>).
64
64
  /model opus 로 전환 후 다시 호출하시거나, 'sonnet으로 진행' 이라고 명시하시면 그대로 실행합니다.
65
65
  ```
66
66
  4. If the user explicitly insists on the lower model ("sonnet으로 진행", "그대로 진행", "force", or similar): proceed to Step 1, but prepend a single-line warning at the top of the generated schedule file: `> ⚠️ Generated with <model> (not Opus). Cross-task synthesis quality may be reduced.`
67
- 5. Skip this gate ONLY when the harness has clearly enforced `model: opus` from the frontmatter — verifiable by the active model already being Opus-class without manual switching.
67
+ 5. Skip this gate ONLY when the harness has clearly enforced `model: opus` from the frontmatter — verifiable by the active model already being Opus-class or above without manual switching.
68
68
 
69
69
  ### Step 1: Resolve task-group and collect tasks
70
70
 
@@ -38,7 +38,7 @@ okstra tasks are always operated using the `Claude lead` + required worker team
38
38
  1. `resultContract.requiredWorkerRoles` in `task-manifest.json` (and the lead model metadata) is the canonical source. There is no role-level fallback — a missing assignment is a manifest defect, not a license to invent one.
39
39
  2. If `modelExecutionValue` differs from `model`, use `modelExecutionValue` during execution.
40
40
  3. **Spawn-time enforcement for in-process Claude subagents (BLOCKING).** `Claude worker` and `Report writer worker` are in-process Claude subagents whose agent definitions declare `model: inherit` (`agents/workers/claude-worker.md`, `agents/workers/report-writer-worker.md`). `inherit` follows the **lead's** runtime model, NOT the role's assignment — so an opus assignment silently runs on a sonnet lead. To make the assignment binding (not merely declared), lead MUST pass an explicit `model:` parameter on every `Agent(...)` dispatch for these two roles, derived from that role's `modelExecutionValue`. The dispatch `model:` parameter overrides the `inherit` frontmatter; the frontmatter remains only as the fallback when no parameter is supplied. Omitting `model:` on a Claude-side dispatch is a contract violation that reproduces the assigned-vs-actual model deviation.
41
- 4. **`modelExecutionValue` → Agent `model:` family token.** The Agent tool's `model` parameter accepts family tokens only — `opus` / `sonnet` / `haiku` (an exact version such as `claude-opus-4-7` is NOT a valid value). Map by prefix: a `modelExecutionValue` of `opus*` / `claude-opus*` → `"opus"`, `sonnet*` / `claude-sonnet*` → `"sonnet"`, `haiku*` / `claude-haiku*` → `"haiku"`. This enforces the assignment at **family granularity** (opus vs sonnet vs haiku); the exact version within a family is still inherited from the lead session and cannot be pinned via this parameter.
41
+ 4. **`modelExecutionValue` → Agent `model:` family token.** The Agent tool's `model` parameter accepts family tokens only — `fable` / `opus` / `sonnet` / `haiku` (an exact version such as `claude-opus-4-7` is NOT a valid value). Map by prefix: a `modelExecutionValue` of `fable*` / `claude-fable*` → `"fable"`, `opus*` / `claude-opus*` → `"opus"`, `sonnet*` / `claude-sonnet*` → `"sonnet"`, `haiku*` / `claude-haiku*` → `"haiku"`. This enforces the assignment at **family granularity** (fable vs opus vs sonnet vs haiku); the exact version within a family is still inherited from the lead session and cannot be pinned via this parameter.
42
42
  5. **Codex / Gemini wrappers are out of scope for the Agent `model:` rule.** `Codex worker` / `Gemini worker` subagents are Claude wrappers that shell out to an external CLI; the role's `modelExecutionValue` is already applied via the CLI's own `--model <modelExecutionValue>` argument (see `agents/workers/_cli-wrapper-template.md`). The Agent `model:` parameter for these wrappers would only set the wrapper's own orchestration model, not the external CLI's model — leave it at `inherit` and do NOT map it from `modelExecutionValue`.
43
43
 
44
44
  ### Dynamic Worker Role Determination