okstra 0.189.4 → 0.190.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 (151) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/cli.md +10 -8
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +7 -4
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +1 -0
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation.md +2 -0
  50. package/runtime/prompts/profiles/release-handoff.md +4 -3
  51. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  52. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  55. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  58. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  60. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  62. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  64. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  66. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  67. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  68. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  72. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  73. package/runtime/python/okstra_ctl/cmux.py +34 -13
  74. package/runtime/python/okstra_ctl/consumers.py +30 -7
  75. package/runtime/python/okstra_ctl/convergence.py +110 -21
  76. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  77. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  78. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  79. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  80. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  81. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  82. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  83. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  84. package/runtime/python/okstra_ctl/group_context.py +475 -10
  85. package/runtime/python/okstra_ctl/handoff.py +109 -32
  86. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  87. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  88. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  89. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  90. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  91. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  92. package/runtime/python/okstra_ctl/recap.py +179 -17
  93. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  94. package/runtime/python/okstra_ctl/render.py +10 -1
  95. package/runtime/python/okstra_ctl/report_assembly.py +7 -6
  96. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  97. package/runtime/python/okstra_ctl/report_html/render.py +15 -4
  98. package/runtime/python/okstra_ctl/run.py +48 -14
  99. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  100. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  101. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  102. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  103. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  104. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  105. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  106. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  107. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  108. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  109. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  110. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  111. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  112. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  113. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  114. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  115. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  116. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  117. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  118. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  119. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  120. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  121. package/runtime/python/okstra_ctl/workflow.py +2 -2
  122. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  123. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  124. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  125. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  126. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  127. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  128. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  129. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  130. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  131. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  132. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  133. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  134. package/runtime/skills/okstra-run/SKILL.md +17 -7
  135. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  136. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  137. package/runtime/templates/reports/brief.template.md +15 -7
  138. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  139. package/runtime/templates/reports/group-context.template.md +7 -0
  140. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  141. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  142. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  143. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  144. package/runtime/templates/reports/schedule.template.md +13 -4
  145. package/runtime/templates/reports/task-brief.template.md +1 -1
  146. package/runtime/templates/reverify-output-contract.md +5 -0
  147. package/runtime/templates/worker-error-contract.md +2 -0
  148. package/runtime/templates/worker-prompt-preamble.md +1 -1
  149. package/runtime/validators/validate-run.py +81 -22
  150. package/runtime/validators/validate_session_conformance.py +102 -2
  151. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -0,0 +1,828 @@
