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.
- package/bin/okstra +7 -0
- package/docs/kr/architecture.md +17 -1
- package/docs/superpowers/plans/2026-06-10-concurrent-run-team-guard.md +456 -0
- package/docs/superpowers/plans/2026-06-10-git-reconcile-stale-sha-recovery.md +1408 -0
- package/docs/superpowers/plans/2026-06-10-stage-group-handoff.md +1572 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +1 -1
- package/docs/superpowers/specs/2026-06-10-concurrent-run-team-guard-design.md +107 -0
- package/docs/superpowers/specs/2026-06-10-git-reconcile-stale-sha-recovery-design.md +105 -0
- package/docs/superpowers/specs/2026-06-10-stage-group-handoff-design.md +156 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/final-verification.md +3 -2
- package/runtime/prompts/profiles/release-handoff.md +12 -5
- package/runtime/prompts/wizard/prompts.ko.json +14 -4
- package/runtime/python/okstra_ctl/consumers.py +72 -5
- package/runtime/python/okstra_ctl/git_reconcile.py +322 -0
- package/runtime/python/okstra_ctl/handoff.py +348 -0
- package/runtime/python/okstra_ctl/render.py +44 -2
- package/runtime/python/okstra_ctl/run.py +88 -27
- package/runtime/python/okstra_ctl/wizard.py +141 -36
- package/runtime/python/okstra_ctl/worktree.py +10 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +40 -9
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +1 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -2
- package/runtime/skills/okstra-run/SKILL.md +45 -5
- package/runtime/skills/okstra-team-contract/SKILL.md +2 -2
- package/runtime/validators/validate-run.py +49 -9
- package/src/git-reconcile.mjs +31 -0
- 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 =
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
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={
|
|
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
|
|
803
|
+
for entry in remaining[:_RECOMMENDATION_CAP]:
|
|
793
804
|
key = entry.get("taskKey") or ""
|
|
794
805
|
ttype = entry.get("taskType") or ""
|
|
795
|
-
|
|
796
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1062
|
-
"""
|
|
1063
|
-
그 기존 manifest 의 nextRecommendedPhase 를 반환한다. 없으면 ''.
|
|
1078
|
+
def _existing_task_workflow(state: WizardState) -> dict:
|
|
1079
|
+
"""현재 task-key 가 이미 존재하면 그 manifest 의 workflow dict 를 반환한다.
|
|
1064
1080
|
|
|
1065
|
-
|
|
1066
|
-
task-group/task-id 를 다시 입력한
|
|
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
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
options
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
|
1154
|
+
def _apply_task_type(state: WizardState, value: str) -> str:
|
|
1101
1155
|
if value not in TASK_TYPE_VALUES:
|
|
1102
|
-
raise WizardError(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|