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
package/runtime/BUILD.json
CHANGED
|
@@ -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__": "
|
|
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
|
|
800
|
+
for entry in remaining[:_RECOMMENDATION_CAP]:
|
|
793
801
|
key = entry.get("taskKey") or ""
|
|
794
802
|
ttype = entry.get("taskType") or ""
|
|
795
|
-
|
|
796
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1062
|
-
"""
|
|
1063
|
-
그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
|
|
1075
|
+
def _existing_task_workflow(state: WizardState) -> dict:
|
|
1076
|
+
"""현재 task-key 가 이미 존재하면 그 manifest 의 workflow dict 를 반환한다.
|
|
1064
1077
|
|
|
1065
|
-
|
|
1066
|
-
task-group/task-id 를 다시 입력한
|
|
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
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
options
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
|
1151
|
+
def _apply_task_type(state: WizardState, value: str) -> str:
|
|
1101
1152
|
if value not in TASK_TYPE_VALUES:
|
|
1102
|
-
raise WizardError(
|
|
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 (
|
|
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,
|