okstra 0.66.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.66.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.66.0",
3
- "builtAt": "2026-06-10T09:21:55.603Z",
2
+ "package": "0.67.0",
3
+ "builtAt": "2026-06-10T10:33:26.768Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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}",
@@ -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,
@@ -97,6 +98,11 @@ GEMINI_MODEL_OPTIONS = ["default", "gemini-3-pro-preview", "gemini-3-flash-previ
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,
@@ -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,