okstra 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/okstra +3 -0
- package/docs/kr/architecture.md +2 -2
- package/docs/kr/cli.md +1 -0
- package/docs/project-structure-overview.md +4 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/python/okstra_ctl/wizard.py +1249 -0
- package/runtime/python/okstra_token_usage/collect.py +12 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +111 -247
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -0
- package/src/wizard.mjs +105 -0
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
"""okstra-run wizard — state machine for interactive task setup.
|
|
2
|
+
|
|
3
|
+
The okstra-run skill used to encode the full input-collection flow in prose,
|
|
4
|
+
which drifted into seven internal inconsistencies (executor placement,
|
|
5
|
+
free-text-via-AskUserQuestion, missing clarification prompt for impl, etc.).
|
|
6
|
+
This module owns the flow as code so the skill becomes a thin loop.
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
- ``WizardState`` - serializable dataclass; one state file per run.
|
|
10
|
+
- ``Prompt`` - what the skill should ask next.
|
|
11
|
+
- ``init_state()`` - seed from project-root / project-id / workspace-root.
|
|
12
|
+
- ``next_prompt()`` - deterministic; pure read on state.
|
|
13
|
+
- ``submit()`` - validate + advance.
|
|
14
|
+
- ``render_args()`` - final args for ``okstra render-bundle``.
|
|
15
|
+
|
|
16
|
+
The skill calls these via the ``okstra wizard`` CLI subcommand; it never
|
|
17
|
+
imports this module directly.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import asdict, dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from okstra_ctl.ids import slugify_task_segment
|
|
29
|
+
from okstra_ctl.models import (
|
|
30
|
+
PROVIDER_MAPPINGS,
|
|
31
|
+
UnknownModelError,
|
|
32
|
+
resolve_model_metadata,
|
|
33
|
+
)
|
|
34
|
+
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
35
|
+
from okstra_ctl.run import APPROVED_PLAN_PATTERN
|
|
36
|
+
from okstra_ctl.workers import (
|
|
37
|
+
ALLOWED_WORKERS,
|
|
38
|
+
WorkersError,
|
|
39
|
+
normalize_workers,
|
|
40
|
+
resolve_profile_workers,
|
|
41
|
+
validate_workers_against_profile,
|
|
42
|
+
)
|
|
43
|
+
from okstra_ctl import worktree_registry
|
|
44
|
+
from okstra_project.state import (
|
|
45
|
+
list_project_tasks,
|
|
46
|
+
read_latest_task,
|
|
47
|
+
read_task_manifest,
|
|
48
|
+
find_task_root,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---- Constants -----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
TASK_TYPES: list[tuple[str, str]] = [
|
|
55
|
+
("requirements-discovery", "Classify request and route to next safe phase"),
|
|
56
|
+
("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
|
|
57
|
+
("implementation-planning", "Plan options + request user approval"),
|
|
58
|
+
("implementation", "Execute approved plan (requires approved final-report)"),
|
|
59
|
+
("final-verification", "Acceptance + residual-risk review"),
|
|
60
|
+
("release-handoff", "Drive commit/push/PR after accepted final-verification"),
|
|
61
|
+
]
|
|
62
|
+
TASK_TYPE_VALUES = [tt for tt, _ in TASK_TYPES]
|
|
63
|
+
|
|
64
|
+
EXECUTORS = ["claude", "codex", "gemini"]
|
|
65
|
+
|
|
66
|
+
CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
|
|
67
|
+
BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
|
|
68
|
+
|
|
69
|
+
CLAUDE_MODEL_OPTIONS = ["default", "opus", "sonnet", "haiku"]
|
|
70
|
+
CODEX_MODEL_OPTIONS = ["default", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
|
|
71
|
+
GEMINI_MODEL_OPTIONS = ["default", "gemini-3-pro-preview", "gemini-3-flash-preview", "auto"]
|
|
72
|
+
|
|
73
|
+
# special pick value: start a brand-new task
|
|
74
|
+
TASK_PICK_NEW_TOKEN = "__new__"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---- Step IDs ------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
S_TASK_PICK = "task_pick"
|
|
80
|
+
S_TASK_GROUP = "task_group"
|
|
81
|
+
S_TASK_ID = "task_id"
|
|
82
|
+
S_TASK_TYPE = "task_type"
|
|
83
|
+
S_BRIEF_KEEP = "brief_keep"
|
|
84
|
+
S_BRIEF_PATH = "brief_path"
|
|
85
|
+
S_BASE_REF_PICK = "base_ref_pick"
|
|
86
|
+
S_BASE_REF_TEXT = "base_ref_text"
|
|
87
|
+
S_APPROVED_PLAN = "approved_plan"
|
|
88
|
+
S_EXECUTOR = "executor"
|
|
89
|
+
S_DEFAULTS_OR_CUSTOM = "defaults_or_custom"
|
|
90
|
+
S_WORKERS_OVERRIDE = "workers_override"
|
|
91
|
+
S_LEAD_MODEL = "lead_model"
|
|
92
|
+
S_EXECUTOR_MODEL = "executor_model"
|
|
93
|
+
S_CLAUDE_MODEL = "claude_model"
|
|
94
|
+
S_CODEX_MODEL = "codex_model"
|
|
95
|
+
S_GEMINI_MODEL = "gemini_model"
|
|
96
|
+
S_REPORT_WRITER_MODEL = "report_writer_model"
|
|
97
|
+
S_DIRECTIVE = "directive"
|
|
98
|
+
S_RELATED_TASKS = "related_tasks"
|
|
99
|
+
S_CLARIFICATION = "clarification"
|
|
100
|
+
S_PR_TEMPLATE = "pr_template"
|
|
101
|
+
S_PR_TEMPLATE_SCOPE = "pr_template_scope"
|
|
102
|
+
S_CONFIRM = "confirm"
|
|
103
|
+
S_EDIT_TARGET = "edit_target"
|
|
104
|
+
S_DONE = "done"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---- Data types ----------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class WizardState:
|
|
111
|
+
# bootstrap
|
|
112
|
+
workspace_root: str = ""
|
|
113
|
+
project_root: str = ""
|
|
114
|
+
project_id: str = ""
|
|
115
|
+
|
|
116
|
+
# task identity
|
|
117
|
+
is_new_task: Optional[bool] = None
|
|
118
|
+
task_group: str = ""
|
|
119
|
+
task_id: str = ""
|
|
120
|
+
existing_brief_path: str = ""
|
|
121
|
+
|
|
122
|
+
# task-type + dependents
|
|
123
|
+
task_type: str = ""
|
|
124
|
+
profile_workers: list[str] = field(default_factory=list)
|
|
125
|
+
|
|
126
|
+
# brief
|
|
127
|
+
keep_existing_brief: Optional[bool] = None
|
|
128
|
+
brief_path: str = ""
|
|
129
|
+
|
|
130
|
+
# worktree
|
|
131
|
+
reuse_worktree: Optional[bool] = None
|
|
132
|
+
base_ref: str = ""
|
|
133
|
+
base_ref_pending_text: bool = False
|
|
134
|
+
|
|
135
|
+
# impl extras
|
|
136
|
+
approved_plan_path: str = ""
|
|
137
|
+
executor: str = ""
|
|
138
|
+
|
|
139
|
+
# customize
|
|
140
|
+
use_defaults: Optional[bool] = None
|
|
141
|
+
workers_override: str = ""
|
|
142
|
+
lead_model: str = ""
|
|
143
|
+
claude_model: str = ""
|
|
144
|
+
codex_model: str = ""
|
|
145
|
+
gemini_model: str = ""
|
|
146
|
+
report_writer_model: str = ""
|
|
147
|
+
directive: str = ""
|
|
148
|
+
related_tasks_raw: str = ""
|
|
149
|
+
clarification_response_path: str = ""
|
|
150
|
+
pr_template_path: str = ""
|
|
151
|
+
pr_template_scope: str = "" # "once" | "project" | "global"
|
|
152
|
+
|
|
153
|
+
# confirm / edit
|
|
154
|
+
confirmed: Optional[bool] = None
|
|
155
|
+
edit_target: str = ""
|
|
156
|
+
|
|
157
|
+
# bookkeeping
|
|
158
|
+
answered: list[str] = field(default_factory=list)
|
|
159
|
+
|
|
160
|
+
def to_json(self) -> dict[str, Any]:
|
|
161
|
+
return asdict(self)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_json(cls, d: dict[str, Any]) -> "WizardState":
|
|
165
|
+
return cls(**d)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class Option:
|
|
170
|
+
value: str
|
|
171
|
+
label: str
|
|
172
|
+
description: str = ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class Prompt:
|
|
177
|
+
step: str
|
|
178
|
+
kind: str # "pick" | "text" | "done"
|
|
179
|
+
label: str = ""
|
|
180
|
+
options: list[Option] = field(default_factory=list)
|
|
181
|
+
help: str = ""
|
|
182
|
+
echo_template: str = "" # e.g. "task-group: {value}"
|
|
183
|
+
|
|
184
|
+
def to_json(self) -> dict[str, Any]:
|
|
185
|
+
return {
|
|
186
|
+
"step": self.step,
|
|
187
|
+
"kind": self.kind,
|
|
188
|
+
"label": self.label,
|
|
189
|
+
"options": [asdict(o) for o in self.options],
|
|
190
|
+
"help": self.help,
|
|
191
|
+
"echoTemplate": self.echo_template,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class WizardError(Exception):
|
|
196
|
+
"""validation failure surfaced to user."""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---- Validation helpers --------------------------------------------------
|
|
200
|
+
|
|
201
|
+
_SLUG_OK = re.compile(r"[a-z0-9]")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _slug_or_die(value: str, field_name: str) -> str:
|
|
205
|
+
slug = slugify_task_segment(value or "")
|
|
206
|
+
if not slug or not _SLUG_OK.search(slug):
|
|
207
|
+
raise WizardError(
|
|
208
|
+
f"{field_name} must contain at least one alphanumeric character "
|
|
209
|
+
f"(got: {value!r})"
|
|
210
|
+
)
|
|
211
|
+
return slug
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _resolve_path(path_str: str, project_root: Path) -> Path:
|
|
215
|
+
p = Path(path_str).expanduser()
|
|
216
|
+
return p if p.is_absolute() else (project_root / p).resolve()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _require_file(path_str: str, project_root: Path, label: str) -> Path:
|
|
220
|
+
if not (path_str or "").strip():
|
|
221
|
+
raise WizardError(f"{label}: empty path")
|
|
222
|
+
p = _resolve_path(path_str, project_root)
|
|
223
|
+
if not p.is_file():
|
|
224
|
+
raise WizardError(f"{label}: file not found: {p}")
|
|
225
|
+
return p
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
229
|
+
p = _require_file(path_str, project_root, "approved plan")
|
|
230
|
+
body = p.read_text(encoding="utf-8", errors="replace")
|
|
231
|
+
if not APPROVED_PLAN_PATTERN.search(body):
|
|
232
|
+
raise WizardError(
|
|
233
|
+
f"approved plan has no APPROVED marker: {p}\n"
|
|
234
|
+
' canonical form (top of report): "- [x] Approved"'
|
|
235
|
+
)
|
|
236
|
+
return p
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _git_main_worktree(project_root: Path) -> Path:
|
|
240
|
+
try:
|
|
241
|
+
common = subprocess.check_output(
|
|
242
|
+
["git", "-C", str(project_root), "rev-parse",
|
|
243
|
+
"--path-format=absolute", "--git-common-dir"],
|
|
244
|
+
stderr=subprocess.DEVNULL,
|
|
245
|
+
).decode().strip()
|
|
246
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
247
|
+
raise WizardError(f"git unavailable in {project_root}: {exc}")
|
|
248
|
+
return Path(common).parent
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _validate_base_ref(ref: str, project_root: Path) -> str:
|
|
252
|
+
ref = (ref or "").strip()
|
|
253
|
+
if not ref:
|
|
254
|
+
raise WizardError("base ref is empty")
|
|
255
|
+
main_wt = _git_main_worktree(project_root)
|
|
256
|
+
rc = subprocess.call(
|
|
257
|
+
["git", "-C", str(main_wt), "rev-parse",
|
|
258
|
+
"--verify", "--quiet", f"{ref}^{{commit}}"],
|
|
259
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
260
|
+
)
|
|
261
|
+
if rc != 0:
|
|
262
|
+
raise WizardError(f"base ref not found in repository: {ref}")
|
|
263
|
+
return ref
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _validate_model(provider: str, raw: str) -> str:
|
|
267
|
+
"""Empty / 'default' → '' (use phase default).
|
|
268
|
+
Known alias → canonical option (passed verbatim to render-bundle).
|
|
269
|
+
Unknown → WizardError.
|
|
270
|
+
"""
|
|
271
|
+
raw = (raw or "").strip()
|
|
272
|
+
if raw == "" or raw.lower() == "default":
|
|
273
|
+
return ""
|
|
274
|
+
try:
|
|
275
|
+
resolve_model_metadata(
|
|
276
|
+
provider=provider, raw_value=raw,
|
|
277
|
+
default_display="x", default_execution="x",
|
|
278
|
+
)
|
|
279
|
+
except UnknownModelError as exc:
|
|
280
|
+
raise WizardError(str(exc))
|
|
281
|
+
return raw
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _executor_model_field(executor: str) -> str:
|
|
285
|
+
return {"claude": "claude_model",
|
|
286
|
+
"codex": "codex_model",
|
|
287
|
+
"gemini": "gemini_model"}[executor]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _executor_model_options(executor: str) -> list[str]:
|
|
291
|
+
return {"claude": CLAUDE_MODEL_OPTIONS,
|
|
292
|
+
"codex": CODEX_MODEL_OPTIONS,
|
|
293
|
+
"gemini": GEMINI_MODEL_OPTIONS}[executor]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- Roster / profile helpers -------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _profile_path(workspace_root: Path, task_type: str) -> Path:
|
|
299
|
+
return workspace_root / "prompts" / "profiles" / f"{task_type}.md"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
|
|
303
|
+
return resolve_profile_workers(_profile_path(workspace_root, task_type))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _resolved_roster(state: WizardState) -> list[str]:
|
|
307
|
+
"""Effective worker list AFTER override. Implementation: profile default
|
|
308
|
+
(caller never asks for override). Others: override or profile default."""
|
|
309
|
+
if state.task_type == "implementation":
|
|
310
|
+
return list(state.profile_workers)
|
|
311
|
+
if state.workers_override.strip():
|
|
312
|
+
return normalize_workers(state.workers_override)
|
|
313
|
+
return list(state.profile_workers)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---- Worktree resolution ------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def _resolve_reuse_worktree(state: WizardState) -> bool:
|
|
319
|
+
"""For a finalized task identity, is there an active worktree to reuse?
|
|
320
|
+
New tasks always answer False (no entry possible)."""
|
|
321
|
+
if state.is_new_task:
|
|
322
|
+
return False
|
|
323
|
+
if not (state.project_id and state.task_group and state.task_id):
|
|
324
|
+
return False
|
|
325
|
+
entry = worktree_registry.lookup(state.project_id,
|
|
326
|
+
state.task_group, state.task_id)
|
|
327
|
+
return bool(entry and entry.status == "active")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _existing_task_brief(project_root: Path, task_key: str) -> str:
|
|
331
|
+
"""Read taskBriefPath from manifest for an existing task. Empty if none."""
|
|
332
|
+
root = find_task_root(project_root, task_key)
|
|
333
|
+
if root is None:
|
|
334
|
+
return ""
|
|
335
|
+
manifest = read_task_manifest(root) or {}
|
|
336
|
+
val = manifest.get("taskBriefPath") or ""
|
|
337
|
+
return val if isinstance(val, str) else ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---- Step descriptors ---------------------------------------------------
|
|
341
|
+
|
|
342
|
+
@dataclass
|
|
343
|
+
class Step:
|
|
344
|
+
id: str
|
|
345
|
+
applies: Callable[[WizardState], bool]
|
|
346
|
+
build: Callable[[WizardState], Prompt]
|
|
347
|
+
submit: Callable[[WizardState, str], Optional[str]]
|
|
348
|
+
# Field names this step owns; resetting the step clears them.
|
|
349
|
+
owns: tuple[str, ...] = ()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _opt(value: str, label: str = "", description: str = "") -> Option:
|
|
353
|
+
return Option(value=value, label=label or value, description=description)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --- builders ---
|
|
357
|
+
|
|
358
|
+
def _build_task_pick(state: WizardState) -> Prompt:
|
|
359
|
+
project_root = Path(state.project_root)
|
|
360
|
+
tasks = list_project_tasks(project_root)
|
|
361
|
+
latest = read_latest_task(project_root) or {}
|
|
362
|
+
latest_key = latest.get("taskKey") or ""
|
|
363
|
+
options: list[Option] = []
|
|
364
|
+
for entry in tasks[:8]:
|
|
365
|
+
key = entry.get("taskKey") or ""
|
|
366
|
+
ttype = entry.get("taskType") or ""
|
|
367
|
+
phase = (entry.get("workflow") or {}).get("currentPhase") or ttype
|
|
368
|
+
nxt = (entry.get("workflow") or {}).get("nextRecommendedPhase") or ""
|
|
369
|
+
suffix = " (latest)" if key == latest_key else ""
|
|
370
|
+
label = f"{key} · {phase} · next: {nxt}{suffix}"
|
|
371
|
+
options.append(_opt(value=key, label=label))
|
|
372
|
+
options.append(_opt(value=TASK_PICK_NEW_TOKEN,
|
|
373
|
+
label="Start a brand-new task"))
|
|
374
|
+
return Prompt(step=S_TASK_PICK, kind="pick",
|
|
375
|
+
label="어느 task?", options=options,
|
|
376
|
+
echo_template="task: {value}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
380
|
+
value = value.strip()
|
|
381
|
+
if value == TASK_PICK_NEW_TOKEN:
|
|
382
|
+
state.is_new_task = True
|
|
383
|
+
state.task_group = ""
|
|
384
|
+
state.task_id = ""
|
|
385
|
+
state.existing_brief_path = ""
|
|
386
|
+
state.task_type = ""
|
|
387
|
+
return "task: (brand-new)"
|
|
388
|
+
# parse "<project-id>:<task-group>:<task-id>"
|
|
389
|
+
parts = value.split(":")
|
|
390
|
+
if len(parts) != 3 or not all(parts):
|
|
391
|
+
raise WizardError(
|
|
392
|
+
f"invalid task-key: {value!r} (expected project-id:task-group:task-id)"
|
|
393
|
+
)
|
|
394
|
+
pid, tg, tid = parts
|
|
395
|
+
if pid != state.project_id:
|
|
396
|
+
raise WizardError(
|
|
397
|
+
f"task-key project-id {pid!r} does not match current project {state.project_id!r}"
|
|
398
|
+
)
|
|
399
|
+
state.is_new_task = False
|
|
400
|
+
state.task_group = tg
|
|
401
|
+
state.task_id = tid
|
|
402
|
+
state.existing_brief_path = _existing_task_brief(
|
|
403
|
+
Path(state.project_root), value
|
|
404
|
+
)
|
|
405
|
+
# task_type seeded from manifest's nextRecommendedPhase as the recommended default
|
|
406
|
+
root = find_task_root(Path(state.project_root), value)
|
|
407
|
+
manifest = (read_task_manifest(root) or {}) if root else {}
|
|
408
|
+
workflow = manifest.get("workflow") or {}
|
|
409
|
+
state.task_type = workflow.get("nextRecommendedPhase") or ""
|
|
410
|
+
return f"task: {value}"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _build_task_group(state: WizardState) -> Prompt:
|
|
414
|
+
return Prompt(step=S_TASK_GROUP, kind="text",
|
|
415
|
+
label="Task group 을 알려주세요 (예: backend-api, INV-1234, refactor)",
|
|
416
|
+
echo_template="task-group: {value}")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _submit_task_group(state: WizardState, value: str) -> Optional[str]:
|
|
420
|
+
state.task_group = _slug_or_die(value, "task_group")
|
|
421
|
+
return f"task-group: {state.task_group}"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _build_task_id(state: WizardState) -> Prompt:
|
|
425
|
+
return Prompt(step=S_TASK_ID, kind="text",
|
|
426
|
+
label="Task id 를 알려주세요 (예: login-error-analysis, dev-9043)",
|
|
427
|
+
echo_template="task-id: {value}")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _submit_task_id(state: WizardState, value: str) -> Optional[str]:
|
|
431
|
+
state.task_id = _slug_or_die(value, "task_id")
|
|
432
|
+
return f"task-id: {state.task_id}"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _build_task_type(state: WizardState) -> Prompt:
|
|
436
|
+
options: list[Option] = []
|
|
437
|
+
recommended = state.task_type if not state.is_new_task else ""
|
|
438
|
+
seen: list[str] = []
|
|
439
|
+
if recommended and recommended in TASK_TYPE_VALUES:
|
|
440
|
+
d = dict(TASK_TYPES)[recommended]
|
|
441
|
+
options.append(_opt(recommended, f"{recommended} (recommended)", d))
|
|
442
|
+
seen.append(recommended)
|
|
443
|
+
for tt, desc in TASK_TYPES:
|
|
444
|
+
if tt in seen:
|
|
445
|
+
continue
|
|
446
|
+
options.append(_opt(tt, tt, desc))
|
|
447
|
+
return Prompt(step=S_TASK_TYPE, kind="pick",
|
|
448
|
+
label="Task type?", options=options,
|
|
449
|
+
echo_template="task-type: {value}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _submit_task_type(state: WizardState, value: str) -> Optional[str]:
|
|
453
|
+
if value not in TASK_TYPE_VALUES:
|
|
454
|
+
raise WizardError(f"unknown task-type: {value!r}")
|
|
455
|
+
state.task_type = value
|
|
456
|
+
state.profile_workers = _load_profile_workers(
|
|
457
|
+
Path(state.workspace_root), value
|
|
458
|
+
)
|
|
459
|
+
# Reuse-worktree is decided once identity is final. Recompute here so
|
|
460
|
+
# subsequent base-ref step knows whether to apply.
|
|
461
|
+
state.reuse_worktree = _resolve_reuse_worktree(state)
|
|
462
|
+
return f"task-type: {value}"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _build_brief_keep(state: WizardState) -> Prompt:
|
|
466
|
+
return Prompt(
|
|
467
|
+
step=S_BRIEF_KEEP, kind="pick",
|
|
468
|
+
label=f"기존 brief 경로 [{state.existing_brief_path}] 를 유지할까요?",
|
|
469
|
+
options=[_opt("keep", "유지"), _opt("change", "변경")],
|
|
470
|
+
echo_template="brief: {value}",
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _submit_brief_keep(state: WizardState, value: str) -> Optional[str]:
|
|
475
|
+
if value not in ("keep", "change"):
|
|
476
|
+
raise WizardError(f"expected 'keep' or 'change', got: {value!r}")
|
|
477
|
+
state.keep_existing_brief = value == "keep"
|
|
478
|
+
if state.keep_existing_brief:
|
|
479
|
+
state.brief_path = state.existing_brief_path
|
|
480
|
+
return f"brief: {state.brief_path} (유지)"
|
|
481
|
+
return None # next prompt is S_BRIEF_PATH
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _build_brief_path(state: WizardState) -> Prompt:
|
|
485
|
+
return Prompt(
|
|
486
|
+
step=S_BRIEF_PATH, kind="text",
|
|
487
|
+
label="task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)",
|
|
488
|
+
echo_template="brief: {value}",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _submit_brief_path(state: WizardState, value: str) -> Optional[str]:
|
|
493
|
+
p = _require_file(value, Path(state.project_root), "task brief")
|
|
494
|
+
state.brief_path = str(p)
|
|
495
|
+
return f"brief: {p}"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _build_base_ref_pick(state: WizardState) -> Prompt:
|
|
499
|
+
options = [_opt(r, "main (recommended)" if r == "main" else r)
|
|
500
|
+
for r in CANONICAL_BASE_REFS]
|
|
501
|
+
options.append(_opt(BASE_REF_FREE_INPUT_TOKEN, "직접 입력"))
|
|
502
|
+
return Prompt(
|
|
503
|
+
step=S_BASE_REF_PICK, kind="pick",
|
|
504
|
+
label="이 task worktree 의 base branch?",
|
|
505
|
+
options=options,
|
|
506
|
+
echo_template="base-ref: {value}",
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _submit_base_ref_pick(state: WizardState, value: str) -> Optional[str]:
|
|
511
|
+
if value == BASE_REF_FREE_INPUT_TOKEN:
|
|
512
|
+
state.base_ref_pending_text = True
|
|
513
|
+
state.base_ref = ""
|
|
514
|
+
return None
|
|
515
|
+
state.base_ref_pending_text = False
|
|
516
|
+
ref = _validate_base_ref(value, Path(state.project_root))
|
|
517
|
+
state.base_ref = ref
|
|
518
|
+
return f"base-ref: {ref}"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _build_base_ref_text(state: WizardState) -> Prompt:
|
|
522
|
+
return Prompt(
|
|
523
|
+
step=S_BASE_REF_TEXT, kind="text",
|
|
524
|
+
label="base ref 를 입력해주세요 (branch, tag, 또는 short/full SHA)",
|
|
525
|
+
echo_template="base-ref: {value}",
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _submit_base_ref_text(state: WizardState, value: str) -> Optional[str]:
|
|
530
|
+
ref = _validate_base_ref(value, Path(state.project_root))
|
|
531
|
+
state.base_ref = ref
|
|
532
|
+
state.base_ref_pending_text = False
|
|
533
|
+
return f"base-ref: {ref}"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _build_approved_plan(state: WizardState) -> Prompt:
|
|
537
|
+
return Prompt(
|
|
538
|
+
step=S_APPROVED_PLAN, kind="text",
|
|
539
|
+
label="approved final-report.md 의 경로를 알려주세요 (APPROVED 마커 필수)",
|
|
540
|
+
echo_template="approved-plan: {value}",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
|
|
545
|
+
p = _validate_approved_plan(value, Path(state.project_root))
|
|
546
|
+
state.approved_plan_path = str(p)
|
|
547
|
+
return f"approved-plan: {p}"
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _build_executor(state: WizardState) -> Prompt:
|
|
551
|
+
options = [_opt(e, e + (" (default)" if e == "claude" else ""))
|
|
552
|
+
for e in EXECUTORS]
|
|
553
|
+
return Prompt(
|
|
554
|
+
step=S_EXECUTOR, kind="pick",
|
|
555
|
+
label="실행자 (executor)?",
|
|
556
|
+
options=options,
|
|
557
|
+
echo_template="executor: {value}",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _submit_executor(state: WizardState, value: str) -> Optional[str]:
|
|
562
|
+
if value not in EXECUTORS:
|
|
563
|
+
raise WizardError(f"executor must be one of {EXECUTORS}, got: {value!r}")
|
|
564
|
+
state.executor = value
|
|
565
|
+
return f"executor: {value}"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _build_defaults_or_custom(state: WizardState) -> Prompt:
|
|
569
|
+
return Prompt(
|
|
570
|
+
step=S_DEFAULTS_OR_CUSTOM, kind="pick",
|
|
571
|
+
label="기본 워커/모델로 진행할까요, 아니면 커스터마이즈할까요?",
|
|
572
|
+
options=[_opt("defaults", "Use defaults"),
|
|
573
|
+
_opt("customize", "Customize")],
|
|
574
|
+
echo_template="customize: {value}",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _submit_defaults_or_custom(state: WizardState, value: str) -> Optional[str]:
|
|
579
|
+
if value not in ("defaults", "customize"):
|
|
580
|
+
raise WizardError(f"expected 'defaults' or 'customize', got: {value!r}")
|
|
581
|
+
state.use_defaults = value == "defaults"
|
|
582
|
+
return f"customize: {'no' if state.use_defaults else 'yes'}"
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _build_workers_override(state: WizardState) -> Prompt:
|
|
586
|
+
csv = ",".join(state.profile_workers)
|
|
587
|
+
return Prompt(
|
|
588
|
+
step=S_WORKERS_OVERRIDE, kind="text",
|
|
589
|
+
label=(f"참여 워커 목록을 쉼표로 구분해서 적어주세요. "
|
|
590
|
+
f"빈 줄이면 프로필 기본값 [{csv}] 을 그대로 씁니다. "
|
|
591
|
+
f"사용 가능한 워커: {csv}"),
|
|
592
|
+
echo_template="workers: {value}",
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _submit_workers_override(state: WizardState, value: str) -> Optional[str]:
|
|
597
|
+
if not (value or "").strip():
|
|
598
|
+
state.workers_override = ""
|
|
599
|
+
return f"workers: (profile default: {','.join(state.profile_workers)})"
|
|
600
|
+
try:
|
|
601
|
+
chosen = normalize_workers(value)
|
|
602
|
+
validate_workers_against_profile(chosen, state.profile_workers)
|
|
603
|
+
except WorkersError as exc:
|
|
604
|
+
raise WizardError(str(exc))
|
|
605
|
+
state.workers_override = ",".join(chosen)
|
|
606
|
+
return f"workers: {state.workers_override}"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _model_pick(step: str, label: str, options: list[str], echo: str) -> Prompt:
|
|
610
|
+
opts = [_opt(o, o) for o in options]
|
|
611
|
+
return Prompt(step=step, kind="pick", label=label,
|
|
612
|
+
options=opts, echo_template=echo)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _build_lead_model(state: WizardState) -> Prompt:
|
|
616
|
+
return _model_pick(S_LEAD_MODEL, "리더(Claude lead) 모델?",
|
|
617
|
+
CLAUDE_MODEL_OPTIONS, "lead-model: {value}")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _submit_lead_model(state: WizardState, value: str) -> Optional[str]:
|
|
621
|
+
state.lead_model = _validate_model("claude", value)
|
|
622
|
+
return f"lead-model: {state.lead_model or 'default'}"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _build_executor_model(state: WizardState) -> Prompt:
|
|
626
|
+
return _model_pick(
|
|
627
|
+
S_EXECUTOR_MODEL,
|
|
628
|
+
f"실행자({state.executor}) 모델?",
|
|
629
|
+
_executor_model_options(state.executor),
|
|
630
|
+
f"{state.executor}-model: {{value}}",
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _submit_executor_model(state: WizardState, value: str) -> Optional[str]:
|
|
635
|
+
resolved = _validate_model(state.executor, value)
|
|
636
|
+
setattr(state, _executor_model_field(state.executor), resolved)
|
|
637
|
+
return f"{state.executor}-model: {resolved or 'default'}"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _build_claude_model(state: WizardState) -> Prompt:
|
|
641
|
+
return _model_pick(S_CLAUDE_MODEL, "claude 워커 모델?",
|
|
642
|
+
CLAUDE_MODEL_OPTIONS, "claude-model: {value}")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _submit_claude_model(state: WizardState, value: str) -> Optional[str]:
|
|
646
|
+
state.claude_model = _validate_model("claude", value)
|
|
647
|
+
return f"claude-model: {state.claude_model or 'default'}"
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _build_codex_model(state: WizardState) -> Prompt:
|
|
651
|
+
return _model_pick(S_CODEX_MODEL, "codex 워커 모델?",
|
|
652
|
+
CODEX_MODEL_OPTIONS, "codex-model: {value}")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _submit_codex_model(state: WizardState, value: str) -> Optional[str]:
|
|
656
|
+
state.codex_model = _validate_model("codex", value)
|
|
657
|
+
return f"codex-model: {state.codex_model or 'default'}"
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _build_gemini_model(state: WizardState) -> Prompt:
|
|
661
|
+
return _model_pick(S_GEMINI_MODEL, "gemini 워커 모델?",
|
|
662
|
+
GEMINI_MODEL_OPTIONS, "gemini-model: {value}")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _submit_gemini_model(state: WizardState, value: str) -> Optional[str]:
|
|
666
|
+
state.gemini_model = _validate_model("gemini", value)
|
|
667
|
+
return f"gemini-model: {state.gemini_model or 'default'}"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _build_report_writer_model(state: WizardState) -> Prompt:
|
|
671
|
+
return _model_pick(S_REPORT_WRITER_MODEL,
|
|
672
|
+
"리포트 작성자(report-writer) 모델?",
|
|
673
|
+
CLAUDE_MODEL_OPTIONS,
|
|
674
|
+
"report-writer-model: {value}")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _submit_report_writer_model(state: WizardState, value: str) -> Optional[str]:
|
|
678
|
+
state.report_writer_model = _validate_model("claude", value)
|
|
679
|
+
return f"report-writer-model: {state.report_writer_model or 'default'}"
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _build_directive(state: WizardState) -> Prompt:
|
|
683
|
+
return Prompt(
|
|
684
|
+
step=S_DIRECTIVE, kind="text",
|
|
685
|
+
label="추가 directive 가 있으면 적어주세요 (없으면 빈 줄)",
|
|
686
|
+
echo_template="directive: {value}",
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _submit_directive(state: WizardState, value: str) -> Optional[str]:
|
|
691
|
+
state.directive = (value or "").strip()
|
|
692
|
+
return f"directive: {state.directive or '(none)'}"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _build_related_tasks(state: WizardState) -> Prompt:
|
|
696
|
+
return Prompt(
|
|
697
|
+
step=S_RELATED_TASKS, kind="text",
|
|
698
|
+
label="관련 task id 목록을 쉼표로 구분해서 적어주세요 (없으면 빈 줄)",
|
|
699
|
+
echo_template="related-tasks: {value}",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _submit_related_tasks(state: WizardState, value: str) -> Optional[str]:
|
|
704
|
+
state.related_tasks_raw = (value or "").strip()
|
|
705
|
+
return f"related-tasks: {state.related_tasks_raw or '(none)'}"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _build_clarification(state: WizardState) -> Prompt:
|
|
709
|
+
return Prompt(
|
|
710
|
+
step=S_CLARIFICATION, kind="text",
|
|
711
|
+
label="clarification-response 파일 경로 (follow-up 시에만, 없으면 빈 줄)",
|
|
712
|
+
echo_template="clarification: {value}",
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _submit_clarification(state: WizardState, value: str) -> Optional[str]:
|
|
717
|
+
val = (value or "").strip()
|
|
718
|
+
if not val:
|
|
719
|
+
state.clarification_response_path = ""
|
|
720
|
+
return "clarification: (none)"
|
|
721
|
+
p = _require_file(val, Path(state.project_root), "clarification-response")
|
|
722
|
+
state.clarification_response_path = str(p)
|
|
723
|
+
return f"clarification: {p}"
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _build_pr_template(state: WizardState) -> Prompt:
|
|
727
|
+
return Prompt(
|
|
728
|
+
step=S_PR_TEMPLATE, kind="text",
|
|
729
|
+
label=("PR 본문 템플릿 경로 1회성 override (빈 줄이면 project.json → "
|
|
730
|
+
"~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)"),
|
|
731
|
+
echo_template="pr-template: {value}",
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _submit_pr_template(state: WizardState, value: str) -> Optional[str]:
|
|
736
|
+
val = (value or "").strip()
|
|
737
|
+
if not val:
|
|
738
|
+
state.pr_template_path = ""
|
|
739
|
+
state.pr_template_scope = ""
|
|
740
|
+
return "pr-template: (auto-resolve)"
|
|
741
|
+
# Validate by re-using resolve_pr_template_path with override.
|
|
742
|
+
try:
|
|
743
|
+
resolved = resolve_pr_template_path(
|
|
744
|
+
Path(state.project_root), override_path=val
|
|
745
|
+
)
|
|
746
|
+
except PrTemplateError as exc:
|
|
747
|
+
raise WizardError(str(exc))
|
|
748
|
+
state.pr_template_path = str(resolved.path)
|
|
749
|
+
return f"pr-template: {resolved.path}"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _build_pr_template_scope(state: WizardState) -> Prompt:
|
|
753
|
+
return Prompt(
|
|
754
|
+
step=S_PR_TEMPLATE_SCOPE, kind="pick",
|
|
755
|
+
label="방금 입력한 경로를 영구 저장할까요?",
|
|
756
|
+
options=[
|
|
757
|
+
_opt("once", "이번 run 만 (1회성)"),
|
|
758
|
+
_opt("project", "프로젝트에 저장 (project scope)"),
|
|
759
|
+
_opt("global", "전역에 저장 (global scope)"),
|
|
760
|
+
],
|
|
761
|
+
echo_template="pr-template-scope: {value}",
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
|
|
766
|
+
if value not in ("once", "project", "global"):
|
|
767
|
+
raise WizardError(
|
|
768
|
+
f"expected 'once' / 'project' / 'global', got: {value!r}"
|
|
769
|
+
)
|
|
770
|
+
state.pr_template_scope = value
|
|
771
|
+
return f"pr-template-scope: {value}"
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _build_confirm(state: WizardState) -> Prompt:
|
|
775
|
+
return Prompt(
|
|
776
|
+
step=S_CONFIRM, kind="pick",
|
|
777
|
+
label="이대로 진행할까요?",
|
|
778
|
+
options=[_opt("proceed", "Proceed"), _opt("edit", "Edit")],
|
|
779
|
+
echo_template="confirm: {value}",
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _submit_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
784
|
+
if value not in ("proceed", "edit"):
|
|
785
|
+
raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
|
|
786
|
+
state.confirmed = value == "proceed"
|
|
787
|
+
return f"confirm: {value}"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _build_edit_target(state: WizardState) -> Prompt:
|
|
791
|
+
# offer every step that has been answered.
|
|
792
|
+
options: list[Option] = []
|
|
793
|
+
for sid in state.answered:
|
|
794
|
+
if sid in (S_CONFIRM, S_EDIT_TARGET):
|
|
795
|
+
continue
|
|
796
|
+
options.append(_opt(sid, sid))
|
|
797
|
+
return Prompt(
|
|
798
|
+
step=S_EDIT_TARGET, kind="pick",
|
|
799
|
+
label="어느 step 으로 돌아갈까요?",
|
|
800
|
+
options=options,
|
|
801
|
+
echo_template="edit-target: {value}",
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
|
|
806
|
+
if not any(s.id == value for s in STEPS):
|
|
807
|
+
raise WizardError(f"unknown step: {value!r}")
|
|
808
|
+
state.edit_target = value
|
|
809
|
+
_reset_from(state, value)
|
|
810
|
+
state.confirmed = None
|
|
811
|
+
state.edit_target = ""
|
|
812
|
+
return f"edit-target: {value} (rewinding)"
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# --- step registry ---
|
|
816
|
+
|
|
817
|
+
STEPS: list[Step] = [
|
|
818
|
+
Step(S_TASK_PICK,
|
|
819
|
+
applies=lambda s: s.is_new_task is None,
|
|
820
|
+
build=_build_task_pick, submit=_submit_task_pick,
|
|
821
|
+
owns=("is_new_task", "task_group", "task_id", "task_type",
|
|
822
|
+
"existing_brief_path", "profile_workers")),
|
|
823
|
+
Step(S_TASK_GROUP,
|
|
824
|
+
applies=lambda s: bool(s.is_new_task) and not s.task_group,
|
|
825
|
+
build=_build_task_group, submit=_submit_task_group,
|
|
826
|
+
owns=("task_group",)),
|
|
827
|
+
Step(S_TASK_ID,
|
|
828
|
+
applies=lambda s: bool(s.is_new_task) and bool(s.task_group) and not s.task_id,
|
|
829
|
+
build=_build_task_id, submit=_submit_task_id,
|
|
830
|
+
owns=("task_id",)),
|
|
831
|
+
Step(S_TASK_TYPE,
|
|
832
|
+
applies=lambda s: (s.is_new_task is not None
|
|
833
|
+
and (s.is_new_task is False or bool(s.task_id))
|
|
834
|
+
and S_TASK_TYPE not in s.answered),
|
|
835
|
+
build=_build_task_type, submit=_submit_task_type,
|
|
836
|
+
owns=("task_type", "profile_workers", "reuse_worktree")),
|
|
837
|
+
Step(S_BRIEF_KEEP,
|
|
838
|
+
applies=lambda s: (not s.is_new_task
|
|
839
|
+
and bool(s.existing_brief_path)
|
|
840
|
+
and s.keep_existing_brief is None
|
|
841
|
+
and S_TASK_TYPE in s.answered),
|
|
842
|
+
build=_build_brief_keep, submit=_submit_brief_keep,
|
|
843
|
+
owns=("keep_existing_brief", "brief_path")),
|
|
844
|
+
Step(S_BRIEF_PATH,
|
|
845
|
+
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
846
|
+
and not s.brief_path
|
|
847
|
+
and (s.is_new_task
|
|
848
|
+
or s.keep_existing_brief is False
|
|
849
|
+
or (not s.is_new_task and not s.existing_brief_path))),
|
|
850
|
+
build=_build_brief_path, submit=_submit_brief_path,
|
|
851
|
+
owns=("brief_path",)),
|
|
852
|
+
Step(S_BASE_REF_PICK,
|
|
853
|
+
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
854
|
+
and s.reuse_worktree is False
|
|
855
|
+
and S_BASE_REF_PICK not in s.answered
|
|
856
|
+
and bool(s.brief_path)),
|
|
857
|
+
build=_build_base_ref_pick, submit=_submit_base_ref_pick,
|
|
858
|
+
owns=("base_ref", "base_ref_pending_text")),
|
|
859
|
+
Step(S_BASE_REF_TEXT,
|
|
860
|
+
applies=lambda s: s.base_ref_pending_text,
|
|
861
|
+
build=_build_base_ref_text, submit=_submit_base_ref_text,
|
|
862
|
+
owns=("base_ref", "base_ref_pending_text")),
|
|
863
|
+
Step(S_APPROVED_PLAN,
|
|
864
|
+
applies=lambda s: (s.task_type == "implementation"
|
|
865
|
+
and not s.approved_plan_path
|
|
866
|
+
and bool(s.brief_path)
|
|
867
|
+
and (s.reuse_worktree is True
|
|
868
|
+
or S_BASE_REF_PICK in s.answered)
|
|
869
|
+
and not s.base_ref_pending_text),
|
|
870
|
+
build=_build_approved_plan, submit=_submit_approved_plan,
|
|
871
|
+
owns=("approved_plan_path",)),
|
|
872
|
+
Step(S_EXECUTOR,
|
|
873
|
+
applies=lambda s: (s.task_type == "implementation"
|
|
874
|
+
and bool(s.approved_plan_path)
|
|
875
|
+
and not s.executor),
|
|
876
|
+
build=_build_executor, submit=_submit_executor,
|
|
877
|
+
owns=("executor",)),
|
|
878
|
+
Step(S_DEFAULTS_OR_CUSTOM,
|
|
879
|
+
applies=lambda s: (_identity_ready(s)
|
|
880
|
+
and s.use_defaults is None),
|
|
881
|
+
build=_build_defaults_or_custom, submit=_submit_defaults_or_custom,
|
|
882
|
+
owns=("use_defaults",)),
|
|
883
|
+
# Customize branch — workers override only when non-empty profile + not impl.
|
|
884
|
+
Step(S_WORKERS_OVERRIDE,
|
|
885
|
+
applies=lambda s: (s.use_defaults is False
|
|
886
|
+
and s.task_type != "implementation"
|
|
887
|
+
and bool(s.profile_workers)
|
|
888
|
+
and S_WORKERS_OVERRIDE not in s.answered),
|
|
889
|
+
build=_build_workers_override, submit=_submit_workers_override,
|
|
890
|
+
owns=("workers_override",)),
|
|
891
|
+
Step(S_LEAD_MODEL,
|
|
892
|
+
applies=lambda s: (s.use_defaults is False
|
|
893
|
+
and S_LEAD_MODEL not in s.answered),
|
|
894
|
+
build=_build_lead_model, submit=_submit_lead_model,
|
|
895
|
+
owns=("lead_model",)),
|
|
896
|
+
Step(S_EXECUTOR_MODEL,
|
|
897
|
+
applies=lambda s: (s.use_defaults is False
|
|
898
|
+
and s.task_type == "implementation"
|
|
899
|
+
and S_EXECUTOR_MODEL not in s.answered),
|
|
900
|
+
build=_build_executor_model, submit=_submit_executor_model,
|
|
901
|
+
owns=("claude_model", "codex_model", "gemini_model")),
|
|
902
|
+
Step(S_CLAUDE_MODEL,
|
|
903
|
+
applies=lambda s: (s.use_defaults is False
|
|
904
|
+
and s.task_type != "implementation"
|
|
905
|
+
and "claude" in _resolved_roster(s)
|
|
906
|
+
and S_CLAUDE_MODEL not in s.answered),
|
|
907
|
+
build=_build_claude_model, submit=_submit_claude_model,
|
|
908
|
+
owns=("claude_model",)),
|
|
909
|
+
Step(S_CODEX_MODEL,
|
|
910
|
+
applies=lambda s: (s.use_defaults is False
|
|
911
|
+
and s.task_type != "implementation"
|
|
912
|
+
and "codex" in _resolved_roster(s)
|
|
913
|
+
and S_CODEX_MODEL not in s.answered),
|
|
914
|
+
build=_build_codex_model, submit=_submit_codex_model,
|
|
915
|
+
owns=("codex_model",)),
|
|
916
|
+
Step(S_GEMINI_MODEL,
|
|
917
|
+
applies=lambda s: (s.use_defaults is False
|
|
918
|
+
and s.task_type != "implementation"
|
|
919
|
+
and "gemini" in _resolved_roster(s)
|
|
920
|
+
and S_GEMINI_MODEL not in s.answered),
|
|
921
|
+
build=_build_gemini_model, submit=_submit_gemini_model,
|
|
922
|
+
owns=("gemini_model",)),
|
|
923
|
+
Step(S_REPORT_WRITER_MODEL,
|
|
924
|
+
applies=lambda s: (s.use_defaults is False
|
|
925
|
+
and (s.task_type == "implementation"
|
|
926
|
+
or "report-writer" in _resolved_roster(s))
|
|
927
|
+
and S_REPORT_WRITER_MODEL not in s.answered),
|
|
928
|
+
build=_build_report_writer_model, submit=_submit_report_writer_model,
|
|
929
|
+
owns=("report_writer_model",)),
|
|
930
|
+
Step(S_DIRECTIVE,
|
|
931
|
+
applies=lambda s: (s.use_defaults is False
|
|
932
|
+
and S_DIRECTIVE not in s.answered),
|
|
933
|
+
build=_build_directive, submit=_submit_directive,
|
|
934
|
+
owns=("directive",)),
|
|
935
|
+
Step(S_RELATED_TASKS,
|
|
936
|
+
applies=lambda s: (s.use_defaults is False
|
|
937
|
+
and S_RELATED_TASKS not in s.answered),
|
|
938
|
+
build=_build_related_tasks, submit=_submit_related_tasks,
|
|
939
|
+
owns=("related_tasks_raw",)),
|
|
940
|
+
Step(S_CLARIFICATION,
|
|
941
|
+
applies=lambda s: (s.use_defaults is False
|
|
942
|
+
and S_CLARIFICATION not in s.answered),
|
|
943
|
+
build=_build_clarification, submit=_submit_clarification,
|
|
944
|
+
owns=("clarification_response_path",)),
|
|
945
|
+
Step(S_PR_TEMPLATE,
|
|
946
|
+
applies=lambda s: (s.use_defaults is False
|
|
947
|
+
and s.task_type == "release-handoff"
|
|
948
|
+
and S_PR_TEMPLATE not in s.answered),
|
|
949
|
+
build=_build_pr_template, submit=_submit_pr_template,
|
|
950
|
+
owns=("pr_template_path", "pr_template_scope")),
|
|
951
|
+
Step(S_PR_TEMPLATE_SCOPE,
|
|
952
|
+
applies=lambda s: (s.use_defaults is False
|
|
953
|
+
and s.task_type == "release-handoff"
|
|
954
|
+
and bool(s.pr_template_path)
|
|
955
|
+
and S_PR_TEMPLATE_SCOPE not in s.answered),
|
|
956
|
+
build=_build_pr_template_scope, submit=_submit_pr_template_scope,
|
|
957
|
+
owns=("pr_template_scope",)),
|
|
958
|
+
Step(S_CONFIRM,
|
|
959
|
+
applies=lambda s: _ready_for_confirm(s) and s.confirmed is None,
|
|
960
|
+
build=_build_confirm, submit=_submit_confirm,
|
|
961
|
+
owns=("confirmed", "edit_target")),
|
|
962
|
+
Step(S_EDIT_TARGET,
|
|
963
|
+
applies=lambda s: s.confirmed is False and not s.edit_target,
|
|
964
|
+
build=_build_edit_target, submit=_submit_edit_target,
|
|
965
|
+
owns=()),
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
STEP_BY_ID = {s.id: s for s in STEPS}
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _identity_ready(s: WizardState) -> bool:
|
|
972
|
+
"""All identity questions (task pick → executor for impl) answered."""
|
|
973
|
+
if not s.task_type:
|
|
974
|
+
return False
|
|
975
|
+
if not s.brief_path:
|
|
976
|
+
return False
|
|
977
|
+
if s.reuse_worktree is False and S_BASE_REF_PICK not in s.answered:
|
|
978
|
+
return False
|
|
979
|
+
if s.base_ref_pending_text:
|
|
980
|
+
return False
|
|
981
|
+
if s.task_type == "implementation":
|
|
982
|
+
if not s.approved_plan_path or not s.executor:
|
|
983
|
+
return False
|
|
984
|
+
return True
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _ready_for_confirm(s: WizardState) -> bool:
|
|
988
|
+
if s.use_defaults is None:
|
|
989
|
+
return False
|
|
990
|
+
if s.use_defaults:
|
|
991
|
+
return True
|
|
992
|
+
# customize: every customize-branch step must be answered or not-applicable.
|
|
993
|
+
custom_ids = [S_WORKERS_OVERRIDE, S_LEAD_MODEL, S_EXECUTOR_MODEL,
|
|
994
|
+
S_CLAUDE_MODEL, S_CODEX_MODEL, S_GEMINI_MODEL,
|
|
995
|
+
S_REPORT_WRITER_MODEL, S_DIRECTIVE, S_RELATED_TASKS,
|
|
996
|
+
S_CLARIFICATION, S_PR_TEMPLATE, S_PR_TEMPLATE_SCOPE]
|
|
997
|
+
for sid in custom_ids:
|
|
998
|
+
step = STEP_BY_ID[sid]
|
|
999
|
+
if step.applies(s):
|
|
1000
|
+
return False
|
|
1001
|
+
return True
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _reset_from(state: WizardState, target_step: str) -> None:
|
|
1005
|
+
"""Clear state owned by target_step and all later steps; remove their
|
|
1006
|
+
entries from `answered`. Used when the user picks Edit."""
|
|
1007
|
+
idx = next((i for i, s in enumerate(STEPS) if s.id == target_step), -1)
|
|
1008
|
+
if idx < 0:
|
|
1009
|
+
return
|
|
1010
|
+
cleared_ids: set[str] = set()
|
|
1011
|
+
for step in STEPS[idx:]:
|
|
1012
|
+
cleared_ids.add(step.id)
|
|
1013
|
+
for fname in step.owns:
|
|
1014
|
+
_reset_field(state, fname)
|
|
1015
|
+
state.answered = [a for a in state.answered if a not in cleared_ids]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
_FIELD_DEFAULTS: dict[str, Any] = {
|
|
1019
|
+
"is_new_task": None, "task_group": "", "task_id": "",
|
|
1020
|
+
"existing_brief_path": "", "task_type": "",
|
|
1021
|
+
"profile_workers": [], "keep_existing_brief": None,
|
|
1022
|
+
"brief_path": "", "reuse_worktree": None, "base_ref": "",
|
|
1023
|
+
"base_ref_pending_text": False, "approved_plan_path": "",
|
|
1024
|
+
"executor": "", "use_defaults": None, "workers_override": "",
|
|
1025
|
+
"lead_model": "", "claude_model": "", "codex_model": "",
|
|
1026
|
+
"gemini_model": "", "report_writer_model": "", "directive": "",
|
|
1027
|
+
"related_tasks_raw": "", "clarification_response_path": "",
|
|
1028
|
+
"pr_template_path": "", "pr_template_scope": "",
|
|
1029
|
+
"confirmed": None, "edit_target": "",
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _reset_field(state: WizardState, fname: str) -> None:
|
|
1034
|
+
if fname in _FIELD_DEFAULTS:
|
|
1035
|
+
default = _FIELD_DEFAULTS[fname]
|
|
1036
|
+
# copy mutable defaults
|
|
1037
|
+
if isinstance(default, list):
|
|
1038
|
+
setattr(state, fname, [])
|
|
1039
|
+
else:
|
|
1040
|
+
setattr(state, fname, default)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# ---- Public API ---------------------------------------------------------
|
|
1044
|
+
|
|
1045
|
+
def init_state(
|
|
1046
|
+
*, workspace_root: str, project_root: str, project_id: str
|
|
1047
|
+
) -> WizardState:
|
|
1048
|
+
"""Bootstrap a new wizard state."""
|
|
1049
|
+
return WizardState(
|
|
1050
|
+
workspace_root=workspace_root,
|
|
1051
|
+
project_root=project_root,
|
|
1052
|
+
project_id=project_id,
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def next_prompt(state: WizardState) -> Prompt:
|
|
1057
|
+
if state.confirmed:
|
|
1058
|
+
return Prompt(step=S_DONE, kind="done")
|
|
1059
|
+
for step in STEPS:
|
|
1060
|
+
if step.id in state.answered:
|
|
1061
|
+
continue
|
|
1062
|
+
if step.applies(state):
|
|
1063
|
+
return step.build(state)
|
|
1064
|
+
return Prompt(step=S_DONE, kind="done")
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
1068
|
+
"""Validate the answer for the *currently active* step and advance.
|
|
1069
|
+
|
|
1070
|
+
Returns {"echo": "...", "next": <Prompt JSON>}. Raises WizardError on
|
|
1071
|
+
validation failure (caller may re-prompt).
|
|
1072
|
+
"""
|
|
1073
|
+
prompt = next_prompt(state)
|
|
1074
|
+
if prompt.kind == "done":
|
|
1075
|
+
return {"echo": "", "next": prompt.to_json()}
|
|
1076
|
+
step = STEP_BY_ID[prompt.step]
|
|
1077
|
+
echo = step.submit(state, value or "")
|
|
1078
|
+
if prompt.step not in state.answered:
|
|
1079
|
+
state.answered.append(prompt.step)
|
|
1080
|
+
nxt = next_prompt(state)
|
|
1081
|
+
return {"echo": echo or "", "next": nxt.to_json()}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def render_args(state: WizardState) -> dict[str, str]:
|
|
1085
|
+
"""Convert finalized state into ``okstra render-bundle`` argument map."""
|
|
1086
|
+
workers = state.workers_override.strip()
|
|
1087
|
+
if state.task_type == "implementation":
|
|
1088
|
+
workers = "" # profile-default roster is mandatory for impl
|
|
1089
|
+
base_ref = "" if state.reuse_worktree else state.base_ref
|
|
1090
|
+
pr_template = (
|
|
1091
|
+
state.pr_template_path
|
|
1092
|
+
if state.task_type == "release-handoff"
|
|
1093
|
+
else ""
|
|
1094
|
+
)
|
|
1095
|
+
return {
|
|
1096
|
+
"project-root": state.project_root,
|
|
1097
|
+
"project-id": state.project_id,
|
|
1098
|
+
"task-group": state.task_group,
|
|
1099
|
+
"task-id": state.task_id,
|
|
1100
|
+
"task-type": state.task_type,
|
|
1101
|
+
"task-brief": state.brief_path,
|
|
1102
|
+
"executor": state.executor,
|
|
1103
|
+
"approved-plan": state.approved_plan_path,
|
|
1104
|
+
"base-ref": base_ref,
|
|
1105
|
+
"workers": workers,
|
|
1106
|
+
"directive": state.directive,
|
|
1107
|
+
"lead-model": state.lead_model,
|
|
1108
|
+
"claude-model": state.claude_model,
|
|
1109
|
+
"codex-model": state.codex_model,
|
|
1110
|
+
"gemini-model": state.gemini_model,
|
|
1111
|
+
"report-writer-model": state.report_writer_model,
|
|
1112
|
+
"related-tasks": state.related_tasks_raw,
|
|
1113
|
+
"clarification-response": state.clarification_response_path,
|
|
1114
|
+
"pr-template-path": pr_template,
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def confirmation_block(state: WizardState) -> str:
|
|
1119
|
+
"""Human-readable echo of the resolved selections (for the Confirm step)."""
|
|
1120
|
+
lines: list[str] = ["선택 확인:"]
|
|
1121
|
+
lines.append(f" task-type : {state.task_type}")
|
|
1122
|
+
lines.append(f" task-key : {state.task_group}/{state.task_id}")
|
|
1123
|
+
if state.reuse_worktree:
|
|
1124
|
+
lines.append(" base-ref : (reusing existing worktree)")
|
|
1125
|
+
else:
|
|
1126
|
+
lines.append(f" base-ref : {state.base_ref}")
|
|
1127
|
+
if state.task_type == "implementation":
|
|
1128
|
+
lines.append(f" executor : {state.executor or '(default)'}")
|
|
1129
|
+
lines.append(
|
|
1130
|
+
f" workers : (프로필 기본 — executor + verifier 2 + report-writer)"
|
|
1131
|
+
)
|
|
1132
|
+
else:
|
|
1133
|
+
roster = state.workers_override or ",".join(state.profile_workers) or "(profile default)"
|
|
1134
|
+
lines.append(f" workers : {roster}")
|
|
1135
|
+
lines.append(f" lead-model : {state.lead_model or 'default'}")
|
|
1136
|
+
if state.claude_model:
|
|
1137
|
+
lines.append(f" claude-model : {state.claude_model}")
|
|
1138
|
+
if state.codex_model:
|
|
1139
|
+
lines.append(f" codex-model : {state.codex_model}")
|
|
1140
|
+
if state.gemini_model:
|
|
1141
|
+
lines.append(f" gemini-model : {state.gemini_model}")
|
|
1142
|
+
if state.report_writer_model:
|
|
1143
|
+
lines.append(f" report-writer : {state.report_writer_model}")
|
|
1144
|
+
lines.append(f" directive : {state.directive or '(none)'}")
|
|
1145
|
+
if state.task_type == "implementation":
|
|
1146
|
+
lines.append(f" approved-plan : {state.approved_plan_path}")
|
|
1147
|
+
if state.clarification_response_path:
|
|
1148
|
+
lines.append(f" clarification : {state.clarification_response_path}")
|
|
1149
|
+
if state.task_type == "release-handoff" and state.pr_template_path:
|
|
1150
|
+
lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
|
|
1151
|
+
return "\n".join(lines)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# ---- File I/O helpers (used by CLI) -------------------------------------
|
|
1155
|
+
|
|
1156
|
+
def load_state_file(path: Path) -> WizardState:
|
|
1157
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
1158
|
+
return WizardState.from_json(data)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def save_state_file(path: Path, state: WizardState) -> None:
|
|
1162
|
+
Path(path).write_text(
|
|
1163
|
+
json.dumps(state.to_json(), indent=2, ensure_ascii=False),
|
|
1164
|
+
encoding="utf-8",
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
# ---- CLI entrypoint -----------------------------------------------------
|
|
1169
|
+
|
|
1170
|
+
def _cli(argv: list[str]) -> int:
|
|
1171
|
+
"""``python3 -m okstra_ctl.wizard <subcmd>`` driver.
|
|
1172
|
+
|
|
1173
|
+
Subcommands:
|
|
1174
|
+
init --state-file PATH --workspace-root P --project-root P --project-id ID
|
|
1175
|
+
step --state-file PATH [--answer VALUE]
|
|
1176
|
+
render-args --state-file PATH
|
|
1177
|
+
confirmation --state-file PATH
|
|
1178
|
+
"""
|
|
1179
|
+
import argparse
|
|
1180
|
+
|
|
1181
|
+
parser = argparse.ArgumentParser(prog="okstra_ctl.wizard")
|
|
1182
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1183
|
+
|
|
1184
|
+
p_init = sub.add_parser("init")
|
|
1185
|
+
p_init.add_argument("--state-file", required=True)
|
|
1186
|
+
p_init.add_argument("--workspace-root", required=True)
|
|
1187
|
+
p_init.add_argument("--project-root", required=True)
|
|
1188
|
+
p_init.add_argument("--project-id", required=True)
|
|
1189
|
+
|
|
1190
|
+
p_step = sub.add_parser("step")
|
|
1191
|
+
p_step.add_argument("--state-file", required=True)
|
|
1192
|
+
p_step.add_argument("--answer", default=None)
|
|
1193
|
+
|
|
1194
|
+
p_render = sub.add_parser("render-args")
|
|
1195
|
+
p_render.add_argument("--state-file", required=True)
|
|
1196
|
+
|
|
1197
|
+
p_conf = sub.add_parser("confirmation")
|
|
1198
|
+
p_conf.add_argument("--state-file", required=True)
|
|
1199
|
+
|
|
1200
|
+
args = parser.parse_args(argv)
|
|
1201
|
+
state_path = Path(args.state_file)
|
|
1202
|
+
|
|
1203
|
+
if args.cmd == "init":
|
|
1204
|
+
state = init_state(
|
|
1205
|
+
workspace_root=args.workspace_root,
|
|
1206
|
+
project_root=args.project_root,
|
|
1207
|
+
project_id=args.project_id,
|
|
1208
|
+
)
|
|
1209
|
+
save_state_file(state_path, state)
|
|
1210
|
+
first = next_prompt(state)
|
|
1211
|
+
print(json.dumps({"ok": True, "next": first.to_json()},
|
|
1212
|
+
ensure_ascii=False, indent=2))
|
|
1213
|
+
return 0
|
|
1214
|
+
|
|
1215
|
+
if args.cmd == "step":
|
|
1216
|
+
state = load_state_file(state_path)
|
|
1217
|
+
try:
|
|
1218
|
+
if args.answer is None:
|
|
1219
|
+
result = {"echo": "", "next": next_prompt(state).to_json()}
|
|
1220
|
+
else:
|
|
1221
|
+
result = submit(state, args.answer)
|
|
1222
|
+
except WizardError as exc:
|
|
1223
|
+
save_state_file(state_path, state)
|
|
1224
|
+
print(json.dumps({"ok": False, "error": str(exc),
|
|
1225
|
+
"current": next_prompt(state).to_json()},
|
|
1226
|
+
ensure_ascii=False, indent=2))
|
|
1227
|
+
return 0
|
|
1228
|
+
save_state_file(state_path, state)
|
|
1229
|
+
print(json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2))
|
|
1230
|
+
return 0
|
|
1231
|
+
|
|
1232
|
+
if args.cmd == "render-args":
|
|
1233
|
+
state = load_state_file(state_path)
|
|
1234
|
+
print(json.dumps({"ok": True, "args": render_args(state)},
|
|
1235
|
+
ensure_ascii=False, indent=2))
|
|
1236
|
+
return 0
|
|
1237
|
+
|
|
1238
|
+
if args.cmd == "confirmation":
|
|
1239
|
+
state = load_state_file(state_path)
|
|
1240
|
+
print(json.dumps({"ok": True, "text": confirmation_block(state)},
|
|
1241
|
+
ensure_ascii=False, indent=2))
|
|
1242
|
+
return 0
|
|
1243
|
+
|
|
1244
|
+
return 2
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
if __name__ == "__main__":
|
|
1248
|
+
import sys
|
|
1249
|
+
raise SystemExit(_cli(sys.argv[1:]))
|