okstra 0.36.2 → 0.37.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/README.kr.md +6 -6
- package/README.md +6 -6
- package/bin/okstra +4 -2
- package/docs/kr/architecture.md +29 -29
- package/docs/kr/cli.md +7 -6
- package/docs/pr-template-usage.md +2 -2
- package/docs/project-structure-overview.md +4 -4
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
- package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
- package/docs/task-process/common-flow.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +6 -6
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +5 -5
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +80 -6
- package/runtime/python/lib/okstra/interactive.sh +2 -2
- package/runtime/python/lib/okstra/project-resolver.sh +1 -1
- package/runtime/python/lib/okstra/usage.sh +5 -5
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/python/okstra_ctl/backfill.py +5 -3
- package/runtime/python/okstra_ctl/migrate.py +408 -0
- package/runtime/python/okstra_ctl/paths.py +12 -3
- package/runtime/python/okstra_ctl/pr_template.py +4 -2
- package/runtime/python/okstra_ctl/render.py +8 -6
- package/runtime/python/okstra_ctl/run.py +5 -5
- package/runtime/python/okstra_ctl/seeding.py +12 -6
- package/runtime/python/okstra_ctl/sequence.py +3 -1
- package/runtime/python/okstra_ctl/wizard.py +412 -77
- package/runtime/python/okstra_ctl/worktree.py +8 -6
- package/runtime/python/okstra_project/__init__.py +35 -5
- package/runtime/python/okstra_project/dirs.py +67 -0
- package/runtime/python/okstra_project/resolver.py +8 -8
- package/runtime/python/okstra_project/state.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +3 -1
- package/runtime/skills/okstra-brief/SKILL.md +30 -30
- package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
- package/runtime/skills/okstra-inspect/SKILL.md +25 -25
- package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
- package/runtime/skills/okstra-schedule/SKILL.md +7 -7
- package/runtime/skills/okstra-setup/SKILL.md +8 -8
- package/runtime/templates/okstra.CLAUDE.md +4 -4
- package/runtime/templates/reports/brief.template.md +5 -5
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/lib/paths.sh +9 -3
- package/runtime/validators/validate-brief.py +2 -2
- package/runtime/validators/validate-brief.sh +1 -1
- package/runtime/validators/validate-run.py +3 -1
- package/runtime/validators/validate-workflow.sh +2 -2
- package/src/check-project.mjs +3 -3
- package/src/config.mjs +6 -5
- package/src/install.mjs +5 -5
- package/src/migrate.mjs +163 -0
- package/src/okstra-dirs.mjs +37 -0
- package/src/paths.mjs +17 -0
- package/src/setup.mjs +8 -4
- package/src/uninstall.mjs +3 -3
|
@@ -45,7 +45,9 @@ from okstra_ctl.workers import (
|
|
|
45
45
|
validate_workers_against_profile,
|
|
46
46
|
)
|
|
47
47
|
from okstra_ctl import worktree_registry
|
|
48
|
+
from okstra_project.dirs import project_json_path, tasks_root
|
|
48
49
|
from okstra_project.state import (
|
|
50
|
+
StateError,
|
|
49
51
|
list_project_tasks,
|
|
50
52
|
read_latest_task,
|
|
51
53
|
read_task_manifest,
|
|
@@ -81,6 +83,8 @@ TASK_PICK_NEW_TOKEN = "__new__"
|
|
|
81
83
|
# Pick-vs-free-text tokens shared by suggestion-aware prompts.
|
|
82
84
|
PICK_USE_SUGGESTED = "__use_suggested__"
|
|
83
85
|
PICK_TYPE_CUSTOM = "__free_input__"
|
|
86
|
+
_RECENT_PREFIX = "__recent:"
|
|
87
|
+
_REPORT_PREFIX = "__report:"
|
|
84
88
|
|
|
85
89
|
# Lines of `key: value` we pull from a brief markdown frontmatter. The
|
|
86
90
|
# parser is intentionally lightweight (no yaml dep) and tolerant — a
|
|
@@ -167,6 +171,7 @@ S_TASK_ID = "task_id"
|
|
|
167
171
|
S_TASK_ID_TEXT = "task_id_text"
|
|
168
172
|
S_TASK_TYPE = "task_type"
|
|
169
173
|
S_BRIEF_KEEP = "brief_keep"
|
|
174
|
+
S_BRIEF_PATH_PICK = "brief_path_pick"
|
|
170
175
|
S_BRIEF_PATH = "brief_path"
|
|
171
176
|
S_BASE_REF_PICK = "base_ref_pick"
|
|
172
177
|
S_BASE_REF_TEXT = "base_ref_text"
|
|
@@ -226,6 +231,7 @@ class WizardState:
|
|
|
226
231
|
# brief
|
|
227
232
|
keep_existing_brief: Optional[bool] = None
|
|
228
233
|
brief_path: str = ""
|
|
234
|
+
brief_path_pending_text: bool = False
|
|
229
235
|
|
|
230
236
|
# worktree
|
|
231
237
|
reuse_worktree: Optional[bool] = None
|
|
@@ -248,13 +254,17 @@ class WizardState:
|
|
|
248
254
|
report_writer_model: str = ""
|
|
249
255
|
directive: str = ""
|
|
250
256
|
directive_pending_text: bool = False
|
|
257
|
+
last_directive_cached: str = ""
|
|
251
258
|
related_tasks_raw: str = ""
|
|
252
259
|
related_tasks_pending_text: bool = False
|
|
260
|
+
last_siblings_cached: str = ""
|
|
253
261
|
clarification_response_path: str = ""
|
|
254
262
|
clarification_pending_text: bool = False
|
|
263
|
+
last_final_report_cached: str = ""
|
|
255
264
|
pr_template_path: str = ""
|
|
256
265
|
pr_template_pending_text: bool = False
|
|
257
266
|
pr_template_scope: str = "" # "once" | "project" | "global"
|
|
267
|
+
last_pr_template_cached: str = ""
|
|
258
268
|
|
|
259
269
|
# confirm / edit
|
|
260
270
|
confirmed: Optional[bool] = None
|
|
@@ -505,6 +515,9 @@ def _p(workspace_root: str, step_id: str, **vars: str) -> dict:
|
|
|
505
515
|
"options": raw.get("options", {}),
|
|
506
516
|
"echo_variants": raw.get("echo_variants", {}),
|
|
507
517
|
"errors": raw.get("errors", {}),
|
|
518
|
+
"labels": raw.get("labels", {}),
|
|
519
|
+
"echo_suffixes": raw.get("echo_suffixes", {}),
|
|
520
|
+
"recent_label_prefix": raw.get("recent_label_prefix", ""),
|
|
508
521
|
}
|
|
509
522
|
|
|
510
523
|
|
|
@@ -656,6 +669,45 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
656
669
|
return f"task: {value}"
|
|
657
670
|
|
|
658
671
|
|
|
672
|
+
def _suggest_recent_task_groups(state: WizardState, limit: int = 2) -> list[str]:
|
|
673
|
+
"""프로젝트 catalog 에서 최근 task-group 후보를 limit 개까지 반환."""
|
|
674
|
+
if not state.project_root:
|
|
675
|
+
return []
|
|
676
|
+
try:
|
|
677
|
+
tasks = list_project_tasks(Path(state.project_root))
|
|
678
|
+
except (OSError, StateError):
|
|
679
|
+
return []
|
|
680
|
+
seen: list[str] = []
|
|
681
|
+
for entry in tasks:
|
|
682
|
+
tg = entry.get("taskGroup") or ""
|
|
683
|
+
if tg and tg not in seen:
|
|
684
|
+
seen.append(tg)
|
|
685
|
+
if len(seen) >= limit:
|
|
686
|
+
break
|
|
687
|
+
return seen
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _suggest_recent_task_ids(state: WizardState, limit: int = 2) -> list[str]:
|
|
691
|
+
"""현재 task_group 내의 최근 task-id 후보를 limit 개까지 반환."""
|
|
692
|
+
if not state.project_root or not state.task_group:
|
|
693
|
+
return []
|
|
694
|
+
try:
|
|
695
|
+
tasks = list_project_tasks(Path(state.project_root))
|
|
696
|
+
except (OSError, StateError):
|
|
697
|
+
return []
|
|
698
|
+
seen: list[str] = []
|
|
699
|
+
for entry in tasks:
|
|
700
|
+
tg = entry.get("taskGroup") or ""
|
|
701
|
+
if tg != state.task_group:
|
|
702
|
+
continue
|
|
703
|
+
tid = entry.get("taskId") or ""
|
|
704
|
+
if tid and tid not in seen:
|
|
705
|
+
seen.append(tid)
|
|
706
|
+
if len(seen) >= limit:
|
|
707
|
+
break
|
|
708
|
+
return seen
|
|
709
|
+
|
|
710
|
+
|
|
659
711
|
def _build_task_group(state: WizardState) -> Prompt:
|
|
660
712
|
sugg = state.task_group_suggestion
|
|
661
713
|
if sugg:
|
|
@@ -670,10 +722,19 @@ def _build_task_group(state: WizardState) -> Prompt:
|
|
|
670
722
|
label=t["label"], options=options,
|
|
671
723
|
echo_template=t["echo_template"],
|
|
672
724
|
)
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
725
|
+
# suggestion 이 없으면 catalog 의 최근 task-group 을 후보로 노출 + 직접 입력
|
|
726
|
+
recent = _suggest_recent_task_groups(state, limit=2)
|
|
727
|
+
t = _p(state.workspace_root, "task_group_no_suggestion")
|
|
728
|
+
recent_prefix = t.get("recent_label_prefix", "")
|
|
729
|
+
options: list[Option] = []
|
|
730
|
+
for tg in recent:
|
|
731
|
+
options.append(_opt(f"{_RECENT_PREFIX}{tg}", f"{recent_prefix}{tg}"))
|
|
732
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
733
|
+
return Prompt(
|
|
734
|
+
step=S_TASK_GROUP, kind="pick",
|
|
735
|
+
label=t["label"], options=options,
|
|
736
|
+
echo_template=t["echo_template"],
|
|
737
|
+
)
|
|
677
738
|
|
|
678
739
|
|
|
679
740
|
def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
|
|
@@ -686,15 +747,27 @@ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
|
|
|
686
747
|
return f"task-group: {state.task_group} (brief)"
|
|
687
748
|
if value == PICK_TYPE_CUSTOM:
|
|
688
749
|
state.task_group_pending_text = True
|
|
689
|
-
t = _p(state.workspace_root, "
|
|
750
|
+
t = _p(state.workspace_root, "task_group_with_suggestion",
|
|
751
|
+
suggestion=state.task_group_suggestion)
|
|
690
752
|
return t["echo_variants"]["free_input"]
|
|
691
753
|
raise WizardError(
|
|
692
754
|
f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
|
|
693
755
|
f"got: {value!r}"
|
|
694
756
|
)
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
757
|
+
# suggestion-없음 분기 (Task 2 신규)
|
|
758
|
+
if value.startswith(_RECENT_PREFIX):
|
|
759
|
+
tg = value[len(_RECENT_PREFIX):]
|
|
760
|
+
state.task_group = _slug_or_die(tg, "task_group")
|
|
761
|
+
state.task_group_pending_text = False
|
|
762
|
+
return f"task-group: {state.task_group} (recent)"
|
|
763
|
+
if value == PICK_TYPE_CUSTOM:
|
|
764
|
+
state.task_group_pending_text = True
|
|
765
|
+
t = _p(state.workspace_root, "task_group_no_suggestion")
|
|
766
|
+
return t["echo_variants"]["free_input"]
|
|
767
|
+
raise WizardError(
|
|
768
|
+
f"unexpected task-group value: {value!r} "
|
|
769
|
+
f"(expected {PICK_USE_SUGGESTED!r}, {PICK_TYPE_CUSTOM!r}, or '{_RECENT_PREFIX}<value>')"
|
|
770
|
+
)
|
|
698
771
|
|
|
699
772
|
|
|
700
773
|
def _build_task_group_text(state: WizardState) -> Prompt:
|
|
@@ -724,10 +797,18 @@ def _build_task_id(state: WizardState) -> Prompt:
|
|
|
724
797
|
label=t["label"], options=options,
|
|
725
798
|
echo_template=t["echo_template"],
|
|
726
799
|
)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
800
|
+
recent = _suggest_recent_task_ids(state, limit=2)
|
|
801
|
+
t = _p(state.workspace_root, "task_id_no_suggestion")
|
|
802
|
+
recent_prefix = t.get("recent_label_prefix", "")
|
|
803
|
+
options: list[Option] = []
|
|
804
|
+
for tid in recent:
|
|
805
|
+
options.append(_opt(f"{_RECENT_PREFIX}{tid}", f"{recent_prefix}{tid}"))
|
|
806
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
807
|
+
return Prompt(
|
|
808
|
+
step=S_TASK_ID, kind="pick",
|
|
809
|
+
label=t["label"], options=options,
|
|
810
|
+
echo_template=t["echo_template"],
|
|
811
|
+
)
|
|
731
812
|
|
|
732
813
|
|
|
733
814
|
def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
|
|
@@ -738,15 +819,28 @@ def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
|
|
|
738
819
|
return f"task-id: {state.task_id} (brief)"
|
|
739
820
|
if value == PICK_TYPE_CUSTOM:
|
|
740
821
|
state.task_id_pending_text = True
|
|
741
|
-
t = _p(state.workspace_root, "
|
|
822
|
+
t = _p(state.workspace_root, "task_id_with_suggestion",
|
|
823
|
+
suggestion=state.task_id_suggestion)
|
|
742
824
|
return t["echo_variants"]["free_input"]
|
|
743
825
|
raise WizardError(
|
|
744
826
|
f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
|
|
745
827
|
f"got: {value!r}"
|
|
746
828
|
)
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
829
|
+
# suggestion-없음 분기
|
|
830
|
+
if value.startswith(_RECENT_PREFIX):
|
|
831
|
+
tid = value[len(_RECENT_PREFIX):]
|
|
832
|
+
state.task_id = _slug_or_die(tid, "task_id")
|
|
833
|
+
state.task_id_pending_text = False
|
|
834
|
+
return f"task-id: {state.task_id} (recent)"
|
|
835
|
+
if value == PICK_TYPE_CUSTOM:
|
|
836
|
+
state.task_id_pending_text = True
|
|
837
|
+
t = _p(state.workspace_root, "task_id_no_suggestion")
|
|
838
|
+
return t["echo_variants"]["free_input"]
|
|
839
|
+
raise WizardError(
|
|
840
|
+
f"unexpected task-id value: {value!r} "
|
|
841
|
+
f"(expected {PICK_USE_SUGGESTED!r}, {PICK_TYPE_CUSTOM!r}, "
|
|
842
|
+
f"or '{_RECENT_PREFIX}<value>')"
|
|
843
|
+
)
|
|
750
844
|
|
|
751
845
|
|
|
752
846
|
def _build_task_id_text(state: WizardState) -> Prompt:
|
|
@@ -820,6 +914,60 @@ def _submit_brief_keep(state: WizardState, value: str) -> Optional[str]:
|
|
|
820
914
|
return None # next prompt is S_BRIEF_PATH
|
|
821
915
|
|
|
822
916
|
|
|
917
|
+
def _suggest_brief_path(state: WizardState) -> tuple[str, str]:
|
|
918
|
+
"""Return (existing_brief_relpath_or_empty, standard_relpath).
|
|
919
|
+
standard_relpath = ".okstra/tasks/<task-group>/<task-id>/brief.md"."""
|
|
920
|
+
existing = state.existing_brief_path or ""
|
|
921
|
+
tg = slugify_task_segment(state.task_group) if state.task_group else ""
|
|
922
|
+
tid = slugify_task_segment(state.task_id) if state.task_id else ""
|
|
923
|
+
if tg and tid:
|
|
924
|
+
standard = str(Path(".okstra") / "tasks" / tg / tid / "brief.md")
|
|
925
|
+
else:
|
|
926
|
+
standard = ""
|
|
927
|
+
return existing, standard
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _build_brief_path_pick(state: WizardState) -> Prompt:
|
|
931
|
+
existing, standard = _suggest_brief_path(state)
|
|
932
|
+
t = _p(state.workspace_root, "brief_path_pick")
|
|
933
|
+
options: list[Option] = []
|
|
934
|
+
if existing:
|
|
935
|
+
options.append(_opt("__existing__",
|
|
936
|
+
t["options"]["__existing__"].format(existing=existing)))
|
|
937
|
+
if standard and standard != existing:
|
|
938
|
+
options.append(_opt("__standard__",
|
|
939
|
+
t["options"]["__standard__"].format(standard=standard)))
|
|
940
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
941
|
+
return Prompt(step=S_BRIEF_PATH_PICK, kind="pick",
|
|
942
|
+
label=t["label"], options=options,
|
|
943
|
+
echo_template=t["echo_template"])
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _submit_brief_path_pick(state: WizardState, value: str) -> Optional[str]:
|
|
947
|
+
existing, standard = _suggest_brief_path(state)
|
|
948
|
+
if value == "__existing__":
|
|
949
|
+
if not existing:
|
|
950
|
+
t = _p(state.workspace_root, "brief_path_pick")
|
|
951
|
+
raise WizardError(t["errors"]["existing_missing"])
|
|
952
|
+
p = _require_file(existing, Path(state.project_root), "task brief")
|
|
953
|
+
state.brief_path = str(p)
|
|
954
|
+
state.brief_path_pending_text = False
|
|
955
|
+
return f"brief: {p}"
|
|
956
|
+
if value == "__standard__":
|
|
957
|
+
p = _require_file(standard, Path(state.project_root), "task brief")
|
|
958
|
+
state.brief_path = str(p)
|
|
959
|
+
state.brief_path_pending_text = False
|
|
960
|
+
return f"brief: {p}"
|
|
961
|
+
if value == PICK_TYPE_CUSTOM:
|
|
962
|
+
state.brief_path_pending_text = True
|
|
963
|
+
state.brief_path = ""
|
|
964
|
+
return None
|
|
965
|
+
raise WizardError(
|
|
966
|
+
f"expected '__existing__', '__standard__', or {PICK_TYPE_CUSTOM!r}, "
|
|
967
|
+
f"got: {value!r}"
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
823
971
|
def _build_brief_path(state: WizardState) -> Prompt:
|
|
824
972
|
t = _p(state.workspace_root, "brief_path")
|
|
825
973
|
return Prompt(
|
|
@@ -832,6 +980,7 @@ def _build_brief_path(state: WizardState) -> Prompt:
|
|
|
832
980
|
def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
|
|
833
981
|
p = _require_file(value, Path(state.project_root), "task brief")
|
|
834
982
|
state.brief_path = str(p)
|
|
983
|
+
state.brief_path_pending_text = False
|
|
835
984
|
# When the user is starting a brand-new task, pull task-group /
|
|
836
985
|
# task-id candidates from the brief frontmatter so the next two
|
|
837
986
|
# prompts can offer them as a one-click pick instead of forcing a
|
|
@@ -888,25 +1037,29 @@ def _submit_base_ref_text(state: WizardState, value: str) -> Optional[str]:
|
|
|
888
1037
|
PICK_USE_DEFAULT = "__use_default__"
|
|
889
1038
|
PICK_OTHER = "__other__"
|
|
890
1039
|
PICK_SKIP = "__skip__"
|
|
891
|
-
|
|
1040
|
+
_REUSE_LAST_TOKEN = "__reuse_last__"
|
|
1041
|
+
_SIBLINGS_TOKEN = "__siblings__"
|
|
1042
|
+
_LATEST_REPORT_TOKEN = "__latest_report__"
|
|
1043
|
+
_PROJECT_DEFAULT_TOKEN = "__project_default__"
|
|
892
1044
|
|
|
893
1045
|
|
|
894
|
-
def
|
|
895
|
-
|
|
896
|
-
|
|
1046
|
+
def _list_implementation_planning_reports(
|
|
1047
|
+
state: WizardState, limit: int = 3
|
|
1048
|
+
) -> list[Path]:
|
|
1049
|
+
"""task 의 implementation-planning runs 디렉토리에서 최신순으로 final-report 경로를 limit 개까지 반환.
|
|
897
1050
|
|
|
898
|
-
|
|
1051
|
+
Each path is relative to ``project_root`` when possible.
|
|
899
1052
|
"""
|
|
900
1053
|
if not state.task_group or not state.task_id or not state.project_root:
|
|
901
|
-
return
|
|
902
|
-
base = (
|
|
1054
|
+
return []
|
|
1055
|
+
base = (tasks_root(state.project_root)
|
|
903
1056
|
/ slugify_task_segment(state.task_group)
|
|
904
1057
|
/ slugify_task_segment(state.task_id)
|
|
905
1058
|
/ "runs" / "implementation-planning")
|
|
906
1059
|
if not base.is_dir():
|
|
907
|
-
return
|
|
1060
|
+
return []
|
|
908
1061
|
pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
|
|
909
|
-
|
|
1062
|
+
found: list[tuple[int, Path]] = []
|
|
910
1063
|
for run_dir in base.iterdir():
|
|
911
1064
|
reports = run_dir / "reports"
|
|
912
1065
|
if not reports.is_dir():
|
|
@@ -915,49 +1068,68 @@ def _latest_implementation_planning_report(state: WizardState) -> Optional[Path]
|
|
|
915
1068
|
m = pat.match(child.name)
|
|
916
1069
|
if not m:
|
|
917
1070
|
continue
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1071
|
+
found.append((int(m.group(1)), child))
|
|
1072
|
+
found.sort(key=lambda x: -x[0])
|
|
1073
|
+
out: list[Path] = []
|
|
1074
|
+
for _, p in found[:limit]:
|
|
1075
|
+
try:
|
|
1076
|
+
out.append(p.relative_to(Path(state.project_root)))
|
|
1077
|
+
except ValueError:
|
|
1078
|
+
out.append(p)
|
|
1079
|
+
return out
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _latest_implementation_planning_report(state: WizardState) -> Optional[Path]:
|
|
1083
|
+
"""task 의 implementation-planning runs 중 가장 최신 final-report 경로 (relpath where possible)."""
|
|
1084
|
+
reports = _list_implementation_planning_reports(state, limit=1)
|
|
1085
|
+
return reports[0] if reports else None
|
|
927
1086
|
|
|
928
1087
|
|
|
929
1088
|
def _build_approved_plan_pick(state: WizardState) -> Prompt:
|
|
930
|
-
|
|
1089
|
+
reports = _list_implementation_planning_reports(state, limit=3)
|
|
1090
|
+
default = reports[0] if reports else None
|
|
931
1091
|
t = _p(state.workspace_root, "approved_plan_pick",
|
|
932
1092
|
default=str(default) if default is not None else "")
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1093
|
+
other_report_label = t["labels"]["other_report"]
|
|
1094
|
+
options: list[Option] = []
|
|
1095
|
+
if default is not None:
|
|
1096
|
+
options.append(_opt(PICK_USE_DEFAULT,
|
|
1097
|
+
t["options"][PICK_USE_DEFAULT].format(default=str(default))))
|
|
1098
|
+
for p in reports[1:]:
|
|
1099
|
+
options.append(_opt(f"{_REPORT_PREFIX}{p}",
|
|
1100
|
+
other_report_label.format(path=str(p))))
|
|
1101
|
+
options.append(_opt(PICK_OTHER, t["options"][PICK_OTHER]))
|
|
937
1102
|
return Prompt(
|
|
938
1103
|
step=S_APPROVED_PLAN_PICK, kind="pick",
|
|
939
|
-
label=t["label"],
|
|
940
|
-
options=options,
|
|
1104
|
+
label=t["label"], options=options,
|
|
941
1105
|
echo_template=t["echo_template"],
|
|
942
1106
|
)
|
|
943
1107
|
|
|
944
1108
|
|
|
945
1109
|
def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1110
|
+
t = _p(state.workspace_root, "approved_plan_pick", default="")
|
|
946
1111
|
if value == PICK_USE_DEFAULT:
|
|
947
1112
|
default = _latest_implementation_planning_report(state)
|
|
948
1113
|
if default is None:
|
|
949
|
-
t = _p(state.workspace_root, "approved_plan_pick", default="")
|
|
950
1114
|
raise WizardError(t["errors"]["default_not_found"])
|
|
951
1115
|
p = _validate_approved_plan(str(default), Path(state.project_root))
|
|
952
1116
|
state.approved_plan_path = str(p)
|
|
953
1117
|
state.approved_plan_pending_text = False
|
|
954
1118
|
return f"approved-plan: {p}"
|
|
1119
|
+
if value.startswith(_REPORT_PREFIX):
|
|
1120
|
+
rel = value[len(_REPORT_PREFIX):]
|
|
1121
|
+
p = _validate_approved_plan(rel, Path(state.project_root))
|
|
1122
|
+
state.approved_plan_path = str(p)
|
|
1123
|
+
state.approved_plan_pending_text = False
|
|
1124
|
+
suffix = t["echo_suffixes"]["other_report"]
|
|
1125
|
+
return f"approved-plan: {p} {suffix}"
|
|
955
1126
|
if value == PICK_OTHER:
|
|
956
1127
|
state.approved_plan_pending_text = True
|
|
957
1128
|
state.approved_plan_path = ""
|
|
958
1129
|
return None
|
|
959
1130
|
raise WizardError(
|
|
960
|
-
f"
|
|
1131
|
+
f"unexpected approved-plan value: {value!r} "
|
|
1132
|
+
f"(expected {PICK_USE_DEFAULT!r}, {PICK_OTHER!r}, or '{_REPORT_PREFIX}<path>')"
|
|
961
1133
|
)
|
|
962
1134
|
|
|
963
1135
|
|
|
@@ -1024,89 +1196,250 @@ def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
|
|
|
1024
1196
|
return f"stage: {answer}"
|
|
1025
1197
|
|
|
1026
1198
|
|
|
1199
|
+
def _suggest_latest_final_report(state: WizardState) -> str:
|
|
1200
|
+
"""task 의 모든 phase runs 디렉토리에서 가장 최근 final-report-*.md 의 relpath 를 반환.
|
|
1201
|
+
|
|
1202
|
+
찾지 못하면 빈 문자열.
|
|
1203
|
+
"""
|
|
1204
|
+
if not state.task_group or not state.task_id or not state.project_root:
|
|
1205
|
+
return ""
|
|
1206
|
+
runs_base = (tasks_root(state.project_root)
|
|
1207
|
+
/ slugify_task_segment(state.task_group)
|
|
1208
|
+
/ slugify_task_segment(state.task_id) / "runs")
|
|
1209
|
+
if not runs_base.is_dir():
|
|
1210
|
+
return ""
|
|
1211
|
+
candidates = [
|
|
1212
|
+
p for p in runs_base.glob("*/*/reports/final-report-*.md")
|
|
1213
|
+
if p.is_file()
|
|
1214
|
+
]
|
|
1215
|
+
if not candidates:
|
|
1216
|
+
return ""
|
|
1217
|
+
|
|
1218
|
+
def _mtime_safe(p: Path) -> float:
|
|
1219
|
+
try:
|
|
1220
|
+
return p.stat().st_mtime
|
|
1221
|
+
except OSError:
|
|
1222
|
+
return -1.0
|
|
1223
|
+
|
|
1224
|
+
best = max(candidates, key=_mtime_safe)
|
|
1225
|
+
try:
|
|
1226
|
+
return str(best.relative_to(Path(state.project_root)))
|
|
1227
|
+
except ValueError:
|
|
1228
|
+
return str(best)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _suggest_last_directive(state: WizardState) -> str:
|
|
1232
|
+
"""같은 task 의 가장 최근 run-inputs-*.json 에서 directive 값을 자동 추출."""
|
|
1233
|
+
if not state.task_group or not state.task_id or not state.project_root:
|
|
1234
|
+
return ""
|
|
1235
|
+
runs_base = (tasks_root(state.project_root)
|
|
1236
|
+
/ slugify_task_segment(state.task_group)
|
|
1237
|
+
/ slugify_task_segment(state.task_id) / "runs")
|
|
1238
|
+
if not runs_base.is_dir():
|
|
1239
|
+
return ""
|
|
1240
|
+
candidates: list[tuple[float, Path]] = []
|
|
1241
|
+
for phase_dir in runs_base.iterdir():
|
|
1242
|
+
if not phase_dir.is_dir():
|
|
1243
|
+
continue
|
|
1244
|
+
for run_dir in phase_dir.iterdir():
|
|
1245
|
+
for inp in run_dir.glob("run-inputs-*.json"):
|
|
1246
|
+
try:
|
|
1247
|
+
candidates.append((inp.stat().st_mtime, inp))
|
|
1248
|
+
except OSError:
|
|
1249
|
+
continue
|
|
1250
|
+
if not candidates:
|
|
1251
|
+
return ""
|
|
1252
|
+
candidates.sort(key=lambda x: -x[0])
|
|
1253
|
+
try:
|
|
1254
|
+
data = json.loads(candidates[0][1].read_text(encoding="utf-8"))
|
|
1255
|
+
except (OSError, json.JSONDecodeError):
|
|
1256
|
+
return ""
|
|
1257
|
+
val = data.get("directive") or ""
|
|
1258
|
+
return val if isinstance(val, str) else ""
|
|
1259
|
+
|
|
1260
|
+
|
|
1027
1261
|
def _build_directive_pick(state: WizardState) -> Prompt:
|
|
1262
|
+
last = _suggest_last_directive(state)
|
|
1028
1263
|
t = _p(state.workspace_root, "directive_pick")
|
|
1264
|
+
options: list[Option] = []
|
|
1265
|
+
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1266
|
+
if last:
|
|
1267
|
+
snippet = last[:60] + ("…" if len(last) > 60 else "")
|
|
1268
|
+
label_template = t["labels"]["reuse_last"]
|
|
1269
|
+
options.append(_opt(_REUSE_LAST_TOKEN, label_template.format(snippet=snippet)))
|
|
1270
|
+
state.last_directive_cached = last
|
|
1271
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1029
1272
|
return Prompt(
|
|
1030
1273
|
step=S_DIRECTIVE_PICK, kind="pick",
|
|
1031
|
-
label=t["label"],
|
|
1032
|
-
options=[_opt(k, v) for k, v in t["options"].items()],
|
|
1274
|
+
label=t["label"], options=options,
|
|
1033
1275
|
echo_template=t["echo_template"],
|
|
1034
1276
|
)
|
|
1035
1277
|
|
|
1036
1278
|
|
|
1037
1279
|
def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1280
|
+
t = _p(state.workspace_root, "directive_pick")
|
|
1038
1281
|
if value == PICK_SKIP:
|
|
1039
1282
|
state.directive = ""
|
|
1040
1283
|
state.directive_pending_text = False
|
|
1041
|
-
return "
|
|
1042
|
-
if value ==
|
|
1284
|
+
return t["echo_suffixes"]["skip"]
|
|
1285
|
+
if value == _REUSE_LAST_TOKEN:
|
|
1286
|
+
state.directive = state.last_directive_cached
|
|
1287
|
+
state.directive_pending_text = False
|
|
1288
|
+
return t["echo_suffixes"]["reuse"].format(value=state.directive)
|
|
1289
|
+
if value == PICK_TYPE_CUSTOM:
|
|
1043
1290
|
state.directive_pending_text = True
|
|
1044
1291
|
return None
|
|
1045
|
-
raise WizardError(
|
|
1292
|
+
raise WizardError(
|
|
1293
|
+
f"unexpected directive value: {value!r} "
|
|
1294
|
+
f"(expected {PICK_SKIP!r}, {_REUSE_LAST_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _suggest_sibling_task_ids(state: WizardState) -> str:
|
|
1299
|
+
"""같은 task-group 의 다른 task-id 를 CSV 로 반환 (현재 task 제외, 빈 결과면 '')."""
|
|
1300
|
+
if not state.project_root or not state.task_group:
|
|
1301
|
+
return ""
|
|
1302
|
+
try:
|
|
1303
|
+
tasks = list_project_tasks(Path(state.project_root))
|
|
1304
|
+
except (OSError, StateError):
|
|
1305
|
+
return ""
|
|
1306
|
+
siblings: list[str] = []
|
|
1307
|
+
for entry in tasks:
|
|
1308
|
+
tg = entry.get("taskGroup") or ""
|
|
1309
|
+
if tg != state.task_group:
|
|
1310
|
+
continue
|
|
1311
|
+
tid = entry.get("taskId") or ""
|
|
1312
|
+
if not tid or tid == state.task_id:
|
|
1313
|
+
continue
|
|
1314
|
+
if tid not in siblings:
|
|
1315
|
+
siblings.append(tid)
|
|
1316
|
+
return ",".join(siblings)
|
|
1046
1317
|
|
|
1047
1318
|
|
|
1048
1319
|
def _build_related_tasks_pick(state: WizardState) -> Prompt:
|
|
1320
|
+
siblings = _suggest_sibling_task_ids(state)
|
|
1049
1321
|
t = _p(state.workspace_root, "related_tasks_pick")
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1322
|
+
options: list[Option] = []
|
|
1323
|
+
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1324
|
+
if siblings:
|
|
1325
|
+
snippet = siblings if len(siblings) <= 60 else siblings[:60] + "…"
|
|
1326
|
+
label_template = t["labels"]["siblings"]
|
|
1327
|
+
options.append(_opt(_SIBLINGS_TOKEN, label_template.format(snippet=snippet)))
|
|
1328
|
+
state.last_siblings_cached = siblings
|
|
1329
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1330
|
+
return Prompt(step=S_RELATED_TASKS_PICK, kind="pick",
|
|
1331
|
+
label=t["label"], options=options,
|
|
1332
|
+
echo_template=t["echo_template"])
|
|
1056
1333
|
|
|
1057
1334
|
|
|
1058
1335
|
def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1336
|
+
t = _p(state.workspace_root, "related_tasks_pick")
|
|
1059
1337
|
if value == PICK_SKIP:
|
|
1060
1338
|
state.related_tasks_raw = ""
|
|
1061
1339
|
state.related_tasks_pending_text = False
|
|
1062
|
-
return "
|
|
1063
|
-
if value ==
|
|
1340
|
+
return t["echo_suffixes"]["skip"]
|
|
1341
|
+
if value == _SIBLINGS_TOKEN:
|
|
1342
|
+
state.related_tasks_raw = state.last_siblings_cached
|
|
1343
|
+
state.related_tasks_pending_text = False
|
|
1344
|
+
return t["echo_suffixes"]["siblings"].format(value=state.related_tasks_raw)
|
|
1345
|
+
if value == PICK_TYPE_CUSTOM:
|
|
1064
1346
|
state.related_tasks_pending_text = True
|
|
1065
1347
|
return None
|
|
1066
|
-
raise WizardError(
|
|
1348
|
+
raise WizardError(
|
|
1349
|
+
f"unexpected related-tasks value: {value!r} "
|
|
1350
|
+
f"(expected {PICK_SKIP!r}, {_SIBLINGS_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1351
|
+
)
|
|
1067
1352
|
|
|
1068
1353
|
|
|
1069
1354
|
def _build_clarification_pick(state: WizardState) -> Prompt:
|
|
1355
|
+
latest = _suggest_latest_final_report(state)
|
|
1070
1356
|
t = _p(state.workspace_root, "clarification_pick")
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1357
|
+
options: list[Option] = []
|
|
1358
|
+
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1359
|
+
if latest:
|
|
1360
|
+
snippet = latest if len(latest) <= 60 else "…" + latest[-60:]
|
|
1361
|
+
label_template = t["labels"]["latest_report"]
|
|
1362
|
+
options.append(_opt(_LATEST_REPORT_TOKEN, label_template.format(snippet=snippet)))
|
|
1363
|
+
state.last_final_report_cached = latest
|
|
1364
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1365
|
+
return Prompt(step=S_CLARIFICATION_PICK, kind="pick",
|
|
1366
|
+
label=t["label"], options=options,
|
|
1367
|
+
echo_template=t["echo_template"])
|
|
1077
1368
|
|
|
1078
1369
|
|
|
1079
1370
|
def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1371
|
+
t = _p(state.workspace_root, "clarification_pick")
|
|
1080
1372
|
if value == PICK_SKIP:
|
|
1081
1373
|
state.clarification_response_path = ""
|
|
1082
1374
|
state.clarification_pending_text = False
|
|
1083
|
-
return "
|
|
1084
|
-
if value ==
|
|
1375
|
+
return t["echo_suffixes"]["skip"]
|
|
1376
|
+
if value == _LATEST_REPORT_TOKEN:
|
|
1377
|
+
state.clarification_response_path = state.last_final_report_cached
|
|
1378
|
+
state.clarification_pending_text = False
|
|
1379
|
+
return t["echo_suffixes"]["latest_report"].format(value=state.clarification_response_path)
|
|
1380
|
+
if value == PICK_TYPE_CUSTOM:
|
|
1085
1381
|
state.clarification_pending_text = True
|
|
1086
1382
|
return None
|
|
1087
|
-
raise WizardError(
|
|
1383
|
+
raise WizardError(
|
|
1384
|
+
f"unexpected clarification value: {value!r} "
|
|
1385
|
+
f"(expected {PICK_SKIP!r}, {_LATEST_REPORT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _suggest_project_pr_template(state: WizardState) -> str:
|
|
1390
|
+
"""project.json 의 prTemplatePath 필드를 읽어 경로 문자열로 반환.
|
|
1391
|
+
|
|
1392
|
+
없거나 읽기 실패 시 빈 문자열.
|
|
1393
|
+
"""
|
|
1394
|
+
if not state.project_root:
|
|
1395
|
+
return ""
|
|
1396
|
+
project_json = project_json_path(Path(state.project_root))
|
|
1397
|
+
if not project_json.is_file():
|
|
1398
|
+
return ""
|
|
1399
|
+
try:
|
|
1400
|
+
data = json.loads(project_json.read_text(encoding="utf-8"))
|
|
1401
|
+
except (OSError, json.JSONDecodeError):
|
|
1402
|
+
return ""
|
|
1403
|
+
val = data.get("prTemplatePath") or ""
|
|
1404
|
+
return val if isinstance(val, str) else ""
|
|
1088
1405
|
|
|
1089
1406
|
|
|
1090
1407
|
def _build_pr_template_pick(state: WizardState) -> Prompt:
|
|
1408
|
+
project_default = _suggest_project_pr_template(state)
|
|
1091
1409
|
t = _p(state.workspace_root, "pr_template_pick")
|
|
1410
|
+
options: list[Option] = []
|
|
1411
|
+
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1412
|
+
if project_default:
|
|
1413
|
+
snippet = project_default if len(project_default) <= 60 else "…" + project_default[-60:]
|
|
1414
|
+
label_template = t["labels"]["project_default"]
|
|
1415
|
+
options.append(_opt(_PROJECT_DEFAULT_TOKEN, label_template.format(snippet=snippet)))
|
|
1416
|
+
state.last_pr_template_cached = project_default
|
|
1417
|
+
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1092
1418
|
return Prompt(
|
|
1093
1419
|
step=S_PR_TEMPLATE_PICK, kind="pick",
|
|
1094
|
-
label=t["label"],
|
|
1095
|
-
options=[_opt(k, v) for k, v in t["options"].items()],
|
|
1420
|
+
label=t["label"], options=options,
|
|
1096
1421
|
echo_template=t["echo_template"],
|
|
1097
1422
|
)
|
|
1098
1423
|
|
|
1099
1424
|
|
|
1100
1425
|
def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1426
|
+
t = _p(state.workspace_root, "pr_template_pick")
|
|
1101
1427
|
if value == PICK_SKIP:
|
|
1102
1428
|
state.pr_template_path = ""
|
|
1103
1429
|
state.pr_template_scope = ""
|
|
1104
1430
|
state.pr_template_pending_text = False
|
|
1105
|
-
return "
|
|
1106
|
-
if value ==
|
|
1431
|
+
return t["echo_suffixes"]["skip"]
|
|
1432
|
+
if value == _PROJECT_DEFAULT_TOKEN:
|
|
1433
|
+
state.pr_template_path = state.last_pr_template_cached
|
|
1434
|
+
state.pr_template_pending_text = False
|
|
1435
|
+
return t["echo_suffixes"]["project_default"].format(value=state.pr_template_path)
|
|
1436
|
+
if value == PICK_TYPE_CUSTOM:
|
|
1107
1437
|
state.pr_template_pending_text = True
|
|
1108
1438
|
return None
|
|
1109
|
-
raise WizardError(
|
|
1439
|
+
raise WizardError(
|
|
1440
|
+
f"unexpected pr-template value: {value!r} "
|
|
1441
|
+
f"(expected {PICK_SKIP!r}, {_PROJECT_DEFAULT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1442
|
+
)
|
|
1110
1443
|
|
|
1111
1444
|
|
|
1112
1445
|
def _build_executor(state: WizardState) -> Prompt:
|
|
@@ -1419,22 +1752,24 @@ STEPS: list[Step] = [
|
|
|
1419
1752
|
"profile_optional_workers",
|
|
1420
1753
|
"task_group_suggestion", "task_id_suggestion",
|
|
1421
1754
|
"task_group_pending_text", "task_id_pending_text")),
|
|
1422
|
-
Step(
|
|
1755
|
+
Step(S_BRIEF_PATH_PICK,
|
|
1423
1756
|
applies=lambda s: (
|
|
1424
1757
|
not s.brief_path
|
|
1758
|
+
and not s.brief_path_pending_text
|
|
1759
|
+
and S_BRIEF_PATH_PICK not in s.answered
|
|
1425
1760
|
and (
|
|
1426
|
-
# new-task flow: collect brief FIRST so task-group /
|
|
1427
|
-
# task-id can be offered as one-click picks from the
|
|
1428
|
-
# brief's frontmatter.
|
|
1429
1761
|
(s.is_new_task is True)
|
|
1430
|
-
# existing-task flow: brief comes after task-type and
|
|
1431
|
-
# the optional brief-keep step (unchanged behavior).
|
|
1432
1762
|
or (s.is_new_task is False
|
|
1433
1763
|
and S_TASK_TYPE in s.answered
|
|
1434
1764
|
and (s.keep_existing_brief is False
|
|
1435
1765
|
or not s.existing_brief_path))
|
|
1436
1766
|
)
|
|
1437
1767
|
),
|
|
1768
|
+
build=_build_brief_path_pick, submit=_submit_brief_path_pick,
|
|
1769
|
+
owns=("brief_path_pending_text",)),
|
|
1770
|
+
Step(S_BRIEF_PATH,
|
|
1771
|
+
applies=lambda s: s.brief_path_pending_text
|
|
1772
|
+
and S_BRIEF_PATH not in s.answered,
|
|
1438
1773
|
build=_build_brief_path, submit=_submit_brief_path,
|
|
1439
1774
|
owns=("brief_path", "task_group_suggestion", "task_id_suggestion")),
|
|
1440
1775
|
Step(S_TASK_GROUP,
|