1
+ """identity 단계 — task pick/group/id/type, brief, base ref 의 build/submit."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+ from collections.abc import Mapping
7
+ from typing import Any, Optional
8
+
9
+ from okstra_ctl import next_phase
10
+ from okstra_ctl.ids import slugify_task_segment
11
+ from okstra_ctl.group_context import (
12
+ QUEUE_DONE,
13
+ QUEUE_IN_PROGRESS,
14
+ QUEUE_NOT_STARTED,
15
+ group_context_file,
16
+ group_queue,
17
+ is_group_context_file,
18
+ parse_memory_entries,
19
+ split_memory_region,
20
+ )
21
+ from okstra_project.state import (
22
+ StateError,
23
+ list_project_tasks,
24
+ read_latest_task,
25
+ read_task_manifest,
26
+ find_task_root,
27
+ )
28
+
29
+ from .ids import (
30
+ BASE_REF_FREE_INPUT_TOKEN,
31
+ CANONICAL_BASE_REFS,
32
+ PICK_TYPE_CUSTOM,
33
+ PICK_USE_SUGGESTED,
34
+ S_BASE_REF_PICK,
35
+ S_BASE_REF_TEXT,
36
+ S_BRIEF_CARRY,
37
+ S_BRIEF_KEEP,
38
+ S_BRIEF_PATH,
39
+ S_BRIEF_PATH_PICK,
40
+ S_TASK_GROUP,
41
+ S_TASK_GROUP_TEXT,
42
+ S_TASK_ID,
43
+ S_TASK_ID_TEXT,
44
+ S_TASK_PICK,
45
+ S_TASK_TYPE,
46
+ S_TASK_TYPE_TEXT,
47
+ TASK_PICK_NEW_TOKEN,
48
+ TASK_TYPES,
49
+ TASK_TYPE_VALUES,
50
+ _BRIEF_ENTRY_TASK_TYPES,
51
+ _BRIEF_PREFIX,
52
+ _RECENT_PREFIX,
53
+ _RECOMMENDATION_CAP,
54
+ )
55
+ from .state import Option, Prompt, WizardError, WizardState, _slug_or_die
56
+ from .prompts import _opt, _p, _recommendation_budget, _static_options
57
+ from .sources import (
58
+ _accept_brief_path,
59
+ _brief_recommended_phase,
60
+ _brief_suggestions,
61
+ _existing_task_brief,
62
+ _existing_task_manifest,
63
+ _file_recency,
64
+ _git_main_worktree,
65
+ _latest_revision_requested_analysis_type,
66
+ _load_profile_optional_workers,
67
+ _load_profile_workers,
68
+ _parse_iso_timestamp,
69
+ _phase_after,
70
+ _project_relative_path,
71
+ _recent_task_types,
72
+ _recently_used_brief_times,
73
+ _report_data_for_pointer,
74
+ _require_file,
75
+ _resolve_reuse_worktree,
76
+ )
77
+
78
+
79
+ def _validate_base_ref(ref: str, project_root: Path) -> str:
80
+ ref = (ref or "").strip()
81
+ if not ref:
82
+ raise WizardError("base ref is empty")
83
+ main_wt = _git_main_worktree(project_root)
84
+ rc = subprocess.call(
85
+ ["git", "-C", str(main_wt), "rev-parse",
86
+ "--verify", "--quiet", f"{ref}^{{commit}}"],
87
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
88
+ )
89
+ if rc != 0:
90
+ raise WizardError(f"base ref not found in repository: {ref}")
91
+ return ref
92
+
93
+
94
+ # --- builders ---
95
+
96
+ def _contract_outcome_suffix(entry: dict) -> str:
97
+ outcome = entry.get("phaseOutcome")
98
+ implementation = outcome.get("implementation") if isinstance(outcome, dict) else {}
99
+ if not isinstance(implementation, dict):
100
+ return ""
101
+ if implementation.get("state") != "completed":
102
+ return ""
103
+ if entry.get("currentStatus") == "contract-violated":
104
+ return " · implementation complete; contract warnings"
105
+ return ""
106
+
107
+
108
+ def _next_phase_cell(raw: Any) -> str:
109
+ """task picker 한 줄에 실을 다음 phase 표기.
110
+
111
+ `phase` 만 싣고 `status` 를 버리면 "아직 여기 있다" 가 "다음으로 갈 수 있다"
112
+ 로 읽힌다. prepare 는 끝나지 않은 run 의 포인터를 `ready` → `pending` 으로
113
+ 내리면서 `phase` 는 남겨두므로(`render._derive_next_recommended_phase`),
114
+ 준비만 된 implementation 태스크의 포인터가
115
+ `{"phase": "final-verification", "status": "pending"}` 이다. status 를 안
116
+ 보면 그 줄이 `next: final-verification` 으로 찍혀 착수 가능한 것처럼 읽힌다.
117
+
118
+ 그래서 `ready` 가 아닌 status 는 괄호로 함께 싣는다. `okstra-inspect` 의
119
+ status 표는 같은 정보를 `*` 마커로 싣지만(`facets/status.md`), 그쪽은 표
120
+ 아래 범례 한 줄을 붙일 수 있는 자리다. picker 옵션은 라벨 한 줄이 전부라
121
+ 범례를 둘 데가 없으므로 status 이름을 그대로 적는다. phase 를 `--` 로
122
+ 지우지 않는 이유는 그 이름이 "어디로 가는 태스크인가" 라는 정보를 여전히
123
+ 나르기 때문이다 — 지우는 것은 착수 가능성만이 아니라 목적지까지 지운다.
124
+ """
125
+ pointer = next_phase.promote(raw)
126
+ cell = pointer["phase"] or "--"
127
+ if pointer["status"] == next_phase.STATUS_READY:
128
+ return cell
129
+ return f"{cell} ({pointer['status']})"
130
+
131
+
132
+ def _build_task_pick(state: WizardState) -> Prompt:
133
+ t = _p(state.workspace_root, "task_pick")
134
+ project_root = Path(state.project_root)
135
+ tasks = list_project_tasks(project_root)
136
+ latest = read_latest_task(project_root) or {}
137
+ latest_key = latest.get("taskKey") or ""
138
+ latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
139
+ remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"]
140
+ options: list[Option] = []
141
+ for entry in remaining[:_recommendation_budget(state)]:
142
+ key = entry.get("taskKey") or ""
143
+ ttype = entry.get("taskType") or ""
144
+ # catalog entries are flat (render_task_catalog_discovery) — there is
145
+ # no nested "workflow" object here, unlike task-manifest.json.
146
+ phase = entry.get("currentPhase") or ttype
147
+ nxt = _next_phase_cell(entry.get("nextRecommendedPhase"))
148
+ suffix = latest_suffix if key == latest_key else ""
149
+ label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
150
+ options.append(_opt(value=key, label=label, recommended=True))
151
+ for value, label in _static_options(t):
152
+ options.append(_opt(value=value, label=label))
153
+ return Prompt(step=S_TASK_PICK, kind="pick",
154
+ label=t["label"], options=options,
155
+ echo_template=t["echo_template"])
156
+
157
+
158
+ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
159
+ value = value.strip()
160
+ if value == TASK_PICK_NEW_TOKEN:
161
+ state.is_new_task = True
162
+ state.task_group = ""
163
+ state.task_id = ""
164
+ state.existing_brief_path = ""
165
+ state.task_type = ""
166
+ return "task: (brand-new)"
167
+ # parse "<project-id>:<task-group>:<task-id>"
168
+ parts = value.split(":")
169
+ if len(parts) != 3 or not all(parts):
170
+ raise WizardError(
171
+ f"invalid task-key: {value!r} (expected project-id:task-group:task-id)"
172
+ )
173
+ pid, tg, tid = parts
174
+ if pid != state.project_id:
175
+ raise WizardError(
176
+ f"task-key project-id {pid!r} does not match current project {state.project_id!r}"
177
+ )
178
+ state.is_new_task = False
179
+ state.task_group = tg
180
+ state.task_id = tid
181
+ state.existing_brief_path = _existing_task_brief(
182
+ Path(state.project_root), value
183
+ )
184
+ # task_type seeded from manifest's nextRecommendedPhase as the recommended default
185
+ root = find_task_root(Path(state.project_root), value)
186
+ manifest = (read_task_manifest(root) or {}) if root else {}
187
+ state.task_type = _recommended_task_type(state, manifest)
188
+ return f"task: {value}"
189
+
190
+
191
+ def _suggest_recent_task_groups(
192
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
193
+ ) -> list[str]:
194
+ """최근 task/brief 활동에서 task-group 후보를 limit 개까지 반환."""
195
+ if not state.project_root:
196
+ return []
197
+ project_root = Path(state.project_root)
198
+ scores: dict[str, float] = {}
199
+ orders: dict[str, int] = {}
200
+ try:
201
+ tasks = list_project_tasks(project_root)
202
+ except (OSError, StateError):
203
+ tasks = []
204
+ for index, entry in enumerate(tasks):
205
+ tg = entry.get("taskGroup") or ""
206
+ if not tg:
207
+ continue
208
+ scores[tg] = max(
209
+ scores.get(tg, 0.0),
210
+ _parse_iso_timestamp(entry.get("updatedAt")),
211
+ )
212
+ orders[tg] = min(orders.get(tg, index), index)
213
+
214
+ brief_root = project_root / ".okstra" / "briefs"
215
+ if brief_root.is_dir():
216
+ next_order = len(orders)
217
+ for group_dir in brief_root.iterdir():
218
+ if not group_dir.is_dir():
219
+ continue
220
+ latest_brief = max(
221
+ (
222
+ _file_recency(path)
223
+ for path in group_dir.rglob("*.md")
224
+ if path.is_file()
225
+ ),
226
+ default=0.0,
227
+ )
228
+ if latest_brief <= 0.0:
229
+ continue
230
+ group = group_dir.name
231
+ scores[group] = max(scores.get(group, 0.0), latest_brief)
232
+ orders.setdefault(group, next_order)
233
+ next_order += 1
234
+
235
+ return sorted(
236
+ scores,
237
+ key=lambda group: (-scores[group], orders[group], group),
238
+ )[:limit]
239
+
240
+
241
+ def _suggest_recent_task_ids(
242
+ state: WizardState, limit: int = _RECOMMENDATION_CAP
243
+ ) -> list[str]:
244
+ """현재 task_group 내의 최근 task-id 후보를 limit 개까지 반환."""
245
+ if not state.project_root or not state.task_group:
246
+ return []
247
+ try:
248
+ tasks = list_project_tasks(Path(state.project_root))
249
+ except (OSError, StateError):
250
+ return []
251
+ seen: list[str] = []
252
+ for entry in tasks:
253
+ tg = entry.get("taskGroup") or ""
254
+ if tg != state.task_group:
255
+ continue
256
+ tid = entry.get("taskId") or ""
257
+ if tid and tid not in seen:
258
+ seen.append(tid)
259
+ if len(seen) >= limit:
260
+ break
261
+ return seen
262
+
263
+
264
+ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
265
+ """Return the selected task-group's okstra-brief-gen outputs in start order.
266
+
267
+ ``okstra-brief-gen`` writes to ``.okstra/briefs/<task-group>/**/*.md``. The
268
+ wizard exposes those paths after task-group selection so users can pick a
269
+ generated brief instead of typing its path. The order is the group's start
270
+ order (`group_context.group_queue`: brief ordinal) with tasks not yet started first, then the ones in
271
+ progress, then the finished ones — the queue the group's `group-context.md`
272
+ memory region shows. Briefs the queue does not know (no `type: brief`
273
+ frontmatter) follow by recency. Paths are project-relative.
274
+ """
275
+ if not state.project_root or not state.task_group:
276
+ return []
277
+ project_root = Path(state.project_root)
278
+ root = project_root / ".okstra" / "briefs" / state.task_group
279
+ if not root.is_dir():
280
+ return []
281
+ used_at_by_relpath = _recently_used_brief_times(state)
282
+ candidates: list[tuple[float, str]] = []
283
+ for path in root.rglob("*.md"):
284
+ if not path.is_file() or is_group_context_file(path):
285
+ continue
286
+ tg_suggestion, _ = _brief_suggestions(path)
287
+ if tg_suggestion:
288
+ try:
289
+ if _slug_or_die(tg_suggestion, "task_group") != state.task_group:
290
+ continue
291
+ except WizardError:
292
+ continue
293
+ relpath = _project_relative_path(path, project_root)
294
+ recency = max(_file_recency(path), used_at_by_relpath.get(relpath, 0.0))
295
+ candidates.append((recency, relpath))
296
+ candidates.sort(key=lambda item: (-item[0], item[1]))
297
+ by_recency = [rel for _, rel in candidates]
298
+ queue = _group_start_order(project_root, state.task_group)
299
+ rank = {QUEUE_NOT_STARTED: 0, QUEUE_IN_PROGRESS: 1, QUEUE_DONE: 2}
300
+ ordered = [
301
+ row.brief for row in sorted(
302
+ (row for row in queue if row.brief in by_recency),
303
+ key=lambda row: rank.get(row.status, 3),
304
+ )
305
+ ]
306
+ ordered.extend(rel for rel in by_recency if rel not in ordered)
307
+ return ordered[:limit]
308
+
309
+
310
+ def _group_start_order(project_root: Path, task_group: str) -> list:
311
+ """`group-context.md` 의 okstra 영역에 기록된 항목으로 상태를 매긴 시작 순서."""
312
+ target = group_context_file(project_root, task_group)
313
+ region = ""
314
+ if target.is_file():
315
+ _, region, _ = split_memory_region(target.read_text(encoding="utf-8"))
316
+ return group_queue(project_root, task_group, parse_memory_entries(region))
317
+
318
+
319
+ def _build_task_group(state: WizardState) -> Prompt:
320
+ sugg = state.task_group_suggestion
321
+ if sugg:
322
+ t = _p(state.workspace_root, "task_group_with_suggestion",
323
+ suggestion=sugg)
324
+ options = [
325
+ _opt(k, v.format(suggestion=sugg))
326
+ for k, v in t["options"].items()
327
+ ]
328
+ return Prompt(
329
+ step=S_TASK_GROUP, kind="pick",
330
+ label=t["label"], options=options,
331
+ echo_template=t["echo_template"],
332
+ )
333
+ # suggestion 이 없으면 catalog 의 최근 task-group 을 후보로 노출 + 직접 입력
334
+ recent = _suggest_recent_task_groups(state, _recommendation_budget(state))
335
+ t = _p(state.workspace_root, "task_group_no_suggestion")
336
+ recent_prefix = t.get("recent_label_prefix", "")
337
+ options: list[Option] = []
338
+ for tg in recent:
339
+ options.append(_opt(f"{_RECENT_PREFIX}{tg}", f"{recent_prefix}{tg}",
340
+ recommended=True))
341
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
342
+ return Prompt(
343
+ step=S_TASK_GROUP, kind="pick",
344
+ label=t["label"], options=options,
345
+ echo_template=t["echo_template"],
346
+ )
347
+
348
+
349
+ def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
350
+ if state.task_group_suggestion:
351
+ if value == PICK_USE_SUGGESTED:
352
+ state.task_group = _slug_or_die(
353
+ state.task_group_suggestion, "task_group"
354
+ )
355
+ state.task_group_pending_text = False
356
+ return f"task-group: {state.task_group} (brief)"
357
+ if value == PICK_TYPE_CUSTOM:
358
+ state.task_group_pending_text = True
359
+ t = _p(state.workspace_root, "task_group_with_suggestion",
360
+ suggestion=state.task_group_suggestion)
361
+ return t["echo_variants"]["free_input"]
362
+ raise WizardError(
363
+ f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
364
+ f"got: {value!r}"
365
+ )
366
+ # suggestion-없음 분기 (Task 2 신규)
367
+ if value.startswith(_RECENT_PREFIX):
368
+ tg = value[len(_RECENT_PREFIX):]
369
+ state.task_group = _slug_or_die(tg, "task_group")
370
+ state.task_group_pending_text = False
371
+ return f"task-group: {state.task_group} (recent)"
372
+ if value == PICK_TYPE_CUSTOM:
373
+ state.task_group_pending_text = True
374
+ t = _p(state.workspace_root, "task_group_no_suggestion")
375
+ return t["echo_variants"]["free_input"]
376
+ raise WizardError(
377
+ f"unexpected task-group value: {value!r} "
378
+ f"(expected {PICK_USE_SUGGESTED!r}, {PICK_TYPE_CUSTOM!r}, or '{_RECENT_PREFIX}<value>')"
379
+ )
380
+
381
+
382
+ def _build_task_group_text(state: WizardState) -> Prompt:
383
+ t = _p(state.workspace_root, "task_group_text")
384
+ return Prompt(step=S_TASK_GROUP_TEXT, kind="text",
385
+ label=t["label"],
386
+ echo_template=t["echo_template"])
387
+
388
+
389
+ def _submit_task_group_text(state: WizardState, value: str) -> Optional[str]:
390
+ state.task_group = _slug_or_die(value, "task_group")
391
+ state.task_group_pending_text = False
392
+ return f"task-group: {state.task_group}"
393
+
394
+
395
+ def _build_task_id(state: WizardState) -> Prompt:
396
+ sugg = state.task_id_suggestion
397
+ if sugg:
398
+ t = _p(state.workspace_root, "task_id_with_suggestion",
399
+ suggestion=sugg)
400
+ options = [
401
+ _opt(k, v.format(suggestion=sugg))
402
+ for k, v in t["options"].items()
403
+ ]
404
+ return Prompt(
405
+ step=S_TASK_ID, kind="pick",
406
+ label=t["label"], options=options,
407
+ echo_template=t["echo_template"],
408
+ )
409
+ recent = _suggest_recent_task_ids(state, _recommendation_budget(state))
410
+ t = _p(state.workspace_root, "task_id_no_suggestion")
411
+ recent_prefix = t.get("recent_label_prefix", "")
412
+ options: list[Option] = []
413
+ for tid in recent:
414
+ options.append(_opt(f"{_RECENT_PREFIX}{tid}", f"{recent_prefix}{tid}",
415
+ recommended=True))
416
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
417
+ return Prompt(
418
+ step=S_TASK_ID, kind="pick",
419
+ label=t["label"], options=options,
420
+ echo_template=t["echo_template"],
421
+ )
422
+
423
+
424
+ def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
425
+ if state.task_id_suggestion:
426
+ if value == PICK_USE_SUGGESTED:
427
+ state.task_id = _slug_or_die(state.task_id_suggestion, "task_id")
428
+ state.task_id_pending_text = False
429
+ return f"task-id: {state.task_id} (brief)"
430
+ if value == PICK_TYPE_CUSTOM:
431
+ state.task_id_pending_text = True
432
+ t = _p(state.workspace_root, "task_id_with_suggestion",
433
+ suggestion=state.task_id_suggestion)
434
+ return t["echo_variants"]["free_input"]
435
+ raise WizardError(
436
+ f"expected {PICK_USE_SUGGESTED!r} or {PICK_TYPE_CUSTOM!r}, "
437
+ f"got: {value!r}"
438
+ )
439
+ # suggestion-없음 분기
440
+ if value.startswith(_RECENT_PREFIX):
441
+ tid = value[len(_RECENT_PREFIX):]
442
+ state.task_id = _slug_or_die(tid, "task_id")
443
+ state.task_id_pending_text = False
444
+ return f"task-id: {state.task_id} (recent)"
445
+ if value == PICK_TYPE_CUSTOM:
446
+ state.task_id_pending_text = True
447
+ t = _p(state.workspace_root, "task_id_no_suggestion")
448
+ return t["echo_variants"]["free_input"]
449
+ raise WizardError(
450
+ f"unexpected task-id value: {value!r} "
451
+ f"(expected {PICK_USE_SUGGESTED!r}, {PICK_TYPE_CUSTOM!r}, "
452
+ f"or '{_RECENT_PREFIX}<value>')"
453
+ )
454
+
455
+
456
+ def _build_task_id_text(state: WizardState) -> Prompt:
457
+ t = _p(state.workspace_root, "task_id_text")
458
+ return Prompt(step=S_TASK_ID_TEXT, kind="text",
459
+ label=t["label"],
460
+ echo_template=t["echo_template"])
461
+
462
+
463
+ def _submit_task_id_text(state: WizardState, value: str) -> Optional[str]:
464
+ state.task_id = _slug_or_die(value, "task_id")
465
+ state.task_id_pending_text = False
466
+ return f"task-id: {state.task_id}"
467
+
468
+
469
+ def _brief_recommended_task_type(state: WizardState) -> str:
470
+ """선택된 brief 가 지목한 진입 phase. brief 가 없거나 값이 없으면 ''."""
471
+ if not state.brief_path:
472
+ return ""
473
+ path = Path(state.brief_path)
474
+ if not path.is_absolute():
475
+ path = Path(state.project_root) / path
476
+ return _brief_recommended_phase(path)
477
+
478
+
479
+ def _recommended_task_type(
480
+ state: WizardState, manifest: Mapping[str, Any],
481
+ ) -> str:
482
+ """매니페스트 포인터가 ready 면 그 phase, 끝난 계획의 낡은 pending 이면 리포트 투영."""
483
+ filled = next_phase.autofill_task_type(manifest)
484
+ if filled:
485
+ return filled
486
+ workflow = manifest.get("workflow")
487
+ if not isinstance(workflow, Mapping):
488
+ return ""
489
+ if (
490
+ workflow.get("currentPhase") != "implementation-planning"
491
+ or workflow.get("currentPhaseState") != "completed"
492
+ ):
493
+ return ""
494
+ pointer = next_phase.project(_report_data_for_pointer(state, manifest))
495
+ if pointer["status"] != next_phase.STATUS_READY:
496
+ return ""
497
+ return pointer["phase"]
498
+
499
+
500
+ def _implementation_needs_approval(
501
+ state: WizardState,
502
+ manifest: Mapping[str, Any],
503
+ workflow: Mapping[str, Any],
504
+ ) -> bool:
505
+ if workflow.get("awaitingApproval") is True:
506
+ return True
507
+ if (
508
+ workflow.get("currentPhase") != "implementation-planning"
509
+ or workflow.get("currentPhaseState") != "completed"
510
+ ):
511
+ return False
512
+ frontmatter = _report_data_for_pointer(state, manifest).get("frontmatter")
513
+ if not isinstance(frontmatter, dict):
514
+ return True
515
+ return frontmatter.get("approved") is not True
516
+
517
+
518
+ def _build_task_type(state: WizardState) -> Prompt:
519
+ t = _p(state.workspace_root, "task_type")
520
+ recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
521
+ rerun_suffix = t["options"].get("_RERUN_SUFFIX", "")
522
+ next_suffix = t["options"].get("_NEXT_SUFFIX", "")
523
+ approve_suffix = t["options"].get("_APPROVE_SUFFIX", recommended_suffix)
524
+ blocked_rerun_suffix = t["options"].get("_BLOCKED_RERUN_SUFFIX", rerun_suffix)
525
+ description_by_type = dict(TASK_TYPES)
526
+ options: list[Option] = []
527
+
528
+ budget = _recommendation_budget(state)
529
+
530
+ def add(task_type: str, suffix: str = "", *, recommended: bool = False) -> None:
531
+ if task_type not in description_by_type:
532
+ return
533
+ if any(o.value == task_type for o in options):
534
+ return
535
+ if len(options) >= budget:
536
+ return
537
+ options.append(_opt(task_type, f"{task_type}{suffix}",
538
+ description_by_type[task_type],
539
+ recommended=recommended))
540
+
541
+ manifest = _existing_task_manifest(state)
542
+ workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
543
+ revision_requested = _latest_revision_requested_analysis_type(state)
544
+ # 포인터에서 phase 를 꺼내는 것은 `status` 가 `ready` 일 때뿐이다. prepare 는
545
+ # 실행이 끝나지 않은 run 의 포인터를 일부러 `pending` 으로 내려두는데
546
+ # (`render._derive_next_recommended_phase`), 여기서 status 를 안 보고 `phase`
547
+ # 만 꺼내면 그 방어를 그대로 우회한다 — 기록된 사례가 `implementation` 이
548
+ # `prepared` 상태인데 `final-verification` 이 추천으로 뜬 것이다. 판정은
549
+ # 셸 진입점이 쓰는 것과 같은 함수(`next_phase.autofill_task_type`)로 한다.
550
+ #
551
+ # `ready` 가 아니면 추천은 비고, 아래 `currentPhase` 재실행 옵션이 남는다.
552
+ # 실패한 run 의 포인터가 `{"phase": "", "status": "blocked"}` 라는 점에서
553
+ # 그것이 맞는 제안이다 — 그 옵션은 포인터가 아니라 `currentPhase` 에서 온다.
554
+ # 끝난 계획의 낡은 pending 은 최신 리포트에서 다시 투영한다. 승인 대기는
555
+ # `awaitingApproval` 또는 미승인 계획 리포트로 접미사를 붙인다. 열린
556
+ # C-NNN 때문에 blocked 면 재실행은 답이 기록된 뒤에만 고르라고 말한다.
557
+ # 이어갈 매니페스트가 없는 새 task 는 포인터도 이력도 없다 — 이번 task 가
558
+ # 무슨 일인지 아는 유일한 입력은 방금 고른 brief 다. brief 가 자기 진입
559
+ # phase 를 적어 두었으면(okstra-brief-gen 의 "Recommended next phase") 그것을
560
+ # 추천으로 쓰고, 없으면 라이프사이클 첫 단계로 떨어진다.
561
+ fresh_task = bool(state.is_new_task) and not workflow
562
+ brief_phase = _brief_recommended_task_type(state) if fresh_task else ""
563
+ recommended = (revision_requested or state.task_type
564
+ or _recommended_task_type(state, manifest))
565
+ if not recommended and not workflow:
566
+ recommended = brief_phase or TASK_TYPE_VALUES[0]
567
+ if brief_phase and recommended == brief_phase:
568
+ recommended_suffix = t["options"].get("_BRIEF_SUFFIX", recommended_suffix)
569
+ pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
570
+ if (
571
+ recommended == "implementation"
572
+ and _implementation_needs_approval(state, manifest, workflow)
573
+ ):
574
+ recommended_suffix = approve_suffix
575
+ if (
576
+ pointer["status"] == next_phase.STATUS_BLOCKED
577
+ and (workflow.get("currentPhase") or "") == "implementation-planning"
578
+ ):
579
+ rerun_suffix = blocked_rerun_suffix
580
+ add(recommended, recommended_suffix, recommended=True)
581
+ add(workflow.get("currentPhase") or "", rerun_suffix)
582
+ if not fresh_task:
583
+ add(_phase_after(recommended), next_suffix)
584
+ # 새 task 의 남은 후보는 entry phase 로 한정한다. implementation 은 승인된
585
+ # 계획서를, final-verification 은 구현을, release-handoff 는 커밋을 요구하므로
586
+ # 선행 산출물이 없는 새 키에서는 어차피 진입할 수 없다 — 목록에 두면 고르는
587
+ # 순간 실패하는 함정이다. 그래도 필요하면 "직접 입력" 이 전체 화이트리스트를
588
+ # 받는다(`_submit_task_type_text`).
589
+ fillers = _BRIEF_ENTRY_TASK_TYPES if fresh_task else TASK_TYPE_VALUES
590
+ for tt in _recent_task_types(state):
591
+ if tt in fillers:
592
+ add(tt)
593
+ for tt in fillers:
594
+ add(tt)
595
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
596
+ return Prompt(step=S_TASK_TYPE, kind="pick",
597
+ label=t["label"], options=options,
598
+ echo_template=t["echo_template"])
599
+
600
+
601
+ def _carry_in_existing_brief(state: WizardState) -> str:
602
+ """downstream task-type 이 등록된 brief 를 자동 carry-in 한다. 주입한 경로(또는 '')."""
603
+ if state.task_type in _BRIEF_ENTRY_TASK_TYPES:
604
+ return ""
605
+ if state.task_type == "release-handoff":
606
+ return "" # prepare 가 검증 보고서 인용 input 문서를 자동 생성한다
607
+ if state.brief_path or not state.existing_brief_path:
608
+ return ""
609
+ p = Path(state.existing_brief_path)
610
+ if not p.is_absolute():
611
+ p = Path(state.project_root) / p
612
+ if not p.is_file():
613
+ return "" # manifest 경로가 깨졌으면 brief_carry 단계가 처리한다
614
+ state.brief_path = state.existing_brief_path
615
+ return state.brief_path
616
+
617
+
618
+ def _apply_task_type(state: WizardState, value: str) -> str:
619
+ if value not in TASK_TYPE_VALUES:
620
+ raise WizardError(
621
+ f"unknown task-type: {value!r} "
622
+ f"(expected one of: {', '.join(TASK_TYPE_VALUES)})"
623
+ )
624
+ state.task_type = value
625
+ # brief_carry 의 "entry phase 로 전환" 이 task-type 을 리셋한 뒤 submit() 이
626
+ # brief_carry 를 answered 로 되돌려 놓는다 — task-type 을 다시 고르는 시점에
627
+ # 퍼지해야 downstream 재선택 시 carry 단계가 다시 나온다.
628
+ state.answered = [a for a in state.answered if a != S_BRIEF_CARRY]
629
+ carried = _carry_in_existing_brief(state)
630
+ state.profile_workers = _load_profile_workers(
631
+ Path(state.workspace_root), value
632
+ )
633
+ state.profile_optional_workers = _load_profile_optional_workers(
634
+ Path(state.workspace_root), value
635
+ )
636
+ # Reuse-worktree is decided once identity is final. Recompute here so
637
+ # subsequent base-ref step knows whether to apply.
638
+ state.reuse_worktree = _resolve_reuse_worktree(state)
639
+ if carried:
640
+ return f"task-type: {value}\nbrief (carry-in): {carried}"
641
+ return f"task-type: {value}"
642
+
643
+
644
+ def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
645
+ # PICK_TYPE_CUSTOM leaves task_type empty while S_TASK_TYPE gets marked
646
+ # answered — that combination is what gates S_TASK_TYPE_TEXT on.
647
+ if value == PICK_TYPE_CUSTOM:
648
+ state.task_type = ""
649
+ t = _p(state.workspace_root, "task_type")
650
+ return t["echo_variants"]["free_input"]
651
+ return _apply_task_type(state, value)
652
+
653
+
654
+ def _build_task_type_text(state: WizardState) -> Prompt:
655
+ t = _p(state.workspace_root, "task_type_text")
656
+ return Prompt(step=S_TASK_TYPE_TEXT, kind="text",
657
+ label=t["label"],
658
+ echo_template=t["echo_template"])
659
+
660
+
661
+ def _submit_task_type_text(state: WizardState, value: str) -> Optional[str]:
662
+ return _apply_task_type(state, value.strip())
663
+
664
+
665
+ def _build_brief_keep(state: WizardState) -> Prompt:
666
+ t = _p(state.workspace_root, "brief_keep",
667
+ existing_brief_path=state.existing_brief_path)
668
+ return Prompt(
669
+ step=S_BRIEF_KEEP, kind="pick",
670
+ label=t["label"],
671
+ options=[_opt(k, v) for k, v in t["options"].items()],
672
+ echo_template=t["echo_template"],
673
+ )
674
+
675
+
676
+ def _submit_brief_keep(state: WizardState, value: str) -> Optional[str]:
677
+ if value not in ("keep", "change"):
678
+ raise WizardError(f"expected 'keep' or 'change', got: {value!r}")
679
+ state.keep_existing_brief = value == "keep"
680
+ if state.keep_existing_brief:
681
+ state.brief_path = state.existing_brief_path
682
+ t = _p(state.workspace_root, "brief_keep",
683
+ existing_brief_path=state.existing_brief_path)
684
+ return t["echo_variants"]["kept"].format(brief_path=state.brief_path)
685
+ return None # next prompt is S_BRIEF_PATH
686
+
687
+
688
+ def _suggest_brief_path(state: WizardState) -> tuple[str, str]:
689
+ """Return (existing_brief_relpath_or_empty, standard_relpath).
690
+ standard_relpath = ".okstra/tasks/<task-group>/<task-id>/brief.md"."""
691
+ existing = state.existing_brief_path or ""
692
+ tg = slugify_task_segment(state.task_group) if state.task_group else ""
693
+ tid = slugify_task_segment(state.task_id) if state.task_id else ""
694
+ if tg and tid:
695
+ standard = str(Path(".okstra") / "tasks" / tg / tid / "brief.md")
696
+ else:
697
+ standard = ""
698
+ return existing, standard
699
+
700
+
701
+ def _build_brief_path_pick(state: WizardState) -> Prompt:
702
+ existing, standard = _suggest_brief_path(state)
703
+ t = _p(state.workspace_root, "brief_path_pick")
704
+ options: list[Option] = []
705
+
706
+ # 후보는 `_RECOMMENDATION_CAP` 개까지다. 그룹에 brief 가 여럿이면 목록이
707
+ # 호스트 네이티브 선택기의 옵션 한도(claude-code AskUserQuestion 은 4개,
708
+ # `adapters/hosts/claude-code/relay.md` 의 `nativeLimits`)를 넘고, 그때
709
+ # `CapabilityInteractionPort.plan` 이 `numbered-single` 로 내려 선택지가
710
+ # 아니라 번호 목록 텍스트로 나온다. 잘린 후보는 "직접 입력" 으로 간다.
711
+ budget = _recommendation_budget(state)
712
+
713
+ def add(value: str, label: str) -> None:
714
+ if len(options) >= budget:
715
+ return
716
+ options.append(_opt(value, label, recommended=True))
717
+
718
+ if existing:
719
+ add("__existing__",
720
+ t["options"]["__existing__"].format(existing=existing))
721
+ if standard and standard != existing:
722
+ add("__standard__",
723
+ t["options"]["__standard__"].format(standard=standard))
724
+ brief_label = t["labels"].get("brief_candidate", "{path}")
725
+ for relpath in _suggest_group_briefs(state):
726
+ if relpath in (existing, standard):
727
+ continue
728
+ add(f"{_BRIEF_PREFIX}{relpath}", brief_label.format(path=relpath))
729
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
730
+ return Prompt(step=S_BRIEF_PATH_PICK, kind="pick",
731
+ label=t["label"], options=options,
732
+ echo_template=t["echo_template"])
733
+
734
+
735
+ def _submit_brief_path_pick(state: WizardState, value: str) -> Optional[str]:
736
+ existing, standard = _suggest_brief_path(state)
737
+ if value == "__existing__":
738
+ if not existing:
739
+ t = _p(state.workspace_root, "brief_path_pick")
740
+ raise WizardError(t["errors"]["existing_missing"])
741
+ p = _require_file(existing, Path(state.project_root), "task brief")
742
+ _accept_brief_path(state, p)
743
+ return f"brief: {p}"
744
+ if value == "__standard__":
745
+ p = _require_file(standard, Path(state.project_root), "task brief")
746
+ _accept_brief_path(state, p)
747
+ return f"brief: {p}"
748
+ if value.startswith(_BRIEF_PREFIX):
749
+ relpath = value[len(_BRIEF_PREFIX):]
750
+ p = _require_file(relpath, Path(state.project_root), "task brief")
751
+ _accept_brief_path(state, p)
752
+ return f"brief: {p}"
753
+ if value == PICK_TYPE_CUSTOM:
754
+ state.brief_path_pending_text = True
755
+ state.brief_path = ""
756
+ return None
757
+ raise WizardError(
758
+ f"expected '__existing__', '__standard__', '{_BRIEF_PREFIX}<path>', "
759
+ f"or {PICK_TYPE_CUSTOM!r}, "
760
+ f"got: {value!r}"
761
+ )
762
+
763
+
764
+ def _build_brief_path(state: WizardState) -> Prompt:
765
+ t = _p(state.workspace_root, "brief_path")
766
+ return Prompt(
767
+ step=S_BRIEF_PATH, kind="text",
768
+ label=t["label"],
769
+ echo_template=t["echo_template"],
770
+ )
771
+
772
+
773
+ def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
774
+ p = _require_file(value, Path(state.project_root), "task brief")
775
+ _accept_brief_path(state, p)
776
+ return f"brief: {p}"
777
+
778
+
779
+ def _build_brief_carry(state: WizardState) -> Prompt:
780
+ t = _p(state.workspace_root, "brief_carry", task_type=state.task_type)
781
+ return Prompt(
782
+ step=S_BRIEF_CARRY, kind="pick",
783
+ label=t["label"],
784
+ options=[_opt(k, v) for k, v in t["options"].items()],
785
+ echo_template=t["echo_template"],
786
+ )
787
+
788
+
789
+ def _build_base_ref_pick(state: WizardState) -> Prompt:
790
+ t = _p(state.workspace_root, "base_ref_pick")
791
+ recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
792
+ options = [_opt(r, f"main{recommended_suffix}" if r == "main" else r)
793
+ for r in CANONICAL_BASE_REFS]
794
+ for value, label in _static_options(t):
795
+ options.append(_opt(value=value, label=label))
796
+ return Prompt(
797
+ step=S_BASE_REF_PICK, kind="pick",
798
+ label=t["label"],
799
+ options=options,
800
+ echo_template=t["echo_template"],
801
+ )
802
+
803
+
804
+ def _submit_base_ref_pick(state: WizardState, value: str) -> Optional[str]:
805
+ if value == BASE_REF_FREE_INPUT_TOKEN:
806
+ state.base_ref_pending_text = True
807
+ state.base_ref = ""
808
+ return None
809
+ state.base_ref_pending_text = False
810
+ ref = _validate_base_ref(value, Path(state.project_root))
811
+ state.base_ref = ref
812
+ return f"base-ref: {ref}"
813
+
814
+
815
+ def _build_base_ref_text(state: WizardState) -> Prompt:
816
+ t = _p(state.workspace_root, "base_ref_text")
817
+ return Prompt(
818
+ step=S_BASE_REF_TEXT, kind="text",
819
+ label=t["label"],
820
+ echo_template=t["echo_template"],
821
+ )
822
+
823
+
824
+ def _submit_base_ref_text(state: WizardState, value: str) -> Optional[str]:
825
+ ref = _validate_base_ref(value, Path(state.project_root))
826
+ state.base_ref = ref
827
+ state.base_ref_pending_text = False
828
+ return f"base-ref: {ref}"