okstra 0.66.0 → 0.68.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.
Files changed (32) hide show
  1. package/bin/okstra +7 -0
  2. package/docs/kr/architecture.md +17 -1
  3. package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
  4. package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
  5. package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
  6. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
  7. package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
  8. package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
  9. package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
  10. package/package.json +1 -1
  11. package/runtime/BUILD.json +2 -2
  12. package/runtime/agents/SKILL.md +5 -4
  13. package/runtime/prompts/profiles/_common-contract.md +6 -6
  14. package/runtime/prompts/profiles/final-verification.md +3 -2
  15. package/runtime/prompts/profiles/release-handoff.md +12 -5
  16. package/runtime/prompts/wizard/prompts.ko.json +14 -4
  17. package/runtime/python/okstra_ctl/consumers.py +72 -5
  18. package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
  19. package/runtime/python/okstra_ctl/handoff.py +348 -0
  20. package/runtime/python/okstra_ctl/render.py +44 -2
  21. package/runtime/python/okstra_ctl/run.py +88 -27
  22. package/runtime/python/okstra_ctl/wizard.py +141 -36
  23. package/runtime/python/okstra_ctl/worktree.py +10 -0
  24. package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
  25. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  26. package/runtime/skills/okstra-convergence/SKILL.md +1 -1
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
  28. package/runtime/skills/okstra-run/SKILL.md +45 -5
  29. package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
  30. package/runtime/validators/validate-run.py +49 -9
  31. package/src/git-reconcile.mjs +31 -0
  32. package/src/handoff.mjs +30 -0
@@ -462,12 +462,41 @@ def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
462
462
  return r.returncode == 0
463
463
 
464
464
 
465
+ def _check_multi_dep_merged(project_root, plan_run_root, latest,
466
+ pred_commits: dict, candidate_base: str,
467
+ stage_n: int) -> None:
468
+ """모든 선행 done commit 이 candidate 에 (내용 기준으로라도) 머지됐는지
469
+ 검증. patch-equivalent 면 보정 row 를 자동 기록(다음 run 은 ancestor 로
470
+ 바로 통과). 아니면 회복 안내를 담아 PrepareError."""
471
+ from .git_reconcile import content_merged, _record_reconciled
472
+ for d, head in pred_commits.items():
473
+ if _commit_is_ancestor(project_root, head, candidate_base):
474
+ continue
475
+ match = content_merged(project_root, head, candidate_base)
476
+ if match.status in ("ancestor", "patch-equivalent"):
477
+ if plan_run_root is not None:
478
+ _record_reconciled(
479
+ plan_run_root,
480
+ impl_task_key=(latest.get(d) or {}).get("impl_task_key", ""),
481
+ stage=d, new_commit=match.matched_commit,
482
+ replaced=head, reason="auto-patch-id")
483
+ continue
484
+ raise PrepareError(
485
+ f"multi-dependency stage {stage_n}: predecessor stage {d} "
486
+ f"({head[:8]}) is not merged into the task worktree "
487
+ f"({candidate_base[:8]}). Merge stage branches "
488
+ f"(e.g. the `-s{d}` branches) into the task worktree "
489
+ "(or into main, then refresh the worktree) and retry."
490
+ )
491
+
492
+
465
493
  def _resolve_stage_base_commit(
466
494
  stage: dict,
467
495
  consumer_done_rows: list,
468
496
  anchor_base_commit: str,
469
497
  candidate_base: str = "",
470
498
  project_root=None,
499
+ plan_run_root=None,
471
500
  ) -> str:
