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.
Files changed (64) hide show
  1. package/README.kr.md +6 -6
  2. package/README.md +6 -6
  3. package/bin/okstra +4 -2
  4. package/docs/kr/architecture.md +29 -29
  5. package/docs/kr/cli.md +7 -6
  6. package/docs/pr-template-usage.md +2 -2
  7. package/docs/project-structure-overview.md +4 -4
  8. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +159 -0
  9. package/docs/superpowers/plans/2026-05-26-wizard-3-option-picker.md +860 -0
  10. package/docs/task-process/common-flow.md +2 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/SKILL.md +2 -2
  14. package/runtime/agents/workers/claude-worker.md +1 -1
  15. package/runtime/prompts/profiles/_common-contract.md +6 -6
  16. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  17. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  18. package/runtime/prompts/profiles/final-verification.md +1 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +5 -5
  20. package/runtime/prompts/profiles/release-handoff.md +1 -1
  21. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  22. package/runtime/prompts/wizard/prompts.ko.json +80 -6
  23. package/runtime/python/lib/okstra/interactive.sh +2 -2
  24. package/runtime/python/lib/okstra/project-resolver.sh +1 -1
  25. package/runtime/python/lib/okstra/usage.sh +5 -5
  26. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +1 -1
  27. package/runtime/python/okstra_ctl/backfill.py +5 -3
  28. package/runtime/python/okstra_ctl/migrate.py +408 -0
  29. package/runtime/python/okstra_ctl/paths.py +12 -3
  30. package/runtime/python/okstra_ctl/pr_template.py +4 -2
  31. package/runtime/python/okstra_ctl/render.py +8 -6
  32. package/runtime/python/okstra_ctl/run.py +5 -5
  33. package/runtime/python/okstra_ctl/seeding.py +12 -6
  34. package/runtime/python/okstra_ctl/sequence.py +3 -1
  35. package/runtime/python/okstra_ctl/wizard.py +412 -77
  36. package/runtime/python/okstra_ctl/worktree.py +8 -6
  37. package/runtime/python/okstra_project/__init__.py +35 -5
  38. package/runtime/python/okstra_project/dirs.py +67 -0
  39. package/runtime/python/okstra_project/resolver.py +8 -8
  40. package/runtime/python/okstra_project/state.py +11 -9
  41. package/runtime/python/okstra_token_usage/collect.py +3 -1
  42. package/runtime/skills/okstra-brief/SKILL.md +30 -30
  43. package/runtime/skills/okstra-context-loader/SKILL.md +7 -7
  44. package/runtime/skills/okstra-inspect/SKILL.md +25 -25
  45. package/runtime/skills/okstra-run/templates/pr-body.template.md +1 -1
  46. package/runtime/skills/okstra-schedule/SKILL.md +7 -7
  47. package/runtime/skills/okstra-setup/SKILL.md +8 -8
  48. package/runtime/templates/okstra.CLAUDE.md +4 -4
  49. package/runtime/templates/reports/brief.template.md +5 -5
  50. package/runtime/templates/reports/task-brief.template.md +1 -1
  51. package/runtime/validators/lib/fixtures.sh +2 -2
  52. package/runtime/validators/lib/paths.sh +9 -3
  53. package/runtime/validators/validate-brief.py +2 -2
  54. package/runtime/validators/validate-brief.sh +1 -1
  55. package/runtime/validators/validate-run.py +3 -1
  56. package/runtime/validators/validate-workflow.sh +2 -2
  57. package/src/check-project.mjs +3 -3
  58. package/src/config.mjs +6 -5
  59. package/src/install.mjs +5 -5
  60. package/src/migrate.mjs +163 -0
  61. package/src/okstra-dirs.mjs +37 -0
  62. package/src/paths.mjs +17 -0
  63. package/src/setup.mjs +8 -4
  64. 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
- t = _p(state.workspace_root, "task_group")
674
- return Prompt(step=S_TASK_GROUP, kind="text",
675
- label=t["label"],
676
- echo_template=t["echo_template"])
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, "task_group")
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
- state.task_group = _slug_or_die(value, "task_group")
696
- state.task_group_pending_text = False
697
- return f"task-group: {state.task_group}"
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
- t = _p(state.workspace_root, "task_id")
728
- return Prompt(step=S_TASK_ID, kind="text",
729
- label=t["label"],
730
- echo_template=t["echo_template"])
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, "task_id")
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
- state.task_id = _slug_or_die(value, "task_id")
748
- state.task_id_pending_text = False
749
- return f"task-id: {state.task_id}"
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
- PICK_ENTER = "__enter__"
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 _latest_implementation_planning_report(state: WizardState) -> Optional[Path]:
895
- """Find the latest ``final-report-implementation-planning-<seq>.md`` under
896
- the current task's runs directory.
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
- Returns the path relative to ``project_root`` if found, otherwise ``None``.
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 None
902
- base = (Path(state.project_root) / ".project-docs" / "okstra" / "tasks"
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 None
1060
+ return []
908
1061
  pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
909
- best: tuple[int, Path] | None = None
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
- n = int(m.group(1))
919
- if best is None or n > best[0]:
920
- best = (n, child)
921
- if best is None:
922
- return None
923
- try:
924
- return best[1].relative_to(Path(state.project_root))
925
- except ValueError:
926
- return best[1]
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
- default = _latest_implementation_planning_report(state)
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
- options = [
934
- _opt(k, v.format(default=str(default) if default is not None else ""))
935
- for k, v in t["options"].items()
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"expected '{PICK_USE_DEFAULT}' or '{PICK_OTHER}', got: {value!r}"
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 "directive: (none)"
1042
- if value == PICK_ENTER:
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(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
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
- return Prompt(
1051
- step=S_RELATED_TASKS_PICK, kind="pick",
1052
- label=t["label"],
1053
- options=[_opt(k, v) for k, v in t["options"].items()],
1054
- echo_template=t["echo_template"],
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 "related-tasks: (none)"
1063
- if value == PICK_ENTER:
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(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
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
- return Prompt(
1072
- step=S_CLARIFICATION_PICK, kind="pick",
1073
- label=t["label"],
1074
- options=[_opt(k, v) for k, v in t["options"].items()],
1075
- echo_template=t["echo_template"],
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 "clarification: (none)"
1084
- if value == PICK_ENTER:
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(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
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 "pr-template: (auto-resolve)"
1106
- if value == PICK_ENTER:
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(f"expected '{PICK_SKIP}' or '{PICK_ENTER}', got: {value!r}")
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(S_BRIEF_PATH,
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,