okstra 0.70.0 → 0.71.1
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/architecture.md +5 -1
- package/docs/kr/cli.md +8 -2
- package/docs/superpowers/specs/2026-06-11-brief-entry-only-handoff-stage-entry-design.md +158 -0
- package/docs/task-process/release-handoff.md +6 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +8 -1
- package/runtime/bin/lib/okstra/interactive.sh +8 -5
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +5 -5
- package/runtime/prompts/wizard/prompts.ko.json +48 -5
- package/runtime/python/okstra_ctl/consumers.py +46 -16
- package/runtime/python/okstra_ctl/handoff.py +29 -0
- package/runtime/python/okstra_ctl/run.py +171 -8
- package/runtime/python/okstra_ctl/wizard.py +287 -30
- package/runtime/python/okstra_ctl/worktree.py +35 -0
- package/runtime/skills/okstra-run/SKILL.md +4 -2
- package/runtime/templates/reports/release-handoff-input.template.md +10 -6
- package/src/render-bundle.mjs +9 -1
|
@@ -52,8 +52,10 @@ from okstra_ctl.workers import (
|
|
|
52
52
|
from okstra_ctl.workflow import PHASE_SEQUENCE
|
|
53
53
|
from okstra_ctl import worktree_registry
|
|
54
54
|
from okstra_ctl.worktree import (
|
|
55
|
+
compute_worktree_path,
|
|
55
56
|
is_git_work_tree,
|
|
56
57
|
main_worktree_path,
|
|
58
|
+
preview_stage_worktree_decision,
|
|
57
59
|
preview_worktree_decision,
|
|
58
60
|
)
|
|
59
61
|
from okstra_ctl.paths import task_runs_dir
|
|
@@ -88,6 +90,13 @@ EXECUTORS = ["claude", "codex", "gemini"]
|
|
|
88
90
|
# (or the whole task via `auto`). Both gate the approved-plan + stage steps.
|
|
89
91
|
_STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
|
|
90
92
|
|
|
93
|
+
# brief 는 entry phase 의 입력물(비개발자/타 팀의 아이디어·에러 초안)이다.
|
|
94
|
+
# downstream phase 는 task manifest 의 taskBriefPath 를 자동 carry-in 하며
|
|
95
|
+
# 위저드에서 brief 를 묻지 않는다 (release-handoff 는 prepare 가 검증 보고서
|
|
96
|
+
# 인용 input 문서를 자동 생성하므로 brief 자체가 없다).
|
|
97
|
+
_BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "improvement-discovery",
|
|
98
|
+
"error-analysis")
|
|
99
|
+
|
|
91
100
|
CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
|
|
92
101
|
BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
|
|
93
102
|
|
|
@@ -230,12 +239,14 @@ S_TASK_TYPE_TEXT = "task_type_text"
|
|
|
230
239
|
S_BRIEF_KEEP = "brief_keep"
|
|
231
240
|
S_BRIEF_PATH_PICK = "brief_path_pick"
|
|
232
241
|
S_BRIEF_PATH = "brief_path"
|
|
242
|
+
S_BRIEF_CARRY = "brief_carry"
|
|
233
243
|
S_BASE_REF_PICK = "base_ref_pick"
|
|
234
244
|
S_BASE_REF_TEXT = "base_ref_text"
|
|
235
245
|
S_APPROVED_PLAN_PICK = "approved_plan_pick"
|
|
236
246
|
S_APPROVED_PLAN = "approved_plan"
|
|
237
247
|
S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
|
|
238
248
|
S_STAGE_PICK = "stage_pick"
|
|
249
|
+
S_HANDOFF_STAGE_PICK = "handoff_stage_pick"
|
|
239
250
|
S_EXECUTOR = "executor"
|
|
240
251
|
S_CRITIC_PICK = "critic_pick"
|
|
241
252
|
S_CRITIC_TEXT = "critic_text"
|
|
@@ -334,6 +345,11 @@ class WizardState:
|
|
|
334
345
|
critic: str = ""
|
|
335
346
|
critic_pending_text: bool = False
|
|
336
347
|
|
|
348
|
+
# release-handoff: PR 로 내보낼 범위. mode 는 stages 선택의 파생값이다 —
|
|
349
|
+
# whole-task = 빈 stages(whole-task 검증 기반 단일 PR), stage-group = csv.
|
|
350
|
+
handoff_mode: str = "" # "" | "whole-task" | "stage-group"
|
|
351
|
+
handoff_stages: str = "" # csv ("2,3"), whole-task 면 ""
|
|
352
|
+
|
|
337
353
|
# resume: 직전 run-inputs 재사용 여부 (None=미응답, True=재사용, False=재입력)
|
|
338
354
|
reuse_previous: Optional[bool] = None
|
|
339
355
|
|
|
@@ -767,6 +783,11 @@ def _base_ref_required(state: WizardState) -> bool:
|
|
|
767
783
|
return state.task_type != "final-verification" and state.reuse_worktree is False
|
|
768
784
|
|
|
769
785
|
|
|
786
|
+
def _brief_resolved(state: WizardState) -> bool:
|
|
787
|
+
"""brief 입력이 끝났는가. release-handoff 는 brief 가 없는 phase 라 항상 True."""
|
|
788
|
+
return bool(state.brief_path) or state.task_type == "release-handoff"
|
|
789
|
+
|
|
790
|
+
|
|
770
791
|
def _base_ref_ready(state: WizardState) -> bool:
|
|
771
792
|
return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
|
|
772
793
|
|
|
@@ -1215,6 +1236,23 @@ def _build_task_type(state: WizardState) -> Prompt:
|
|
|
1215
1236
|
echo_template=t["echo_template"])
|
|
1216
1237
|
|
|
1217
1238
|
|
|
1239
|
+
def _carry_in_existing_brief(state: WizardState) -> str:
|
|
1240
|
+
"""downstream task-type 이 등록된 brief 를 자동 carry-in 한다. 주입한 경로(또는 '')."""
|
|
1241
|
+
if state.task_type in _BRIEF_ENTRY_TASK_TYPES:
|
|
1242
|
+
return ""
|
|
1243
|
+
if state.task_type == "release-handoff":
|
|
1244
|
+
return "" # prepare 가 검증 보고서 인용 input 문서를 자동 생성한다
|
|
1245
|
+
if state.brief_path or not state.existing_brief_path:
|
|
1246
|
+
return ""
|
|
1247
|
+
p = Path(state.existing_brief_path)
|
|
1248
|
+
if not p.is_absolute():
|
|
1249
|
+
p = Path(state.project_root) / p
|
|
1250
|
+
if not p.is_file():
|
|
1251
|
+
return "" # manifest 경로가 깨졌으면 brief_carry 단계가 처리한다
|
|
1252
|
+
state.brief_path = state.existing_brief_path
|
|
1253
|
+
return state.brief_path
|
|
1254
|
+
|
|
1255
|
+
|
|
1218
1256
|
def _apply_task_type(state: WizardState, value: str) -> str:
|
|
1219
1257
|
if value not in TASK_TYPE_VALUES:
|
|
1220
1258
|
raise WizardError(
|
|
@@ -1222,6 +1260,11 @@ def _apply_task_type(state: WizardState, value: str) -> str:
|
|
|
1222
1260
|
f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
|
|
1223
1261
|
)
|
|
1224
1262
|
state.task_type = value
|
|
1263
|
+
# brief_carry 의 "entry phase 로 전환" 이 task-type 을 리셋한 뒤 submit() 이
|
|
1264
|
+
# brief_carry 를 answered 로 되돌려 놓는다 — task-type 을 다시 고르는 시점에
|
|
1265
|
+
# 퍼지해야 downstream 재선택 시 carry 단계가 다시 나온다.
|
|
1266
|
+
state.answered = [a for a in state.answered if a != S_BRIEF_CARRY]
|
|
1267
|
+
carried = _carry_in_existing_brief(state)
|
|
1225
1268
|
state.profile_workers = _load_profile_workers(
|
|
1226
1269
|
Path(state.workspace_root), value
|
|
1227
1270
|
)
|
|
@@ -1231,6 +1274,8 @@ def _apply_task_type(state: WizardState, value: str) -> str:
|
|
|
1231
1274
|
# Reuse-worktree is decided once identity is final. Recompute here so
|
|
1232
1275
|
# subsequent base-ref step knows whether to apply.
|
|
1233
1276
|
state.reuse_worktree = _resolve_reuse_worktree(state)
|
|
1277
|
+
if carried:
|
|
1278
|
+
return f"task-type: {value}\nbrief (carry-in): {carried}"
|
|
1234
1279
|
return f"task-type: {value}"
|
|
1235
1280
|
|
|
1236
1281
|
|
|
@@ -1357,6 +1402,37 @@ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
|
|
|
1357
1402
|
return f"brief: {p}"
|
|
1358
1403
|
|
|
1359
1404
|
|
|
1405
|
+
BRIEF_CARRY_SWITCH_ENTRY = "__switch_entry__"
|
|
1406
|
+
BRIEF_CARRY_ABORT = "__abort__"
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _build_brief_carry(state: WizardState) -> Prompt:
|
|
1410
|
+
t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
|
|
1411
|
+
return Prompt(
|
|
1412
|
+
step=S_BRIEF_CARRY, kind="pick",
|
|
1413
|
+
label=t["label"],
|
|
1414
|
+
options=[_opt(k, v) for k, v in t["options"].items()],
|
|
1415
|
+
echo_template=t["echo_template"],
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _submit_brief_carry(state: WizardState, value: str) -> Optional[str]:
|
|
1420
|
+
t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
|
|
1421
|
+
if value == BRIEF_CARRY_SWITCH_ENTRY:
|
|
1422
|
+
_reset_from(state, S_TASK_TYPE)
|
|
1423
|
+
return t["echo_variants"]["switch_entry"]
|
|
1424
|
+
if value == PICK_TYPE_CUSTOM:
|
|
1425
|
+
state.brief_path_pending_text = True
|
|
1426
|
+
return None
|
|
1427
|
+
if value == BRIEF_CARRY_ABORT:
|
|
1428
|
+
state.aborted = True
|
|
1429
|
+
return t["echo_variants"]["abort"]
|
|
1430
|
+
raise WizardError(
|
|
1431
|
+
f"expected '{BRIEF_CARRY_SWITCH_ENTRY}', {PICK_TYPE_CUSTOM!r}, "
|
|
1432
|
+
f"or '{BRIEF_CARRY_ABORT}', got: {value!r}"
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
|
|
1360
1436
|
def _build_base_ref_pick(state: WizardState) -> Prompt:
|
|
1361
1437
|
t = _p(state.workspace_root, "base_ref_pick")
|
|
1362
1438
|
recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
|
|
@@ -1602,6 +1678,105 @@ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
|
|
|
1602
1678
|
return f"stage: {answer}"
|
|
1603
1679
|
|
|
1604
1680
|
|
|
1681
|
+
def _handoff_msgs(state: WizardState) -> dict:
|
|
1682
|
+
"""handoff_stage_pick 의 JSON 텍스트 묶음 (label 미사용 조회용)."""
|
|
1683
|
+
return _p(state.workspace_root, "handoff_stage_pick", blocked="")
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def _resolve_handoff_plan(state: WizardState) -> Path:
|
|
1687
|
+
"""release-handoff 의 approved plan 을 질문 없이 자동 해소한다.
|
|
1688
|
+
|
|
1689
|
+
plan 미존재/미승인은 사용자가 고칠 대상이 아니라 라이프사이클 선행 단계
|
|
1690
|
+
누락이므로 picker 대신 안내 메시지로 즉시 실패한다."""
|
|
1691
|
+
if state.approved_plan_path:
|
|
1692
|
+
return Path(state.approved_plan_path)
|
|
1693
|
+
t = _handoff_msgs(state)
|
|
1694
|
+
latest = _latest_implementation_planning_report(state)
|
|
1695
|
+
if latest is None:
|
|
1696
|
+
raise WizardError(t["errors"]["no_plan"])
|
|
1697
|
+
p, fully_approved = _classify_approved_plan(
|
|
1698
|
+
str(latest), Path(state.project_root))
|
|
1699
|
+
if not fully_approved:
|
|
1700
|
+
raise WizardError(t["errors"]["plan_not_approved"].format(plan=p))
|
|
1701
|
+
state.approved_plan_path = str(p)
|
|
1702
|
+
return p
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _handoff_eligibility(state: WizardState) -> list:
|
|
1706
|
+
"""stage 별 PR 자격 — okstra_ctl.handoff 의 SSOT 판정을 그대로 재사용한다."""
|
|
1707
|
+
from okstra_ctl.handoff import compute_eligibility
|
|
1708
|
+
from okstra_ctl.run import _parse_stage_map_into_ctx
|
|
1709
|
+
from okstra_ctl.consumers import read_consumers
|
|
1710
|
+
plan = _resolve_handoff_plan(state)
|
|
1711
|
+
stage_map = _parse_stage_map_into_ctx(str(plan))
|
|
1712
|
+
if not stage_map:
|
|
1713
|
+
raise WizardError(
|
|
1714
|
+
_handoff_msgs(state)["errors"]["no_stage_map"].format(plan=plan))
|
|
1715
|
+
rows = read_consumers(plan.resolve().parents[1])
|
|
1716
|
+
return compute_eligibility(stage_map, rows)
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _latest_whole_task_fv_accepted(state: WizardState) -> str:
|
|
1720
|
+
"""accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
|
|
1721
|
+
from okstra_ctl.handoff import latest_whole_task_fv_accepted
|
|
1722
|
+
return latest_whole_task_fv_accepted(
|
|
1723
|
+
state.project_root, state.project_id, state.task_group, state.task_id)
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _build_handoff_stage_pick(state: WizardState) -> Prompt:
|
|
1727
|
+
elig = _handoff_eligibility(state)
|
|
1728
|
+
eligible = [e for e in elig if e["eligible"]]
|
|
1729
|
+
blocked = [e for e in elig if not e["eligible"]]
|
|
1730
|
+
whole_task_report = _latest_whole_task_fv_accepted(state)
|
|
1731
|
+
msgs = _handoff_msgs(state)
|
|
1732
|
+
blocked_summary = ("; ".join(
|
|
1733
|
+
f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
|
|
1734
|
+
or msgs["labels"]["blocked_none"])
|
|
1735
|
+
if not eligible and not whole_task_report:
|
|
1736
|
+
raise WizardError(
|
|
1737
|
+
msgs["errors"]["nothing_eligible"].format(blocked=blocked_summary))
|
|
1738
|
+
t = _p(state.workspace_root, "handoff_stage_pick", blocked=blocked_summary)
|
|
1739
|
+
options: list[Option] = []
|
|
1740
|
+
if whole_task_report:
|
|
1741
|
+
options.append(_opt(WHOLE_TASK_STAGE, t["labels"]["whole_task"]))
|
|
1742
|
+
stage_label = t["labels"]["stage"]
|
|
1743
|
+
for e in eligible:
|
|
1744
|
+
deps = ", ".join(str(d) for d in e["depends_on"]) or "-"
|
|
1745
|
+
options.append(_opt(str(e["stage"]),
|
|
1746
|
+
stage_label.format(stage=e["stage"], deps=deps)))
|
|
1747
|
+
return Prompt(
|
|
1748
|
+
step=S_HANDOFF_STAGE_PICK, kind="pick", multi=True,
|
|
1749
|
+
label=t["label"], options=options,
|
|
1750
|
+
echo_template=t["echo_template"],
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1755
|
+
t = _handoff_msgs(state)
|
|
1756
|
+
picks = [v.strip() for v in (value or "").split(",") if v.strip()]
|
|
1757
|
+
if not picks:
|
|
1758
|
+
raise WizardError(t["errors"]["none_selected"])
|
|
1759
|
+
if WHOLE_TASK_STAGE in picks:
|
|
1760
|
+
if len(picks) > 1:
|
|
1761
|
+
raise WizardError(t["errors"]["whole_task_exclusive"])
|
|
1762
|
+
if not _latest_whole_task_fv_accepted(state):
|
|
1763
|
+
raise WizardError(t["errors"]["whole_task_missing"])
|
|
1764
|
+
state.handoff_mode = "whole-task"
|
|
1765
|
+
state.handoff_stages = ""
|
|
1766
|
+
return t["echo_variants"]["whole_task"]
|
|
1767
|
+
eligible = {str(e["stage"]) for e in _handoff_eligibility(state)
|
|
1768
|
+
if e["eligible"]}
|
|
1769
|
+
bad = [p for p in picks if p not in eligible]
|
|
1770
|
+
if bad:
|
|
1771
|
+
raise WizardError(t["errors"]["not_eligible"].format(
|
|
1772
|
+
bad=", ".join(bad), eligible=", ".join(sorted(eligible))))
|
|
1773
|
+
nums = sorted({int(p) for p in picks})
|
|
1774
|
+
state.handoff_mode = "stage-group"
|
|
1775
|
+
state.handoff_stages = ",".join(str(n) for n in nums)
|
|
1776
|
+
return t["echo_variants"]["stage_group"].format(
|
|
1777
|
+
stages=state.handoff_stages)
|
|
1778
|
+
|
|
1779
|
+
|
|
1605
1780
|
def _suggest_latest_final_report(state: WizardState) -> str:
|
|
1606
1781
|
"""task 의 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
|
|
1607
1782
|
|
|
@@ -2250,6 +2425,8 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
|
|
|
2250
2425
|
|
|
2251
2426
|
|
|
2252
2427
|
def _build_branch_confirm(state: WizardState) -> Prompt:
|
|
2428
|
+
if state.task_type == "implementation":
|
|
2429
|
+
return _build_branch_confirm_impl_stage(state)
|
|
2253
2430
|
decision = preview_worktree_decision(
|
|
2254
2431
|
project_root=Path(state.project_root),
|
|
2255
2432
|
project_id=state.project_id,
|
|
@@ -2270,6 +2447,7 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
|
|
|
2270
2447
|
branch=decision.branch or "(none)",
|
|
2271
2448
|
base_ref=decision.base_ref or "(HEAD)",
|
|
2272
2449
|
path=decision.path,
|
|
2450
|
+
task_key=f"{state.task_group}/{state.task_id}",
|
|
2273
2451
|
)
|
|
2274
2452
|
# Pass the computed label as `summary` so _p's placeholder interpolation works.
|
|
2275
2453
|
t = _p(state.workspace_root, "branch_confirm", summary=label)
|
|
@@ -2282,6 +2460,43 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
|
|
|
2282
2460
|
options=options, echo_template=t["echo_template"])
|
|
2283
2461
|
|
|
2284
2462
|
|
|
2463
|
+
def _build_branch_confirm_impl_stage(state: WizardState) -> Prompt:
|
|
2464
|
+
"""implementation 은 stage 격리로 동작하므로 task-key 디렉터리가 아니라
|
|
2465
|
+
이번 run 이 실제로 사용할 stage worktree 관점으로 미리보기를 보여준다."""
|
|
2466
|
+
raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
|
|
2467
|
+
task_key = f"{state.task_group}/{state.task_id}"
|
|
2468
|
+
stage = (state.selected_stage or "auto").strip()
|
|
2469
|
+
if stage == "auto":
|
|
2470
|
+
parent_dir = compute_worktree_path(
|
|
2471
|
+
project_id=state.project_id, task_group_segment=state.task_group,
|
|
2472
|
+
task_id_segment=state.task_id,
|
|
2473
|
+
)
|
|
2474
|
+
label = raw["labels"]["impl_stage_auto"].format(
|
|
2475
|
+
task_key=task_key, path=str(parent_dir),
|
|
2476
|
+
)
|
|
2477
|
+
decision_status = "new"
|
|
2478
|
+
else:
|
|
2479
|
+
decision = preview_stage_worktree_decision(
|
|
2480
|
+
project_id=state.project_id, task_group_segment=state.task_group,
|
|
2481
|
+
task_id_segment=state.task_id, work_category="",
|
|
2482
|
+
stage_number=int(stage),
|
|
2483
|
+
)
|
|
2484
|
+
key = "impl_stage_reuse" if decision.status == "reused" else "impl_stage_new"
|
|
2485
|
+
label = raw["labels"][key].format(
|
|
2486
|
+
stage=stage, path=decision.path, branch=decision.branch,
|
|
2487
|
+
task_key=task_key,
|
|
2488
|
+
)
|
|
2489
|
+
decision_status = decision.status
|
|
2490
|
+
t = _p(state.workspace_root, "branch_confirm", summary=label)
|
|
2491
|
+
opts = t["options"]
|
|
2492
|
+
options = [_opt("proceed", opts["proceed"])]
|
|
2493
|
+
if decision_status == "new" and _base_ref_required(state):
|
|
2494
|
+
options.append(_opt("edit", opts["edit"]))
|
|
2495
|
+
options.append(_opt("abort", opts["abort"]))
|
|
2496
|
+
return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
|
|
2497
|
+
options=options, echo_template=t["echo_template"])
|
|
2498
|
+
|
|
2499
|
+
|
|
2285
2500
|
def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
2286
2501
|
if value == "edit":
|
|
2287
2502
|
_reset_from(state, S_BASE_REF_PICK)
|
|
@@ -2364,16 +2579,40 @@ STEPS: list[Step] = [
|
|
|
2364
2579
|
and s.task_group_pending_text),
|
|
2365
2580
|
build=_build_task_group_text, submit=_submit_task_group_text,
|
|
2366
2581
|
owns=("task_group", "task_group_pending_text")),
|
|
2582
|
+
# 신규 task 흐름 순서: task-group → task-type → (entry 면 brief) → task-id.
|
|
2583
|
+
# brief 는 entry phase 전용 입력이므로 task-type 이 정해진 뒤에만 물을 수 있다.
|
|
2584
|
+
Step(S_TASK_TYPE,
|
|
2585
|
+
applies=lambda s: (s.is_new_task is not None
|
|
2586
|
+
and (s.is_new_task is False or bool(s.task_group))
|
|
2587
|
+
and S_TASK_TYPE not in s.answered),
|
|
2588
|
+
build=_build_task_type, submit=_submit_task_type,
|
|
2589
|
+
owns=("task_type", "profile_workers", "profile_optional_workers",
|
|
2590
|
+
"reuse_worktree")),
|
|
2591
|
+
Step(S_TASK_TYPE_TEXT,
|
|
2592
|
+
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
2593
|
+
and not s.task_type
|
|
2594
|
+
and S_TASK_TYPE_TEXT not in s.answered),
|
|
2595
|
+
build=_build_task_type_text, submit=_submit_task_type_text,
|
|
2596
|
+
owns=("task_type", "profile_workers", "profile_optional_workers",
|
|
2597
|
+
"reuse_worktree")),
|
|
2598
|
+
Step(S_BRIEF_KEEP,
|
|
2599
|
+
applies=lambda s: (not s.is_new_task
|
|
2600
|
+
and s.task_type in _BRIEF_ENTRY_TASK_TYPES
|
|
2601
|
+
and bool(s.existing_brief_path)
|
|
2602
|
+
and s.keep_existing_brief is None
|
|
2603
|
+
and S_TASK_TYPE in s.answered),
|
|
2604
|
+
build=_build_brief_keep, submit=_submit_brief_keep,
|
|
2605
|
+
owns=("keep_existing_brief",)),
|
|
2367
2606
|
Step(S_BRIEF_PATH_PICK,
|
|
2368
2607
|
applies=lambda s: (
|
|
2369
2608
|
not s.brief_path
|
|
2370
2609
|
and not s.brief_path_pending_text
|
|
2371
2610
|
and S_BRIEF_PATH_PICK not in s.answered
|
|
2611
|
+
and S_TASK_TYPE in s.answered
|
|
2612
|
+
and s.task_type in _BRIEF_ENTRY_TASK_TYPES
|
|
2372
2613
|
and (
|
|
2373
|
-
|
|
2614
|
+
s.is_new_task is True
|
|
2374
2615
|
or (s.is_new_task is False
|
|
2375
|
-
and S_TASK_TYPE in s.answered
|
|
2376
|
-
and bool(s.task_type)
|
|
2377
2616
|
and (s.keep_existing_brief is False
|
|
2378
2617
|
or not s.existing_brief_path))
|
|
2379
2618
|
)
|
|
@@ -2385,49 +2624,39 @@ STEPS: list[Step] = [
|
|
|
2385
2624
|
and S_BRIEF_PATH not in s.answered,
|
|
2386
2625
|
build=_build_brief_path, submit=_submit_brief_path,
|
|
2387
2626
|
owns=("brief_path", "task_group_suggestion", "task_id_suggestion")),
|
|
2627
|
+
# downstream task-type 인데 carry-in 할 brief 가 없을 때의 fallback.
|
|
2628
|
+
# release-handoff 는 brief 자체가 없는 phase 라 대상에서 빠진다.
|
|
2629
|
+
Step(S_BRIEF_CARRY,
|
|
2630
|
+
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
2631
|
+
and bool(s.task_type)
|
|
2632
|
+
and s.task_type not in _BRIEF_ENTRY_TASK_TYPES
|
|
2633
|
+
and s.task_type != "release-handoff"
|
|
2634
|
+
and not s.brief_path
|
|
2635
|
+
and not s.brief_path_pending_text),
|
|
2636
|
+
build=_build_brief_carry, submit=_submit_brief_carry,
|
|
2637
|
+
owns=("brief_path_pending_text",)),
|
|
2388
2638
|
Step(S_TASK_ID,
|
|
2389
2639
|
applies=lambda s: (bool(s.is_new_task)
|
|
2390
|
-
and bool(s.brief_path)
|
|
2391
2640
|
and bool(s.task_group)
|
|
2641
|
+
and _brief_resolved(s)
|
|
2392
2642
|
and not s.task_id
|
|
2393
2643
|
and not s.task_id_pending_text),
|
|
2394
2644
|
build=_build_task_id, submit=_submit_task_id,
|
|
2395
2645
|
owns=("task_id", "task_id_pending_text")),
|
|
2396
2646
|
Step(S_TASK_ID_TEXT,
|
|
2397
2647
|
applies=lambda s: (bool(s.is_new_task)
|
|
2398
|
-
and bool(s.brief_path)
|
|
2399
2648
|
and bool(s.task_group)
|
|
2649
|
+
and _brief_resolved(s)
|
|
2400
2650
|
and not s.task_id
|
|
2401
2651
|
and s.task_id_pending_text),
|
|
2402
2652
|
build=_build_task_id_text, submit=_submit_task_id_text,
|
|
2403
2653
|
owns=("task_id", "task_id_pending_text")),
|
|
2404
|
-
Step(S_TASK_TYPE,
|
|
2405
|
-
applies=lambda s: (s.is_new_task is not None
|
|
2406
|
-
and (s.is_new_task is False or bool(s.task_id))
|
|
2407
|
-
and S_TASK_TYPE not in s.answered),
|
|
2408
|
-
build=_build_task_type, submit=_submit_task_type,
|
|
2409
|
-
owns=("task_type", "profile_workers", "profile_optional_workers",
|
|
2410
|
-
"reuse_worktree")),
|
|
2411
|
-
Step(S_TASK_TYPE_TEXT,
|
|
2412
|
-
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
2413
|
-
and not s.task_type
|
|
2414
|
-
and S_TASK_TYPE_TEXT not in s.answered),
|
|
2415
|
-
build=_build_task_type_text, submit=_submit_task_type_text,
|
|
2416
|
-
owns=("task_type", "profile_workers", "profile_optional_workers",
|
|
2417
|
-
"reuse_worktree")),
|
|
2418
|
-
Step(S_BRIEF_KEEP,
|
|
2419
|
-
applies=lambda s: (not s.is_new_task
|
|
2420
|
-
and bool(s.existing_brief_path)
|
|
2421
|
-
and s.keep_existing_brief is None
|
|
2422
|
-
and S_TASK_TYPE in s.answered
|
|
2423
|
-
and bool(s.task_type)),
|
|
2424
|
-
build=_build_brief_keep, submit=_submit_brief_keep,
|
|
2425
|
-
owns=("keep_existing_brief",)),
|
|
2426
2654
|
Step(S_BASE_REF_PICK,
|
|
2427
2655
|
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
2428
2656
|
and _base_ref_required(s)
|
|
2429
2657
|
and S_BASE_REF_PICK not in s.answered
|
|
2430
|
-
and
|
|
2658
|
+
and _brief_resolved(s)
|
|
2659
|
+
and (not s.is_new_task or bool(s.task_id))),
|
|
2431
2660
|
build=_build_base_ref_pick, submit=_submit_base_ref_pick,
|
|
2432
2661
|
owns=("base_ref", "base_ref_pending_text")),
|
|
2433
2662
|
Step(S_BASE_REF_TEXT,
|
|
@@ -2468,6 +2697,13 @@ STEPS: list[Step] = [
|
|
|
2468
2697
|
and S_STAGE_PICK not in s.answered),
|
|
2469
2698
|
build=_build_stage_pick, submit=_submit_stage_pick,
|
|
2470
2699
|
owns=("selected_stage",)),
|
|
2700
|
+
Step(S_HANDOFF_STAGE_PICK,
|
|
2701
|
+
applies=lambda s: (s.task_type == "release-handoff"
|
|
2702
|
+
and bool(s.task_group)
|
|
2703
|
+
and bool(s.task_id)
|
|
2704
|
+
and S_HANDOFF_STAGE_PICK not in s.answered),
|
|
2705
|
+
build=_build_handoff_stage_pick, submit=_submit_handoff_stage_pick,
|
|
2706
|
+
owns=("handoff_mode", "handoff_stages", "approved_plan_path")),
|
|
2471
2707
|
Step(S_EXECUTOR,
|
|
2472
2708
|
applies=lambda s: (s.task_type == "implementation"
|
|
2473
2709
|
and bool(s.approved_plan_path)
|
|
@@ -2644,7 +2880,8 @@ def _identity_ready(s: WizardState) -> bool:
|
|
|
2644
2880
|
"""All identity questions (task pick → executor for impl) answered."""
|
|
2645
2881
|
if not s.task_type:
|
|
2646
2882
|
return False
|
|
2647
|
-
|
|
2883
|
+
# release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
|
|
2884
|
+
if not s.brief_path and s.task_type != "release-handoff":
|
|
2648
2885
|
return False
|
|
2649
2886
|
if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
|
|
2650
2887
|
return False
|
|
@@ -2656,6 +2893,9 @@ def _identity_ready(s: WizardState) -> bool:
|
|
|
2656
2893
|
if s.task_type == "implementation":
|
|
2657
2894
|
if not s.executor:
|
|
2658
2895
|
return False
|
|
2896
|
+
if (s.task_type == "release-handoff"
|
|
2897
|
+
and S_HANDOFF_STAGE_PICK not in s.answered):
|
|
2898
|
+
return False
|
|
2659
2899
|
return True
|
|
2660
2900
|
|
|
2661
2901
|
|
|
@@ -2708,6 +2948,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
2708
2948
|
"base_ref_pending_text": False, "approved_plan_path": "",
|
|
2709
2949
|
"approved_plan_pending_text": False,
|
|
2710
2950
|
"selected_stage": "auto",
|
|
2951
|
+
"handoff_mode": "", "handoff_stages": "",
|
|
2711
2952
|
"executor": "", "critic": "", "critic_pending_text": False,
|
|
2712
2953
|
"reuse_previous": None,
|
|
2713
2954
|
"use_defaults": None, "workers_override": "",
|
|
@@ -2873,6 +3114,7 @@ def render_args(state: WizardState) -> dict[str, str]:
|
|
|
2873
3114
|
"critic": state.critic,
|
|
2874
3115
|
"approved-plan": state.approved_plan_path,
|
|
2875
3116
|
"stage": stage,
|
|
3117
|
+
"stages": state.handoff_stages,
|
|
2876
3118
|
"base-ref": base_ref,
|
|
2877
3119
|
"workers": workers,
|
|
2878
3120
|
"directive": state.directive,
|
|
@@ -2896,8 +3138,13 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
2896
3138
|
lines.append(f" brief : {state.brief_path or '(none)'}")
|
|
2897
3139
|
if state.task_type == "final-verification":
|
|
2898
3140
|
lines.append(" base-ref : (selected stage worktree)")
|
|
3141
|
+
elif state.task_type == "implementation" and state.reuse_worktree:
|
|
3142
|
+
lines.append(_msg(state.workspace_root, "confirmation",
|
|
3143
|
+
"base_ref_stage_isolated"))
|
|
2899
3144
|
elif state.reuse_worktree:
|
|
2900
|
-
lines.append(
|
|
3145
|
+
lines.append(_msg(state.workspace_root, "confirmation",
|
|
3146
|
+
"base_ref_reuse_task_dir",
|
|
3147
|
+
task_key=f"{state.task_group}/{state.task_id}"))
|
|
2901
3148
|
else:
|
|
2902
3149
|
lines.append(f" base-ref : {state.base_ref}")
|
|
2903
3150
|
if state.task_type == "implementation":
|
|
@@ -2934,6 +3181,16 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
2934
3181
|
lines.append(f" stage : {stage}")
|
|
2935
3182
|
if state.clarification_response_path:
|
|
2936
3183
|
lines.append(f" clarification : {state.clarification_response_path}")
|
|
3184
|
+
if state.task_type == "release-handoff" and state.handoff_mode:
|
|
3185
|
+
scope = (
|
|
3186
|
+
_msg(state.workspace_root, "confirmation",
|
|
3187
|
+
"handoff_scope_whole_task")
|
|
3188
|
+
if state.handoff_mode == "whole-task"
|
|
3189
|
+
else _msg(state.workspace_root, "confirmation",
|
|
3190
|
+
"handoff_scope_stage_group",
|
|
3191
|
+
stages=state.handoff_stages)
|
|
3192
|
+
)
|
|
3193
|
+
lines.append(f" handoff scope : {scope}")
|
|
2937
3194
|
if state.task_type == "release-handoff" and state.pr_template_path:
|
|
2938
3195
|
lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
|
|
2939
3196
|
return "\n".join(lines)
|
|
@@ -195,6 +195,41 @@ def preview_worktree_decision(
|
|
|
195
195
|
)
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
def preview_stage_worktree_decision(
|
|
199
|
+
*,
|
|
200
|
+
project_id: str,
|
|
201
|
+
task_group_segment: str,
|
|
202
|
+
task_id_segment: str,
|
|
203
|
+
work_category: str,
|
|
204
|
+
stage_number: int,
|
|
205
|
+
) -> "WorktreeDecision":
|
|
206
|
+
"""Side-effect-free: what provision_stage_worktree WOULD do (reuse vs new).
|
|
207
|
+
|
|
208
|
+
Mirrors provision_stage_worktree's registry-reuse branch exactly. A "new"
|
|
209
|
+
decision carries no base_ref — the stage base commit is resolved later by
|
|
210
|
+
the prepare flow from the stage's dependency anchors.
|
|
211
|
+
"""
|
|
212
|
+
safe_project = _safe_segment(project_id)
|
|
213
|
+
safe_group = _safe_segment(task_group_segment)
|
|
214
|
+
safe_task = _safe_segment(task_id_segment)
|
|
215
|
+
existing = worktree_registry.lookup(
|
|
216
|
+
safe_project, safe_group, safe_task, stage_number=stage_number)
|
|
217
|
+
if existing is not None and existing.status == "active":
|
|
218
|
+
return WorktreeDecision(
|
|
219
|
+
status="reused", path=existing.worktree_path,
|
|
220
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
221
|
+
)
|
|
222
|
+
return WorktreeDecision(
|
|
223
|
+
status="new",
|
|
224
|
+
path=str(compute_worktree_path(
|
|
225
|
+
project_id=safe_project, task_group_segment=safe_group,
|
|
226
|
+
task_id_segment=safe_task, stage_number=stage_number)),
|
|
227
|
+
branch=compute_branch_name(
|
|
228
|
+
work_category=work_category, task_id_segment=safe_task,
|
|
229
|
+
stage_number=stage_number),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
198
233
|
def _safe_segment(value: str) -> str:
|
|
199
234
|
"""Sanitise a single path/branch segment.
|
|
200
235
|
|
|
@@ -47,7 +47,7 @@ The wizard tells you *which UI to use* via `kind` (and the optional `multi` flag
|
|
|
47
47
|
- `kind: "done"` → input collection finished; move to Step 5.
|
|
48
48
|
- `kind: "aborted"` → the user picked 중단; the wizard is terminally cancelled. Tell the user on one short line that the run setup was aborted, delete the state file (`rm` with the literal path), and stop this skill — do NOT call `render-args` or `render-bundle` (the wizard rejects `render-args` on an aborted state).
|
|
49
49
|
|
|
50
|
-
The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new
|
|
50
|
+
The `branch_confirm` step (shown just before `confirm`) is a normal `pick` step and is rendered the same way — no special handling needed. Its options always include `중단` (abort); `base-ref 다시 고르기` (edit) appears only when a new branch would be created from the user-chosen base-ref. For `implementation` runs the wizard previews the **stage worktree** this run will actually use (not the task-key directory); render its label verbatim.
|
|
51
51
|
|
|
52
52
|
Never invent additional questions. Never reorder. **Never drop, hide, or merge a `pick` / `pick_group` option** — render every `options[]` entry as its own selectable `AskUserQuestion` choice, including entries that carry a `(default)` / `(recommended)` suffix. Do NOT collapse a multi-option pick into a "recommended + 직접 입력 / Other" shortlist: the wizard's `options[]` array IS the complete, authoritative choice set. Example: the `executor` step always emits `claude` / `codex` / `gemini` — show all three, never just `claude`. The run-prompt recommendation rule (1–2 추천 + 직접 입력) applies ONLY to prompts this skill authors itself (e.g. the conformance-waiver picker), never to wizard-provided `options[]`. Never use `AskUserQuestion` for `text` prompts — the wizard explicitly chose `text` to avoid the picker-Other re-render lag.
|
|
53
53
|
|
|
@@ -121,9 +121,10 @@ That is the entire interactive flow. The wizard handles:
|
|
|
121
121
|
|
|
122
122
|
- new-vs-existing task split (남은 작업 — `workStatus != done` — 최신순 3개 추천 + 직접 입력), task-group / task-id slug validation (각각 최근 후보 3개 추천 + 직접 입력),
|
|
123
123
|
- task-type pick (추천 3개 — `nextRecommendedPhase` recommended / 현재 phase 재실행 / 라이프사이클 다음 단계 — + 직접 입력; 직접 입력은 후속 `text` 단계에서 전체 task-type 화이트리스트로 검증),
|
|
124
|
-
- brief path
|
|
124
|
+
- brief path — **entry task-type(requirements-discovery / error-analysis / improvement-discovery)에서만 질문** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, direct input last; `유지 / 변경` for existing entry tasks). downstream task-type 은 manifest 의 brief 를 자동 carry-in 하고, 등록 brief 가 없으면 `brief_carry` 3-옵션(entry 전환 추천 / 직접 입력 / 중단)이 뜬다. `release-handoff` 는 brief 단계가 아예 없다 — prepare 가 검증 보고서 인용 input 문서를 생성한다,
|
|
125
125
|
- base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
|
|
126
126
|
- `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = 의존성 충족된 가장 빠른 미완료 stage, 또는 특정 stage 번호) + executor pick,
|
|
127
|
+
- `release-handoff`-only sub-flow: approved plan 자동 해소 후 `handoff_stage_pick` 멀티선택 — eligible stage 묶음(stage-group) 또는 전체 task(accepted whole-task 검증 보고서 존재 시) 선택; 결과는 render-args 의 `stages` 키(csv, whole-task 면 빈 값)로 나간다,
|
|
127
128
|
- `Use defaults / Customize` branch with profile-aware worker/model questions,
|
|
128
129
|
- `release-handoff` PR template override + persist scope,
|
|
129
130
|
- final `Proceed / Edit` confirmation; on `Edit` the wizard asks which step to rewind to and clears every later answer.
|
|
@@ -166,6 +167,7 @@ okstra render-bundle \
|
|
|
166
167
|
--critic "<args.critic>" \
|
|
167
168
|
--approved-plan "<args.approved-plan>" \
|
|
168
169
|
--stage "<args.stage>" \
|
|
170
|
+
--stages "<args.stages>" \
|
|
169
171
|
--base-ref "<args.base-ref>" \
|
|
170
172
|
--workers "<args.workers>" \
|
|
171
173
|
--directive "<args.directive>" \
|
|
@@ -25,11 +25,15 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
25
25
|
|
|
26
26
|
## Source Verification Report
|
|
27
27
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
28
|
+
- Mode: `{{HANDOFF_MODE}}`
|
|
29
|
+
- Stages: `{{HANDOFF_STAGES}}`
|
|
30
|
+
- Reports (one row per cited `final-verification` final-report; every Verdict Token MUST be `accepted`):
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
| Stage | Report path (project-relative) | Verdict Token |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
{{HANDOFF_SOURCE_REPORTS}}
|
|
35
|
+
|
|
36
|
+
> This section is generated by `okstra render-bundle` from the consumers ledger (`verified` rows) / the latest accepted whole-task report — the lead re-confirms each cited report's `Verdict Token` and MUST end the run immediately (route back to `final-verification`) if any token is not `accepted`. Release-handoff never operates on `conditional-accept` or `blocked` outcomes.
|
|
33
37
|
|
|
34
38
|
## Working-Tree Snapshot (filled at run start)
|
|
35
39
|
|
|
@@ -83,5 +87,5 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
83
87
|
|
|
84
88
|
## Conversion Note
|
|
85
89
|
|
|
86
|
-
- This
|
|
87
|
-
-
|
|
90
|
+
- This document is generated by `okstra render-bundle --task-type release-handoff` and serves as the run's input in place of a task brief (briefs belong to entry phases only).
|
|
91
|
+
- It reuses the same `Task Group` and `Task ID` as the originating implementation / final-verification runs so the handoff stays attached to the same task history.
|
package/src/render-bundle.mjs
CHANGED
|
@@ -18,7 +18,15 @@ Usage:
|
|
|
18
18
|
[--gemini-model <m>] [--report-writer-model <m>] \\
|
|
19
19
|
[--related-tasks <list>] [--base-ref <ref>] \\
|
|
20
20
|
[--clarification-response <path>] [--work-category <cat>] \\
|
|
21
|
-
[--pr-template-path <path>]
|
|
21
|
+
[--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>]
|
|
22
|
+
|
|
23
|
+
--stage implementation / final-verification only
|
|
24
|
+
--stages release-handoff only: PR stage bundle csv (empty = whole-task)
|
|
25
|
+
--pr-template-path release-handoff only
|
|
26
|
+
|
|
27
|
+
release-handoff takes NO --task-brief (briefs belong to entry phases) —
|
|
28
|
+
prepare generates the run's input document from the cited verification
|
|
29
|
+
reports instead.
|
|
22
30
|
|
|
23
31
|
All flags pass through unchanged to \`python3 -m okstra_ctl.run\`. The
|
|
24
32
|
shim auto-supplies \`--workspace-root\` (from \`okstra paths --field workspace\`)
|