472
501
  """Pick the git base commit a stage's isolated worktree branches from.
473
502
 
@@ -479,18 +508,15 @@ def _resolve_stage_base_commit(
479
508
  candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
480
509
 
481
510
  Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
511
+ from .consumers import latest_done_by_stage
512
+ latest = latest_done_by_stage(consumer_done_rows)
482
513
  deps = stage.get("depends_on") or []
483
514
  if len(deps) >= 2:
484
515
  n = stage["stage_number"]
485
516
  # 1) 모든 선행의 done head_commit 수집
486
517
  pred_commits = {}
487
518
  for d in deps:
488
- head = next(
489
- (r.get("head_commit") for r in consumer_done_rows
490
- if r.get("stage") == d and r.get("status") == "done"
491
- and r.get("head_commit")),
492
- None,
493
- )
519
+ head = (latest.get(d) or {}).get("head_commit")
494
520
  if not head:
495
521
  raise PrepareError(
496
522
  f"predecessor stage {d} has no done row with head_commit "
@@ -503,16 +529,10 @@ def _resolve_stage_base_commit(
503
529
  f"candidate base missing for multi-dependency stage {n}; "
504
530
  "task-key worktree HEAD could not be resolved"
505
531
  )
506
- # 3) 모든 선행 done 이 candidate ancestor 인지 (=사용자가 머지함)
507
- for d, head in pred_commits.items():
508
- if not _commit_is_ancestor(project_root, head, candidate_base):
509
- raise PrepareError(
510
- f"multi-dependency stage {n}: predecessor stage {d} "
511
- f"({head[:8]}) is not merged into the task worktree "
512
- f"({candidate_base[:8]}). Merge stage branches "
513
- f"(e.g. the `-s{d}` branches) into the task worktree "
514
- "(or into main, then refresh the worktree) and retry."
515
- )
532
+ # 3) 모든 선행 done 이 candidate (ancestor 또는 patch-equivalent )
533
+ # 머지됐는지 patch-equivalent 보정 row 를 자동 기록한다.
534
+ _check_multi_dep_merged(project_root, plan_run_root, latest,
535
+ pred_commits, candidate_base, n)
516
536
  return candidate_base
517
537
  if not deps:
518
538
  if not anchor_base_commit:
@@ -524,11 +544,9 @@ def _resolve_stage_base_commit(
524
544
  return anchor_base_commit
525
545
  # 단일 의존
526
546
  pred = deps[0]
527
- for row in consumer_done_rows:
528
- if row.get("stage") == pred and row.get("status") == "done":
529
- head = row.get("head_commit") or ""
530
- if head:
531
- return head
547
+ head = (latest.get(pred) or {}).get("head_commit") or ""
548
+ if head:
549
+ return head
532
550
  raise PrepareError(
533
551
  f"predecessor stage {pred} has no done row with head_commit in "
534
552
  "consumers.jsonl; cannot derive base for stage "
@@ -553,7 +571,8 @@ def _resolve_whole_task_target(
553
571
  ) -> "_FVTarget":
554
572
  """전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
555
573
  worktree clean 이어야 한다. 위반 시 PrepareError."""
556
- done_by_stage = {r["stage"]: r for r in done_rows}
574
+ from .consumers import latest_done_by_stage
575
+ done_by_stage = latest_done_by_stage(done_rows)
557
576
  for s in stage_map:
558
577
  n = s["stage_number"]
559
578
  if n not in done_by_stage:
@@ -587,8 +606,9 @@ def _resolve_single_stage_target(
587
606
  ) -> "_FVTarget":
588
607
  """단독-stage 검증 target. stage N 만 done + stage worktree 존재 + clean.
589
608
  다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
609
+ from .consumers import latest_done_by_stage
590
610
  n = int(requested_stage)
591
- done_by_stage = {r["stage"]: r for r in done_rows}
611
+ done_by_stage = latest_done_by_stage(done_rows)
592
612
  if n not in done_by_stage:
593
613
  raise PrepareError(
594
614
  f"final-verification(single-stage): stage {n} not done — "
@@ -1290,6 +1310,7 @@ class StageSelection:
1290
1310
  worktree_status: str
1291
1311
  worktree_note: str
1292
1312
  started_head_commit: str
1313
+ concurrent_stages: list = field(default_factory=list)
1293
1314
 
1294
1315
 
1295
1316
  def _select_and_provision_implementation_stage(
@@ -1315,6 +1336,7 @@ def _select_and_provision_implementation_stage(
1315
1336
  # carry sidecars are the SSOT for stage completion; recover any `done` rows
1316
1337
  # the lead failed to append before the dependency gate reads them.
1317
1338
  backfill_done_from_carry(plan_run_root)
1339
+ _auto_reconcile_best_effort(inp, plan_run_root)
1318
1340
  consumed = read_consumers(plan_run_root)
1319
1341
  done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
1320
1342
  started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
@@ -1327,6 +1349,7 @@ def _select_and_provision_implementation_stage(
1327
1349
  started_stages=started_stages, reserved_stages=reserved_stages,
1328
1350
  )
1329
1351
  selected = batch[0]
1352
+ concurrent_stages = sorted(reserved_stages - {selected})
1330
1353
 
1331
1354
  # spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
1332
1355
  # stage 격리도 동일하게 degrade — worktree 없이 project HEAD 만 기록.
@@ -1340,6 +1363,7 @@ def _select_and_provision_implementation_stage(
1340
1363
  worktree_status=executor_worktree_status,
1341
1364
  worktree_note="",
1342
1365
  started_head_commit=head,
1366
+ concurrent_stages=concurrent_stages,
1343
1367
  )
1344
1368
 
1345
1369
  # anchor base commit 1회 고정 (task-key worktree HEAD 기준)
@@ -1360,6 +1384,7 @@ def _select_and_provision_implementation_stage(
1360
1384
  stage_base = _resolve_stage_base_commit(
1361
1385
  selected_stage, consumer_done_rows, anchor_base_commit=anchor,
1362
1386
  candidate_base=head_sha, project_root=Path(inp.project_root),
1387
+ plan_run_root=plan_run_root,
1363
1388
  )
1364
1389
  try:
1365
1390
  prov = _worktree.provision_stage_worktree(
@@ -1372,7 +1397,12 @@ def _select_and_provision_implementation_stage(
1372
1397
  base_commit=stage_base,
1373
1398
  )
1374
1399
  except RuntimeError as exc:
1375
- raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
1400
+ from .git_reconcile import guidance
1401
+ hint = guidance(plan_run_root=plan_run_root, project_id=inp.project_id,
1402
+ task_group=inp.task_group, task_id=inp.task_id,
1403
+ work_category=inp.work_category)
1404
+ raise PrepareError(
1405
+ f"stage worktree provisioning failed: {exc}\n{hint}") from exc
1376
1406
 
1377
1407
  return StageSelection(
1378
1408
  stage=selected,
@@ -1382,6 +1412,7 @@ def _select_and_provision_implementation_stage(
1382
1412
  worktree_status=prov.status,
1383
1413
  worktree_note=prov.note,
1384
1414
  started_head_commit=prov.base_ref,
1415
+ concurrent_stages=concurrent_stages,
1385
1416
  )
1386
1417
 
1387
1418
 
@@ -1400,6 +1431,7 @@ def _apply_implementation_stage(
1400
1431
  ctx["effective_stages"] = [sel.stage]
1401
1432
  csv = str(sel.stage)
1402
1433
  ctx["EFFECTIVE_STAGES"] = csv
1434
+ ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in sel.concurrent_stages)
1403
1435
  ctx["STAGE_BATCH_DIRECTIVE"] = (
1404
1436
  f"- **Stage for this implementation run:** `{csv}`. "
1405
1437
  "Execute exactly this Stage Map stage — this is the authoritative scope. "
@@ -1436,6 +1468,20 @@ def _git_out(cwd, *args) -> str:
1436
1468
  return r.stdout.strip() if r.returncode == 0 else ""
1437
1469
 
1438
1470
 
1471
+ def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> None:
1472
+ try:
1473
+ from .git_reconcile import auto_reconcile
1474
+ for it in auto_reconcile(
1475
+ project_root=Path(inp.project_root), plan_run_root=plan_run_root,
1476
+ project_id=inp.project_id, task_group=inp.task_group,
1477
+ task_id=inp.task_id, work_category=inp.work_category):
1478
+ print(f"git-reconcile: stage {it.stage} done commit "
1479
+ f"{it.recorded[:8]} -> {it.suggested_commit[:8]} "
1480
+ "(patch-equivalent)", file=sys.stdout)
1481
+ except Exception as exc: # 화해는 부가 기능 — 실패 시 기존 gate 가 판정
1482
+ print(f"git-reconcile skipped: {exc}", file=sys.stderr)
1483
+
1484
+
1439
1485
  def _is_ancestor(cwd, commit, head) -> bool:
1440
1486
  if not commit or not head:
1441
1487
  return False
@@ -1464,6 +1510,7 @@ def _reserve_final_verification_target(
1464
1510
  # carry sidecars are the SSOT for stage completion — recover missing `done`
1465
1511
  # rows before the whole-task gate checks every stage.
1466
1512
  backfill_done_from_carry(plan_run_root)
1513
+ _auto_reconcile_best_effort(inp, plan_run_root)
1467
1514
  done_rows = [r for r in read_consumers(plan_run_root)
1468
1515
  if r.get("status") == "done"]
1469
1516
 
@@ -1485,8 +1532,9 @@ def _reserve_final_verification_target(
1485
1532
  anchor = _reg.get_implementation_base(
1486
1533
  inp.project_id, inp.task_group, inp.task_id) or ""
1487
1534
  head = _git_out(wt_path, "rev-parse", "HEAD")
1488
- merged = {r["stage"]: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1489
- for r in done_rows}
1535
+ from .consumers import latest_done_by_stage
1536
+ merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1537
+ for s, r in latest_done_by_stage(done_rows).items()}
1490
1538
  target = _resolve_whole_task_target(
1491
1539
  stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
1492
1540
  task_worktree_path=wt_path, task_head=head,
@@ -1959,10 +2007,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1959
2007
  if not inp.render_only:
1960
2008
  _provision_settings_symlink(inp)
1961
2009
 
2010
+ concurrent_run: dict = {}
2011
+ if impl_stage_selection is not None and impl_stage_selection.concurrent_stages:
2012
+ concurrent_run = {
2013
+ "detected": True,
2014
+ "active_stages": impl_stage_selection.concurrent_stages,
2015
+ }
1962
2016
  return PrepareOutputs(
1963
2017
  ctx=ctx,
1964
2018
  prompt_text=prompt_text,
1965
- extras={"profile_content": profile_content},
2019
+ extras={
2020
+ "profile_content": profile_content,
2021
+ "concurrent_run": concurrent_run,
2022
+ },
1966
2023
  )
1967
2024
 
1968
2025
 
@@ -2132,6 +2189,10 @@ def main(argv: list[str]) -> int:
2132
2189
  print(f"okstra instruction-set: {ctx['INSTRUCTION_SET_PATH']}")
2133
2190
  print(f"okstra reference expectations: {ctx['REFERENCE_EXPECTATIONS_FILE']}")
2134
2191
  print(f"okstra final report template: {ctx['FINAL_REPORT_TEMPLATE_PATH']}")
2192
+ cr = out.extras.get("concurrent_run", {})
2193
+ if cr.get("detected"):
2194
+ stages_csv = ",".join(str(s) for s in cr["active_stages"])
2195
+ print(f"okstra concurrent-run stages: {stages_csv}")
2135
2196
  if inputs.render_only:
2136
2197
  print()
2137
2198
  print(out.prompt_text, end="")
@@ -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"
@@ -254,6 +261,7 @@ S_BRANCH_CONFIRM = "branch_confirm"
254
261
  S_CONFIRM = "confirm"
255
262
  S_EDIT_TARGET = "edit_target"
256
263
  S_DONE = "done"
264
+ S_ABORTED = "aborted"
257
265
 
258
266
  # ---- 멀티탭 배치 프롬프트 그룹 (방출 계층 전용) ----
259
267
  # 그룹 id 는 S_* 가 아니므로 prompts JSON SOT / step-id 동기화 검사 대상이 아니다.
@@ -355,6 +363,8 @@ class WizardState:
355
363
  branch_confirmed: Optional[bool] = None
356
364
  confirmed: Optional[bool] = None
357
365
  edit_target: str = ""
366
+ # terminal: user picked 중단 — no further prompt ever applies
367
+ aborted: bool = False
358
368
 
359
369
  # bookkeeping
360
370
  answered: list[str] = field(default_factory=list)
@@ -377,7 +387,7 @@ class Option:
377
387
  @dataclass
378
388
  class Prompt:
379
389
  step: str
380
- kind: str # "pick" | "text" | "done"
390
+ kind: str # "pick" | "text" | "pick_group" | "done" | "aborted"
381
391
  label: str = ""
382
392
  options: list[Option] = field(default_factory=list)
383
393
  help: str = ""
@@ -788,12 +798,15 @@ def _build_task_pick(state: WizardState) -> Prompt:
788
798
  latest = read_latest_task(project_root) or {}
789
799
  latest_key = latest.get("taskKey") or ""
790
800
  latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
801
+ remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"]
791
802
  options: list[Option] = []
792
- for entry in tasks[:16]:
803
+ for entry in remaining[:_RECOMMENDATION_CAP]:
793
804
  key = entry.get("taskKey") or ""
794
805
  ttype = entry.get("taskType") or ""
795
- phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
796
- nxt = (entry.get("workflow") or {}).get("nextRecommendedPhase") or ""
806
+ # catalog entries are flat (render_task_catalog_discovery) there is
807
+ # no nested "workflow" object here, unlike task-manifest.json.
808
+ phase = entry.get("currentPhase") or ttype
809
+ nxt = entry.get("nextRecommendedPhase") or ""
797
810
  suffix = latest_suffix if key == latest_key else ""
798
811
  label = f"{key} · {phase} · next: {nxt}{suffix}"
799
812
  options.append(_opt(value=key, label=label))
@@ -838,7 +851,9 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
838
851
  return f"task: {value}"
839
852
 
840
853
 
841
- def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]:
854
+ def _suggest_recent_task_groups(
855
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
856
+ ) -> list[str]:
842
857
  """프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
843
858
  if not state.project_root:
844
859
  return []
@@ -856,7 +871,9 @@ def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]
856
871
  return seen
857
872
 
858
873
 
859
- def _suggest_recent_task_ids(state: WizardState, limit: int = 2) -> list[str]:
874
+ def _suggest_recent_task_ids(
875
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
876
+ ) -> list[str]:
860
877
  """현재 task_group 내의 최근 task-id 후보를 limit 개까지 반환."""
861
878
  if not state.project_root or not state.task_group:
862
879
  return []
@@ -925,7 +942,7 @@ def _build_task_group(state: WizardState) -> Prompt:
925
942
  echo_template=t["echo_template"],
926
943
  )
927
944
  # suggestion 이 없으면 catalog 의 최근 task-group 을 후보로 노출 + 직접 입력
928
- recent = _suggest_recent_task_groups(state, limit=2)
945
+ recent = _suggest_recent_task_groups(state)
929
946
  t = _p(state.workspace_root, "task_group_no_suggestion")
930
947
  recent_prefix = t.get("recent_label_prefix", "")
931
948
  options: list[Option] = []
@@ -999,7 +1016,7 @@ def _build_task_id(state: WizardState) -> Prompt:
999
1016
  label=t["label"], options=options,
1000
1017
  echo_template=t["echo_template"],
1001
1018
  )
1002
- recent = _suggest_recent_task_ids(state, limit=2)
1019
+ recent = _suggest_recent_task_ids(state)
1003
1020
  t = _p(state.workspace_root, "task_id_no_suggestion")
1004
1021
  recent_prefix = t.get("recent_label_prefix", "")
1005
1022
  options: list[Option] = []
@@ -1058,48 +1075,88 @@ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
1058
1075
  return f"task-id: {state.task_id}"
1059
1076
 
1060
1077
 
1061
- def _existing_task_next_phase(state: WizardState) -> str:
1062
- """ task 로 시작했더라도 입력한 task-key 가 이미 존재하면(=사실상 이어가기)
1063
- 그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
1078
+ def _existing_task_workflow(state: WizardState) -> dict:
1079
+ """현재 task-key 가 이미 존재하면 그 manifest 의 workflow dict 를 반환한다.
1064
1080
 
1065
- 사용자가 picker 에서 기존 task-key고르지 않고 new-task 흐름으로 같은
1066
- task-group/task-id 를 다시 입력한 경우에도 직전 phase 의 추천이 끊기지 않게
1067
- 하는 안전장치."""
1081
+ picker 기존 task 를 고른 경우뿐 아니라, new-task 흐름으로 같은
1082
+ task-group/task-id 를 다시 입력한 경우(=사실상 이어가기)에도 직전 phase
1083
+ 기반 추천이 끊기지 않게 하는 안전장치. 없으면 {}."""
1068
1084
  if not (state.project_id and state.task_group and state.task_id):
1069
- return ""
1085
+ return {}
1070
1086
  key = f"{state.project_id}:{state.task_group}:{state.task_id}"
1071
1087
  root = find_task_root(Path(state.project_root), key)
1072
1088
  if root is None:
1073
- return ""
1089
+ return {}
1074
1090
  workflow = (read_task_manifest(root) or {}).get("workflow") or {}
1075
- nxt = workflow.get("nextRecommendedPhase") or ""
1076
- return nxt if isinstance(nxt, str) else ""
1091
+ return workflow if isinstance(workflow, dict) else {}
1092
+
1093
+
1094
+ def _phase_after(task_type: str) -> str:
1095
+ """라이프사이클(PHASE_SEQUENCE) 상 task_type 바로 다음 단계. 없으면 ''."""
1096
+ try:
1097
+ idx = PHASE_SEQUENCE.index(task_type)
1098
+ except ValueError:
1099
+ return ""
1100
+ return PHASE_SEQUENCE[idx + 1] if idx + 1 < len(PHASE_SEQUENCE) else ""
1101
+
1102
+
1103
+ def _recent_task_types(state: WizardState) -> list[str]:
1104
+ """catalog 최신순으로 이 프로젝트에서 최근 사용된 task-type 목록(중복 제거)."""
1105
+ if not state.project_root:
1106
+ return []
1107
+ try:
1108
+ tasks = list_project_tasks(Path(state.project_root))
1109
+ except (OSError, StateError):
1110
+ return []
1111
+ out: list[str] = []
1112
+ for entry in tasks:
1113
+ tt = entry.get("taskType") or ""
1114
+ if tt and tt not in out:
1115
+ out.append(tt)
1116
+ return out
1077
1117
 
1078
1118
 
1079
1119
  def _build_task_type(state: WizardState) -> Prompt:
1080
1120
  t = _p(state.workspace_root, "task_type")
1081
1121
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
1122
+ rerun_suffix = t["options"].get("_RERUN_SUFFIX", "")
1123
+ next_suffix = t["options"].get("_NEXT_SUFFIX", "")
1124
+ description_by_type = dict(TASK_TYPES)
1082
1125
  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))
1126
+
1127
+ def add(task_type: str, suffix: str = "") -> None:
1128
+ if task_type not in description_by_type:
1129
+ return
1130
+ if any(o.value == task_type for o in options):
1131
+ return
1132
+ if len(options) >= _RECOMMENDATION_CAP:
1133
+ return
1134
+ options.append(_opt(task_type, f"{task_type}{suffix}",
1135
+ description_by_type[task_type]))
1136
+
1137
+ workflow = _existing_task_workflow(state)
1138
+ recommended = state.task_type or workflow.get("nextRecommendedPhase") or ""
1139
+ if not recommended and not workflow:
1140
+ recommended = TASK_TYPE_VALUES[0]
1141
+ add(recommended, recommended_suffix)
1142
+ add(workflow.get("currentPhase") or "", rerun_suffix)
1143
+ add(_phase_after(recommended), next_suffix)
1144
+ for tt in _recent_task_types(state):
1145
+ add(tt)
1146
+ for tt in TASK_TYPE_VALUES:
1147
+ add(tt)
1148
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1095
1149
  return Prompt(step=S_TASK_TYPE, kind="pick",
1096
1150
  label=t["label"], options=options,
1097
1151
  echo_template=t["echo_template"])
1098
1152
 
1099
1153
 
1100
- def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1154
+ def _apply_task_type(state: WizardState, value: str) -> str:
1101
1155
  if value not in TASK_TYPE_VALUES:
1102
- raise WizardError(f"unknown task-type: {value!r}")
1156
+ raise WizardError(
1157
+ f"unknown task-type: {value!r} "
1158
+ f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
1159
+ )
1103
1160
  state.task_type = value
1104
1161
  state.profile_workers = _load_profile_workers(
1105
1162
  Path(state.workspace_root), value
@@ -1113,6 +1170,27 @@ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1113
1170
  return f"task-type: {value}"
1114
1171
 
1115
1172
 
1173
+ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
1174
+ # PICK_TYPE_CUSTOM leaves task_type empty while S_TASK_TYPE gets marked
1175
+ # answered — that combination is what gates S_TASK_TYPE_TEXT on.
1176
+ if value == PICK_TYPE_CUSTOM:
1177
+ state.task_type = ""
1178
+ t = _p(state.workspace_root, "task_type")
1179
+ return t["echo_variants"]["free_input"]
1180
+ return _apply_task_type(state, value)
1181
+
1182
+
1183
+ def _build_task_type_text(state: WizardState) -> Prompt:
1184
+ t = _p(state.workspace_root, "task_type_text")
1185
+ return Prompt(step=S_TASK_TYPE_TEXT, kind="text",
1186
+ label=t["label"],
1187
+ echo_template=t["echo_template"])
1188
+
1189
+
1190
+ def _submit_task_type_text(state: WizardState, value: str) -> Optional[str]:
1191
+ return _apply_task_type(state, value.strip())
1192
+
1193
+
1116
1194
  def _build_brief_keep(state: WizardState) -> Prompt:
1117
1195
  t = _p(state.workspace_root, "brief_keep",
1118
1196
  existing_brief_path=state.existing_brief_path)
@@ -2126,6 +2204,7 @@ def _build_branch_confirm(state: WizardState) -> Prompt:
2126
2204
  options = [_opt("proceed", opts["proceed"])]
2127
2205
  if decision.status == "new":
2128
2206
  options.append(_opt("edit", opts["edit"]))
2207
+ options.append(_opt("abort", opts["abort"]))
2129
2208
  return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2130
2209
  options=options, echo_template=t["echo_template"])
2131
2210
 
@@ -2135,8 +2214,13 @@ def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
2135
2214
  _reset_from(state, S_BASE_REF_PICK)
2136
2215
  state.branch_confirmed = None
2137
2216
  return "branch-confirm: edit"
2217
+ if value == "abort":
2218
+ state.aborted = True
2219
+ return "branch-confirm: abort"
2138
2220
  if value != "proceed":
2139
- raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
2221
+ raise WizardError(
2222
+ f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
2223
+ )
2140
2224
  state.branch_confirmed = True
2141
2225
  return "branch-confirm: proceed"
2142
2226
 
@@ -2216,6 +2300,7 @@ STEPS: list[Step] = [
2216
2300
  (s.is_new_task is True and bool(s.task_group))
2217
2301
  or (s.is_new_task is False
2218
2302
  and S_TASK_TYPE in s.answered
2303
+ and bool(s.task_type)
2219
2304
  and (s.keep_existing_brief is False
2220
2305
  or not s.existing_brief_path))
2221
2306
  )
@@ -2250,11 +2335,19 @@ STEPS: list[Step] = [
2250
2335
  build=_build_task_type, submit=_submit_task_type,
2251
2336
  owns=("task_type", "profile_workers", "profile_optional_workers",
2252
2337
  "reuse_worktree")),
2338
+ Step(S_TASK_TYPE_TEXT,
2339
+ applies=lambda s: (S_TASK_TYPE in s.answered
2340
+ and not s.task_type
2341
+ and S_TASK_TYPE_TEXT not in s.answered),
2342
+ build=_build_task_type_text, submit=_submit_task_type_text,
2343
+ owns=("task_type", "profile_workers", "profile_optional_workers",
2344
+ "reuse_worktree")),
2253
2345
  Step(S_BRIEF_KEEP,
2254
2346
  applies=lambda s: (not s.is_new_task
2255
2347
  and bool(s.existing_brief_path)
2256
2348
  and s.keep_existing_brief is None
2257
- and S_TASK_TYPE in s.answered),
2349
+ and S_TASK_TYPE in s.answered
2350
+ and bool(s.task_type)),
2258
2351
  build=_build_brief_keep, submit=_submit_brief_keep,
2259
2352
  owns=("keep_existing_brief",)),
2260
2353
  Step(S_BASE_REF_PICK,
@@ -2601,6 +2694,8 @@ def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
2601
2694
 
2602
2695
 
2603
2696
  def next_prompt(state: WizardState) -> Prompt:
2697
+ if state.aborted:
2698
+ return Prompt(step=S_ABORTED, kind="aborted")
2604
2699
  if state.confirmed:
2605
2700
  return Prompt(step=S_DONE, kind="done")
2606
2701
  for step in STEPS:
@@ -2647,7 +2742,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2647
2742
  validation failure (caller may re-prompt).
2648
2743
  """
2649
2744
  prompt = next_prompt(state)
2650
- if prompt.kind == "done":
2745
+ if prompt.kind in ("done", "aborted"):
2651
2746
  return {"echo": "", "next": prompt.to_json()}
2652
2747
  if prompt.kind == "pick_group":
2653
2748
  return _submit_group(state, prompt, value)
@@ -2661,6 +2756,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
2661
2756
 
2662
2757
  def render_args(state: WizardState) -> dict[str, str]:
2663
2758
  """Convert finalized state into ``okstra render-bundle`` argument map."""
2759
+ if state.aborted:
2760
+ raise WizardError(
2761
+ "wizard was aborted by the user — render-args is unavailable"
2762
+ )
2664
2763
  workers = state.workers_override.strip()
2665
2764
  if state.task_type == "implementation":
2666
2765
  workers = "" # profile-default roster is mandatory for impl
@@ -2844,7 +2943,13 @@ def _cli(argv: list[str]) -> int:
2844
2943
 
2845
2944
  if args.cmd == "render-args":
2846
2945
  state = load_state_file(state_path)
2847
- print(json.dumps({"ok": True, "args": render_args(state)},
2946
+ try:
2947
+ rendered = render_args(state)
2948
+ except WizardError as exc:
2949
+ print(json.dumps({"ok": False, "error": str(exc)},
2950
+ ensure_ascii=False, indent=2))
2951
+ return 0
2952
+ print(json.dumps({"ok": True, "args": rendered},
2848
2953
  ensure_ascii=False, indent=2))
2849
2954
  return 0
2850
2955
 
@@ -492,11 +492,14 @@ def compute_worktree_path(
492
492
  task_group_segment: str,
493
493
  task_id_segment: str,
494
494
  stage_number: Optional[int] = None,
495
+ group_id: Optional[str] = None,
495
496
  ) -> Path:
496
497
  """Pure path computation. One worktree dir per task-key, or per
497
498
  `<task-key>/stage-<N>` when stage_number is given (implementation
498
499
  stage isolation). Uses `OKSTRA_HOME` when set (test hook), else
499
500
  `~/.okstra`."""
501
+ if stage_number is not None and group_id is not None:
502
+ raise ValueError("stage_number and group_id are mutually exclusive")
500
503
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
501
504
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
502
505
  path = (
@@ -507,6 +510,8 @@ def compute_worktree_path(
507
510
  )
508
511
  if stage_number is not None:
509
512
  path = path / f"stage-{stage_number}"
513
+ if group_id is not None:
514
+ path = path / f"group-{group_id}"
510
515
  return path
511
516
 
512
517
 
@@ -515,12 +520,17 @@ def compute_branch_name(
515
520
  work_category: str,
516
521
  task_id_segment: str,
517
522
  stage_number: Optional[int] = None,
523
+ group_id: Optional[str] = None,
518
524
  ) -> str:
519
525
  """One branch per task-key, or `<prefix>-<task-id>-s<N>` for an
520
526
  implementation stage worktree."""
527
+ if stage_number is not None and group_id is not None:
528
+ raise ValueError("stage_number and group_id are mutually exclusive")
521
529
  name = f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
522
530
  if stage_number is not None:
523
531
  name = f"{name}-s{stage_number}"
532
+ if group_id is not None:
533
+ name = f"{name}-{group_id}"
524
534
  return name
525
535
 
526
536