okstra 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +8 -7
- package/README.md +8 -7
- package/bin/okstra +2 -0
- package/docs/kr/architecture.md +23 -24
- package/docs/kr/cli.md +6 -6
- package/docs/project-structure-overview.md +13 -9
- package/docs/superpowers/plans/2026-06-05-wizard-batch-prompts.md +559 -0
- package/docs/superpowers/specs/2026-06-05-wizard-batch-prompts-design.md +121 -0
- package/docs/task-process/error-analysis.md +1 -1
- package/docs/task-process/final-verification.md +1 -1
- package/docs/task-process/release-handoff.md +1 -1
- package/docs/task-process/requirements-discovery.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +18 -14
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +3 -3
- package/runtime/bin/lib/okstra/cli.sh +8 -1
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/interactive.sh +14 -12
- package/runtime/bin/lib/okstra/usage.sh +6 -0
- package/runtime/bin/okstra-render-report-views.py +1 -1
- package/runtime/bin/okstra-team-reconcile.sh +28 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/launch.template.md +4 -2
- package/runtime/prompts/profiles/_common-contract.md +15 -15
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +3 -3
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +10 -9
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/prompts/profiles/improvement-discovery.md +5 -5
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/prompts/profiles/requirements-discovery.md +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
- package/runtime/python/okstra_ctl/clarification_items.py +11 -11
- package/runtime/python/okstra_ctl/context_cost.py +308 -0
- package/runtime/python/okstra_ctl/migrate.py +2 -12
- package/runtime/python/okstra_ctl/paths.py +22 -0
- package/runtime/python/okstra_ctl/render.py +285 -126
- package/runtime/python/okstra_ctl/render_final_report.py +32 -1
- package/runtime/python/okstra_ctl/report_views.py +12 -12
- package/runtime/python/okstra_ctl/run.py +510 -248
- package/runtime/python/okstra_ctl/sequence.py +2 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
- package/runtime/python/okstra_ctl/wizard.py +219 -136
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/worktree.py +13 -5
- package/runtime/schemas/final-report-v1.0.schema.json +4 -0
- package/runtime/skills/okstra-brief/SKILL.md +1 -1
- package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
- package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
- package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
- package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
- package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
- package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
- package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
- package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
- package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
- package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
- package/runtime/skills/okstra-convergence/SKILL.md +8 -8
- package/runtime/skills/okstra-inspect/SKILL.md +100 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +27 -23
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +8 -5
- package/runtime/templates/reports/final-report.template.md +188 -187
- package/runtime/templates/reports/i18n/en.json +4 -4
- package/runtime/templates/reports/i18n/ko.json +4 -4
- package/runtime/templates/reports/implementation-planning-input.template.md +1 -1
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/templates/reports/user-response.template.md +1 -1
- package/runtime/templates/worker-prompt-preamble.md +4 -4
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/validate-implementation-plan-stages.py +9 -9
- package/runtime/validators/validate-report-views.py +10 -10
- package/runtime/validators/validate-run.py +36 -36
- package/runtime/validators/validate_improvement_report.py +8 -8
- package/src/_python-helper.mjs +3 -3
- package/src/context-cost.mjs +27 -0
- package/src/install.mjs +1 -0
- package/src/uninstall.mjs +1 -0
|
@@ -5,10 +5,9 @@ import re as _re
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Optional
|
|
7
7
|
|
|
8
|
-
from okstra_project.dirs import tasks_root
|
|
9
|
-
|
|
10
8
|
from .ids import slugify_task_segment
|
|
11
9
|
from .jsonl import read_jsonl
|
|
10
|
+
from .paths import task_runs_dir
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
|
|
@@ -26,9 +25,7 @@ def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
|
|
|
26
25
|
이 셋의 max + 1 이 안전한 다음 seq.
|
|
27
26
|
"""
|
|
28
27
|
task_type_segment = slugify_task_segment(task_type)
|
|
29
|
-
base = (
|
|
30
|
-
/ slugify_task_segment(task_group) / slugify_task_segment(task_id)
|
|
31
|
-
/ "runs" / task_type_segment)
|
|
28
|
+
base = task_runs_dir(project_root, task_group, task_id) / task_type_segment
|
|
32
29
|
max_seq = 0
|
|
33
30
|
rep_pat = _re.compile(rf"^final-report-{_re.escape(task_type_segment)}-(\d+)\.md$")
|
|
34
31
|
man_pat = _re.compile(rf"^run-manifest-{_re.escape(task_type_segment)}-(\d+)\.json$")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Stale team-member reconciliation for run-end teardown.
|
|
2
|
+
|
|
3
|
+
A Claude Code team member clears its own ``isActive`` flag in
|
|
4
|
+
``~/.claude/teams/<team>/config.json`` when its ``Agent()`` dispatch returns, so
|
|
5
|
+
by Phase 7 every worker is normally already inactive and ``TeamDelete()``
|
|
6
|
+
succeeds immediately. The one failure mode is a member whose tmux pane died
|
|
7
|
+
WITHOUT clearing the flag (killed mid-turn): it stays ``isActive: true`` forever,
|
|
8
|
+
and ``TeamDelete`` then refuses the whole team with an "active members" error
|
|
9
|
+
that re-sending ``shutdown_request`` cannot clear — the addressee is already
|
|
10
|
+
gone.
|
|
11
|
+
|
|
12
|
+
This module reconciles exactly that case: a member with ``isActive`` truthy
|
|
13
|
+
whose recorded tmux pane is no longer live is flipped to inactive. It NEVER
|
|
14
|
+
touches a member whose pane is still live, the lead, or a member with no
|
|
15
|
+
recorded pane — those are left for graceful shutdown.
|
|
16
|
+
|
|
17
|
+
Note: the active-member flag is the harness's source of truth, but whether
|
|
18
|
+
``TeamDelete`` re-reads ``config.json`` at delete time (vs. caching the roster in
|
|
19
|
+
the owning session) is a harness behaviour that can only be confirmed inside a
|
|
20
|
+
real run — see _common-contract.md "Run-end team teardown".
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def live_pane_ids() -> tuple[bool, set[str]]:
|
|
33
|
+
"""Return ``(tmux_available, live_pane_ids)``.
|
|
34
|
+
|
|
35
|
+
``tmux_available`` is False when no tmux server is reachable; the caller must
|
|
36
|
+
then skip reconciliation rather than treat every pane as dead.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
out = subprocess.run(
|
|
40
|
+
["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
|
|
41
|
+
capture_output=True, text=True,
|
|
42
|
+
)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return False, set()
|
|
45
|
+
if out.returncode != 0:
|
|
46
|
+
return False, set()
|
|
47
|
+
return True, {ln.strip() for ln in out.stdout.splitlines() if ln.strip()}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
|
|
51
|
+
"""Flip dead-pane stale-active members to inactive in-place.
|
|
52
|
+
|
|
53
|
+
Returns the names of members that were flipped. A member qualifies only when
|
|
54
|
+
it is not the lead, ``isActive`` is truthy, it has a recorded ``tmuxPaneId``,
|
|
55
|
+
and that pane is not among ``live_panes``.
|
|
56
|
+
"""
|
|
57
|
+
lead = config.get("leadAgentId")
|
|
58
|
+
flipped: list[str] = []
|
|
59
|
+
for member in config.get("members", []):
|
|
60
|
+
if member.get("agentId") == lead:
|
|
61
|
+
continue
|
|
62
|
+
pane = (member.get("tmuxPaneId") or "").strip()
|
|
63
|
+
if not member.get("isActive") or not pane:
|
|
64
|
+
continue
|
|
65
|
+
if pane not in live_panes:
|
|
66
|
+
member["isActive"] = False
|
|
67
|
+
flipped.append(member.get("name", member.get("agentId", "?")))
|
|
68
|
+
return flipped
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _config_path(team_arg: str) -> Path:
|
|
72
|
+
"""Resolve a team name, a team dir, or a config.json path to the config file."""
|
|
73
|
+
p = Path(team_arg)
|
|
74
|
+
if p.name == "config.json":
|
|
75
|
+
return p
|
|
76
|
+
if p.is_dir():
|
|
77
|
+
return p / "config.json"
|
|
78
|
+
return Path.home() / ".claude" / "teams" / team_arg / "config.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
82
|
+
"""Replace ``path`` atomically — config.json is load-bearing for the next
|
|
83
|
+
TeamDelete, so a partial write on crash must never be observable."""
|
|
84
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".reconcile-")
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(fd, "w") as handle:
|
|
87
|
+
handle.write(text)
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
except BaseException:
|
|
90
|
+
try:
|
|
91
|
+
os.unlink(tmp)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str]) -> int:
|
|
98
|
+
dry_run = False
|
|
99
|
+
rest: list[str] = []
|
|
100
|
+
for arg in argv:
|
|
101
|
+
if arg in ("--list", "--dry-run"):
|
|
102
|
+
dry_run = True
|
|
103
|
+
else:
|
|
104
|
+
rest.append(arg)
|
|
105
|
+
if len(rest) != 1:
|
|
106
|
+
print("usage: okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>",
|
|
107
|
+
file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
cfg_path = _config_path(rest[0])
|
|
111
|
+
if not cfg_path.is_file():
|
|
112
|
+
print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
config = json.loads(cfg_path.read_text())
|
|
116
|
+
available, live = live_pane_ids()
|
|
117
|
+
if not available:
|
|
118
|
+
print("team-reconcile: tmux unavailable — skipped (no flip)")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
flipped = reconcile_members(config, live)
|
|
122
|
+
if not flipped:
|
|
123
|
+
print("team-reconcile: no stale-active members")
|
|
124
|
+
return 0
|
|
125
|
+
if dry_run:
|
|
126
|
+
print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
_atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
|
|
130
|
+
print(f"team-reconcile: deactivated {len(flipped)} stale member(s): " + ", ".join(flipped))
|
|
131
|
+
return 0
|
|
@@ -46,8 +46,13 @@ from okstra_ctl.workers import (
|
|
|
46
46
|
validate_workers_against_profile,
|
|
47
47
|
)
|
|
48
48
|
from okstra_ctl import worktree_registry
|
|
49
|
-
from okstra_ctl.worktree import
|
|
50
|
-
|
|
49
|
+
from okstra_ctl.worktree import (
|
|
50
|
+
is_git_work_tree,
|
|
51
|
+
main_worktree_path,
|
|
52
|
+
preview_worktree_decision,
|
|
53
|
+
)
|
|
54
|
+
from okstra_ctl.paths import task_runs_dir
|
|
55
|
+
from okstra_project.dirs import project_json_path
|
|
51
56
|
from okstra_project.state import (
|
|
52
57
|
StateError,
|
|
53
58
|
list_project_tasks,
|
|
@@ -205,6 +210,28 @@ S_CONFIRM = "confirm"
|
|
|
205
210
|
S_EDIT_TARGET = "edit_target"
|
|
206
211
|
S_DONE = "done"
|
|
207
212
|
|
|
213
|
+
# ---- 멀티탭 배치 프롬프트 그룹 (방출 계층 전용) ----
|
|
214
|
+
# 그룹 id 는 S_* 가 아니므로 prompts JSON SOT / step-id 동기화 검사 대상이 아니다.
|
|
215
|
+
GROUP_MODELS = "models"
|
|
216
|
+
GROUP_OPTIONS = "options"
|
|
217
|
+
GROUP_MAX_TABS = 4 # AskUserQuestion 의 질문(탭) 수 한도
|
|
218
|
+
|
|
219
|
+
# 멤버는 모두 서로 의존이 없는 단일선택 픽 step 이어야 한다.
|
|
220
|
+
# *_TEXT 후속 / workers_override / pr_template_scope 는 의존성 때문에 개별 유지.
|
|
221
|
+
PROMPT_GROUPS: dict[str, tuple[str, ...]] = {
|
|
222
|
+
GROUP_MODELS: (S_LEAD_MODEL, S_EXECUTOR_MODEL, S_CLAUDE_MODEL,
|
|
223
|
+
S_CODEX_MODEL, S_GEMINI_MODEL, S_REPORT_WRITER_MODEL),
|
|
224
|
+
GROUP_OPTIONS: (S_DIRECTIVE_PICK, S_RELATED_TASKS_PICK,
|
|
225
|
+
S_CLARIFICATION_PICK, S_PR_TEMPLATE_PICK),
|
|
226
|
+
}
|
|
227
|
+
GROUP_LABELS: dict[str, str] = {
|
|
228
|
+
GROUP_MODELS: "모델 선택 (탭별로 선택)",
|
|
229
|
+
GROUP_OPTIONS: "추가 옵션 (탭별로 선택)",
|
|
230
|
+
}
|
|
231
|
+
_STEP_TO_GROUP: dict[str, str] = {
|
|
232
|
+
sid: gid for gid, ids in PROMPT_GROUPS.items() for sid in ids
|
|
233
|
+
}
|
|
234
|
+
|
|
208
235
|
|
|
209
236
|
# ---- Data types ----------------------------------------------------------
|
|
210
237
|
|
|
@@ -305,9 +332,11 @@ class Prompt:
|
|
|
305
332
|
help: str = ""
|
|
306
333
|
echo_template: str = "" # e.g. "task-group: {value}"
|
|
307
334
|
multi: bool = False # only meaningful when kind == "pick"
|
|
335
|
+
# only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
|
|
336
|
+
questions: list["Prompt"] = field(default_factory=list)
|
|
308
337
|
|
|
309
338
|
def to_json(self) -> dict[str, Any]:
|
|
310
|
-
|
|
339
|
+
out = {
|
|
311
340
|
"step": self.step,
|
|
312
341
|
"kind": self.kind,
|
|
313
342
|
"label": self.label,
|
|
@@ -316,6 +345,14 @@ class Prompt:
|
|
|
316
345
|
"echoTemplate": self.echo_template,
|
|
317
346
|
"multi": self.multi,
|
|
318
347
|
}
|
|
348
|
+
if self.kind == "pick_group":
|
|
349
|
+
out["questions"] = [
|
|
350
|
+
{"step": q.step, "label": q.label,
|
|
351
|
+
"options": [asdict(o) for o in q.options],
|
|
352
|
+
"multi": q.multi}
|
|
353
|
+
for q in self.questions
|
|
354
|
+
]
|
|
355
|
+
return out
|
|
319
356
|
|
|
320
357
|
|
|
321
358
|
class WizardError(Exception):
|
|
@@ -373,12 +410,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
|
373
410
|
" edit the report and change the line to `approved: true`, or re-run "
|
|
374
411
|
"okstra with `--approve` to flip it from the CLI."
|
|
375
412
|
)
|
|
376
|
-
# frontmatter approved == true 라도 §
|
|
413
|
+
# frontmatter approved == true 라도 §1 의 Blocks=approval 행이 미해결이면
|
|
377
414
|
# 승인이 무효 — prepare_task_bundle 의 _validate_approved_plan 과 동일 규약.
|
|
378
415
|
blockers = unresolved_approval_blockers(body)
|
|
379
416
|
if blockers:
|
|
380
417
|
lines = [
|
|
381
|
-
f"approved plan frontmatter has `approved: true` but §
|
|
418
|
+
f"approved plan frontmatter has `approved: true` but §1 has {len(blockers)} "
|
|
382
419
|
f"unresolved `Blocks=approval` row(s); resolve them or mark them obsolete first:",
|
|
383
420
|
]
|
|
384
421
|
for b in blockers:
|
|
@@ -389,15 +426,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
|
|
|
389
426
|
|
|
390
427
|
|
|
391
428
|
def _git_main_worktree(project_root: Path) -> Path:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
|
399
|
-
raise WizardError(f"git unavailable in {project_root}: {exc}")
|
|
400
|
-
return Path(common).parent
|
|
429
|
+
# main worktree 해소는 worktree 모듈의 public seam(main_worktree_path)에
|
|
430
|
+
# 위임한다. base-ref 검증은 git 이 없으면 의미가 없으므로, 자체 메시지로
|
|
431
|
+
# 먼저 fail-fast 한다 (과거 자체 `--git-common-dir` 구현을 대체).
|
|
432
|
+
if not is_git_work_tree(project_root):
|
|
433
|
+
raise WizardError(f"git unavailable or not a work tree in {project_root}")
|
|
434
|
+
return main_worktree_path(project_root)
|
|
401
435
|
|
|
402
436
|
|
|
403
437
|
def _validate_base_ref(ref: str, project_root: Path) -> str:
|
|
@@ -1075,10 +1109,8 @@ def _list_implementation_planning_reports(
|
|
|
1075
1109
|
# Run seq lives in the filename, not a per-run subdirectory: every
|
|
1076
1110
|
# implementation-planning run writes into the same flat `reports/`
|
|
1077
1111
|
# dir (see paths.py — `run_reports = runs/<task-type>/reports`).
|
|
1078
|
-
reports_dir = (
|
|
1079
|
-
/
|
|
1080
|
-
/ slugify_task_segment(state.task_id)
|
|
1081
|
-
/ "runs" / "implementation-planning" / "reports")
|
|
1112
|
+
reports_dir = (task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1113
|
+
/ "implementation-planning" / "reports")
|
|
1082
1114
|
if not reports_dir.is_dir():
|
|
1083
1115
|
return []
|
|
1084
1116
|
pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
|
|
@@ -1224,9 +1256,7 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
1224
1256
|
"""
|
|
1225
1257
|
if not state.task_group or not state.task_id or not state.project_root:
|
|
1226
1258
|
return ""
|
|
1227
|
-
runs_base = (
|
|
1228
|
-
/ slugify_task_segment(state.task_group)
|
|
1229
|
-
/ slugify_task_segment(state.task_id) / "runs")
|
|
1259
|
+
runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1230
1260
|
if not runs_base.is_dir():
|
|
1231
1261
|
return ""
|
|
1232
1262
|
candidates = [
|
|
@@ -1253,9 +1283,7 @@ def _suggest_last_directive(state: WizardState) -> str:
|
|
|
1253
1283
|
"""같은 task 의 가장 최근 run-inputs-*.json 에서 directive 값을 자동 추출."""
|
|
1254
1284
|
if not state.task_group or not state.task_id or not state.project_root:
|
|
1255
1285
|
return ""
|
|
1256
|
-
runs_base = (
|
|
1257
|
-
/ slugify_task_segment(state.task_group)
|
|
1258
|
-
/ slugify_task_segment(state.task_id) / "runs")
|
|
1286
|
+
runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
|
|
1259
1287
|
if not runs_base.is_dir():
|
|
1260
1288
|
return ""
|
|
1261
1289
|
candidates: list[tuple[float, Path]] = []
|
|
@@ -1279,43 +1307,96 @@ def _suggest_last_directive(state: WizardState) -> str:
|
|
|
1279
1307
|
return val if isinstance(val, str) else ""
|
|
1280
1308
|
|
|
1281
1309
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1310
|
+
# ---------------------------------------------------------------------------
|
|
1311
|
+
# "optional cached pick" seam.
|
|
1312
|
+
#
|
|
1313
|
+
# directive / related-tasks / clarification / pr-template 단계는 모두 동일한
|
|
1314
|
+
# 3-옵션 picker 구조를 갖는다: [건너뛰기 / 추천값(이전 directive·siblings·최근
|
|
1315
|
+
# 리포트·프로젝트 기본) / 직접 입력]. 추천값은 디스크에서 suggest 하고, 선택 시
|
|
1316
|
+
# state 의 cache 필드에 담아 submit 에서 꺼낸다. 과거에는 이 구조가 네 곳에
|
|
1317
|
+
# 기계적으로 복제돼 한 곳을 고치면 나머지가 drift 했다. 변이점만 담은 선언적
|
|
1318
|
+
# spec + 제네릭 build/submit 으로 단일 seam 화한다.
|
|
1319
|
+
# ---------------------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
@dataclass(frozen=True)
|
|
1323
|
+
class _OptionalCachedPickSpec:
|
|
1324
|
+
step: str
|
|
1325
|
+
prompt_key: str # _p() 의 step_id
|
|
1326
|
+
recommend_token: str # 추천 옵션의 sentinel value
|
|
1327
|
+
label_key: str # t["labels"] 의 추천 라벨 키
|
|
1328
|
+
echo_suffix_key: str # t["echo_suffixes"] 의 추천 echo 키
|
|
1329
|
+
suggest: Callable[[WizardState], str]
|
|
1330
|
+
snippet_style: str # "prefix" (앞 60자+…) | "suffix" (…+뒤 60자)
|
|
1331
|
+
cache_attr: str # 추천값을 담아둘 state 속성
|
|
1332
|
+
target_attr: str # 확정값을 쓸 state 속성
|
|
1333
|
+
pending_attr: str # 직접 입력 대기 플래그 state 속성
|
|
1334
|
+
extra_clear_attrs: tuple[str, ...] = () # skip 시 추가로 비울 속성
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _pick_snippet(value: str, style: str) -> str:
|
|
1338
|
+
if len(value) <= 60:
|
|
1339
|
+
return value
|
|
1340
|
+
return value[:60] + "…" if style == "prefix" else "…" + value[-60:]
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
|
|
1344
|
+
suggestion = spec.suggest(state)
|
|
1345
|
+
t = _p(state.workspace_root, spec.prompt_key)
|
|
1346
|
+
options: list[Option] = [_opt(PICK_SKIP, t["options"][PICK_SKIP])]
|
|
1347
|
+
if suggestion:
|
|
1348
|
+
snippet = _pick_snippet(suggestion, spec.snippet_style)
|
|
1349
|
+
options.append(_opt(spec.recommend_token, t["labels"][spec.label_key].format(snippet=snippet)))
|
|
1350
|
+
setattr(state, spec.cache_attr, suggestion)
|
|
1292
1351
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1293
1352
|
return Prompt(
|
|
1294
|
-
step=
|
|
1353
|
+
step=spec.step, kind="pick",
|
|
1295
1354
|
label=t["label"], options=options,
|
|
1296
1355
|
echo_template=t["echo_template"],
|
|
1297
1356
|
)
|
|
1298
1357
|
|
|
1299
1358
|
|
|
1300
|
-
def
|
|
1301
|
-
|
|
1359
|
+
def _submit_optional_cached_pick(
|
|
1360
|
+
state: WizardState, value: str, spec: _OptionalCachedPickSpec
|
|
1361
|
+
) -> Optional[str]:
|
|
1362
|
+
t = _p(state.workspace_root, spec.prompt_key)
|
|
1302
1363
|
if value == PICK_SKIP:
|
|
1303
|
-
state.
|
|
1304
|
-
|
|
1364
|
+
setattr(state, spec.target_attr, "")
|
|
1365
|
+
for attr in spec.extra_clear_attrs:
|
|
1366
|
+
setattr(state, attr, "")
|
|
1367
|
+
setattr(state, spec.pending_attr, False)
|
|
1305
1368
|
return t["echo_suffixes"]["skip"]
|
|
1306
|
-
if value ==
|
|
1307
|
-
|
|
1308
|
-
state.
|
|
1309
|
-
|
|
1369
|
+
if value == spec.recommend_token:
|
|
1370
|
+
cached = getattr(state, spec.cache_attr)
|
|
1371
|
+
setattr(state, spec.target_attr, cached)
|
|
1372
|
+
setattr(state, spec.pending_attr, False)
|
|
1373
|
+
return t["echo_suffixes"][spec.echo_suffix_key].format(value=cached)
|
|
1310
1374
|
if value == PICK_TYPE_CUSTOM:
|
|
1311
|
-
state.
|
|
1375
|
+
setattr(state, spec.pending_attr, True)
|
|
1312
1376
|
return None
|
|
1313
1377
|
raise WizardError(
|
|
1314
|
-
f"unexpected
|
|
1315
|
-
f"(expected {PICK_SKIP!r}, {
|
|
1378
|
+
f"unexpected {spec.prompt_key} value: {value!r} "
|
|
1379
|
+
f"(expected {PICK_SKIP!r}, {spec.recommend_token!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1316
1380
|
)
|
|
1317
1381
|
|
|
1318
1382
|
|
|
1383
|
+
_DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
|
|
1384
|
+
step=S_DIRECTIVE_PICK, prompt_key="directive_pick",
|
|
1385
|
+
recommend_token=_REUSE_LAST_TOKEN, label_key="reuse_last", echo_suffix_key="reuse",
|
|
1386
|
+
suggest=_suggest_last_directive, snippet_style="prefix",
|
|
1387
|
+
cache_attr="last_directive_cached", target_attr="directive",
|
|
1388
|
+
pending_attr="directive_pending_text",
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _build_directive_pick(state: WizardState) -> Prompt:
|
|
1393
|
+
return _build_optional_cached_pick(state, _DIRECTIVE_PICK_SPEC)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1397
|
+
return _submit_optional_cached_pick(state, value, _DIRECTIVE_PICK_SPEC)
|
|
1398
|
+
|
|
1399
|
+
|
|
1319
1400
|
def _suggest_sibling_task_ids(state: WizardState) -> str:
|
|
1320
1401
|
"""같은 task-group 의 다른 task-id 를 CSV 로 반환 (현재 task 제외, 빈 결과면 '')."""
|
|
1321
1402
|
if not state.project_root or not state.task_group:
|
|
@@ -1337,74 +1418,39 @@ def _suggest_sibling_task_ids(state: WizardState) -> str:
|
|
|
1337
1418
|
return ",".join(siblings)
|
|
1338
1419
|
|
|
1339
1420
|
|
|
1421
|
+
_RELATED_TASKS_PICK_SPEC = _OptionalCachedPickSpec(
|
|
1422
|
+
step=S_RELATED_TASKS_PICK, prompt_key="related_tasks_pick",
|
|
1423
|
+
recommend_token=_SIBLINGS_TOKEN, label_key="siblings", echo_suffix_key="siblings",
|
|
1424
|
+
suggest=_suggest_sibling_task_ids, snippet_style="prefix",
|
|
1425
|
+
cache_attr="last_siblings_cached", target_attr="related_tasks_raw",
|
|
1426
|
+
pending_attr="related_tasks_pending_text",
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
|
|
1340
1430
|
def _build_related_tasks_pick(state: WizardState) -> Prompt:
|
|
1341
|
-
|
|
1342
|
-
t = _p(state.workspace_root, "related_tasks_pick")
|
|
1343
|
-
options: list[Option] = []
|
|
1344
|
-
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1345
|
-
if siblings:
|
|
1346
|
-
snippet = siblings if len(siblings) <= 60 else siblings[:60] + "…"
|
|
1347
|
-
label_template = t["labels"]["siblings"]
|
|
1348
|
-
options.append(_opt(_SIBLINGS_TOKEN, label_template.format(snippet=snippet)))
|
|
1349
|
-
state.last_siblings_cached = siblings
|
|
1350
|
-
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1351
|
-
return Prompt(step=S_RELATED_TASKS_PICK, kind="pick",
|
|
1352
|
-
label=t["label"], options=options,
|
|
1353
|
-
echo_template=t["echo_template"])
|
|
1431
|
+
return _build_optional_cached_pick(state, _RELATED_TASKS_PICK_SPEC)
|
|
1354
1432
|
|
|
1355
1433
|
|
|
1356
1434
|
def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
return None
|
|
1369
|
-
raise WizardError(
|
|
1370
|
-
f"unexpected related-tasks value: {value!r} "
|
|
1371
|
-
f"(expected {PICK_SKIP!r}, {_SIBLINGS_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1372
|
-
)
|
|
1435
|
+
return _submit_optional_cached_pick(state, value, _RELATED_TASKS_PICK_SPEC)
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
_CLARIFICATION_PICK_SPEC = _OptionalCachedPickSpec(
|
|
1439
|
+
step=S_CLARIFICATION_PICK, prompt_key="clarification_pick",
|
|
1440
|
+
recommend_token=_LATEST_REPORT_TOKEN, label_key="latest_report",
|
|
1441
|
+
echo_suffix_key="latest_report",
|
|
1442
|
+
suggest=_suggest_latest_final_report, snippet_style="suffix",
|
|
1443
|
+
cache_attr="last_final_report_cached", target_attr="clarification_response_path",
|
|
1444
|
+
pending_attr="clarification_pending_text",
|
|
1445
|
+
)
|
|
1373
1446
|
|
|
1374
1447
|
|
|
1375
1448
|
def _build_clarification_pick(state: WizardState) -> Prompt:
|
|
1376
|
-
|
|
1377
|
-
t = _p(state.workspace_root, "clarification_pick")
|
|
1378
|
-
options: list[Option] = []
|
|
1379
|
-
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1380
|
-
if latest:
|
|
1381
|
-
snippet = latest if len(latest) <= 60 else "…" + latest[-60:]
|
|
1382
|
-
label_template = t["labels"]["latest_report"]
|
|
1383
|
-
options.append(_opt(_LATEST_REPORT_TOKEN, label_template.format(snippet=snippet)))
|
|
1384
|
-
state.last_final_report_cached = latest
|
|
1385
|
-
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1386
|
-
return Prompt(step=S_CLARIFICATION_PICK, kind="pick",
|
|
1387
|
-
label=t["label"], options=options,
|
|
1388
|
-
echo_template=t["echo_template"])
|
|
1449
|
+
return _build_optional_cached_pick(state, _CLARIFICATION_PICK_SPEC)
|
|
1389
1450
|
|
|
1390
1451
|
|
|
1391
1452
|
def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1392
|
-
|
|
1393
|
-
if value == PICK_SKIP:
|
|
1394
|
-
state.clarification_response_path = ""
|
|
1395
|
-
state.clarification_pending_text = False
|
|
1396
|
-
return t["echo_suffixes"]["skip"]
|
|
1397
|
-
if value == _LATEST_REPORT_TOKEN:
|
|
1398
|
-
state.clarification_response_path = state.last_final_report_cached
|
|
1399
|
-
state.clarification_pending_text = False
|
|
1400
|
-
return t["echo_suffixes"]["latest_report"].format(value=state.clarification_response_path)
|
|
1401
|
-
if value == PICK_TYPE_CUSTOM:
|
|
1402
|
-
state.clarification_pending_text = True
|
|
1403
|
-
return None
|
|
1404
|
-
raise WizardError(
|
|
1405
|
-
f"unexpected clarification value: {value!r} "
|
|
1406
|
-
f"(expected {PICK_SKIP!r}, {_LATEST_REPORT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1407
|
-
)
|
|
1453
|
+
return _submit_optional_cached_pick(state, value, _CLARIFICATION_PICK_SPEC)
|
|
1408
1454
|
|
|
1409
1455
|
|
|
1410
1456
|
def _suggest_project_pr_template(state: WizardState) -> str:
|
|
@@ -1425,42 +1471,24 @@ def _suggest_project_pr_template(state: WizardState) -> str:
|
|
|
1425
1471
|
return val if isinstance(val, str) else ""
|
|
1426
1472
|
|
|
1427
1473
|
|
|
1474
|
+
_PR_TEMPLATE_PICK_SPEC = _OptionalCachedPickSpec(
|
|
1475
|
+
step=S_PR_TEMPLATE_PICK, prompt_key="pr_template_pick",
|
|
1476
|
+
recommend_token=_PROJECT_DEFAULT_TOKEN, label_key="project_default",
|
|
1477
|
+
echo_suffix_key="project_default",
|
|
1478
|
+
suggest=_suggest_project_pr_template, snippet_style="suffix",
|
|
1479
|
+
cache_attr="last_pr_template_cached", target_attr="pr_template_path",
|
|
1480
|
+
pending_attr="pr_template_pending_text",
|
|
1481
|
+
# skip 시 scope 도 함께 비운다 (원래 _submit_pr_template_pick 의 동작).
|
|
1482
|
+
extra_clear_attrs=("pr_template_scope",),
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
|
|
1428
1486
|
def _build_pr_template_pick(state: WizardState) -> Prompt:
|
|
1429
|
-
|
|
1430
|
-
t = _p(state.workspace_root, "pr_template_pick")
|
|
1431
|
-
options: list[Option] = []
|
|
1432
|
-
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
1433
|
-
if project_default:
|
|
1434
|
-
snippet = project_default if len(project_default) <= 60 else "…" + project_default[-60:]
|
|
1435
|
-
label_template = t["labels"]["project_default"]
|
|
1436
|
-
options.append(_opt(_PROJECT_DEFAULT_TOKEN, label_template.format(snippet=snippet)))
|
|
1437
|
-
state.last_pr_template_cached = project_default
|
|
1438
|
-
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
1439
|
-
return Prompt(
|
|
1440
|
-
step=S_PR_TEMPLATE_PICK, kind="pick",
|
|
1441
|
-
label=t["label"], options=options,
|
|
1442
|
-
echo_template=t["echo_template"],
|
|
1443
|
-
)
|
|
1487
|
+
return _build_optional_cached_pick(state, _PR_TEMPLATE_PICK_SPEC)
|
|
1444
1488
|
|
|
1445
1489
|
|
|
1446
1490
|
def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
|
|
1447
|
-
|
|
1448
|
-
if value == PICK_SKIP:
|
|
1449
|
-
state.pr_template_path = ""
|
|
1450
|
-
state.pr_template_scope = ""
|
|
1451
|
-
state.pr_template_pending_text = False
|
|
1452
|
-
return t["echo_suffixes"]["skip"]
|
|
1453
|
-
if value == _PROJECT_DEFAULT_TOKEN:
|
|
1454
|
-
state.pr_template_path = state.last_pr_template_cached
|
|
1455
|
-
state.pr_template_pending_text = False
|
|
1456
|
-
return t["echo_suffixes"]["project_default"].format(value=state.pr_template_path)
|
|
1457
|
-
if value == PICK_TYPE_CUSTOM:
|
|
1458
|
-
state.pr_template_pending_text = True
|
|
1459
|
-
return None
|
|
1460
|
-
raise WizardError(
|
|
1461
|
-
f"unexpected pr-template value: {value!r} "
|
|
1462
|
-
f"(expected {PICK_SKIP!r}, {_PROJECT_DEFAULT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
|
|
1463
|
-
)
|
|
1491
|
+
return _submit_optional_cached_pick(state, value, _PR_TEMPLATE_PICK_SPEC)
|
|
1464
1492
|
|
|
1465
1493
|
|
|
1466
1494
|
CRITIC_CHOICES = ["off", "claude", "codex", "gemini"]
|
|
@@ -2218,6 +2246,30 @@ def init_state(
|
|
|
2218
2246
|
)
|
|
2219
2247
|
|
|
2220
2248
|
|
|
2249
|
+
def _build_group_prompt(state: WizardState, group_id: str) -> Prompt:
|
|
2250
|
+
"""그룹의 적용가능·미답변 픽 멤버를 최대 GROUP_MAX_TABS 개 모은다.
|
|
2251
|
+
|
|
2252
|
+
멤버가 1개뿐이면 멀티탭 UI가 불필요하므로 그 멤버의 평범한 픽을 반환한다.
|
|
2253
|
+
호출부(next_prompt)는 적용 가능한 멤버가 최소 1개일 때만 진입하므로 빈 그룹은
|
|
2254
|
+
도달 불가다.
|
|
2255
|
+
"""
|
|
2256
|
+
members: list[Prompt] = []
|
|
2257
|
+
for sid in PROMPT_GROUPS[group_id]:
|
|
2258
|
+
if sid in state.answered:
|
|
2259
|
+
continue
|
|
2260
|
+
step = STEP_BY_ID[sid]
|
|
2261
|
+
if not step.applies(state):
|
|
2262
|
+
continue
|
|
2263
|
+
members.append(step.build(state))
|
|
2264
|
+
if len(members) >= GROUP_MAX_TABS:
|
|
2265
|
+
break
|
|
2266
|
+
assert members, f"group {group_id!r} reached with no applicable members"
|
|
2267
|
+
if len(members) == 1:
|
|
2268
|
+
return members[0]
|
|
2269
|
+
return Prompt(step=group_id, kind="pick_group",
|
|
2270
|
+
label=GROUP_LABELS[group_id], questions=members)
|
|
2271
|
+
|
|
2272
|
+
|
|
2221
2273
|
def next_prompt(state: WizardState) -> Prompt:
|
|
2222
2274
|
if state.confirmed:
|
|
2223
2275
|
return Prompt(step=S_DONE, kind="done")
|
|
@@ -2225,10 +2277,39 @@ def next_prompt(state: WizardState) -> Prompt:
|
|
|
2225
2277
|
if step.id in state.answered:
|
|
2226
2278
|
continue
|
|
2227
2279
|
if step.applies(state):
|
|
2280
|
+
group_id = _STEP_TO_GROUP.get(step.id)
|
|
2281
|
+
if group_id is not None:
|
|
2282
|
+
return _build_group_prompt(state, group_id)
|
|
2228
2283
|
return step.build(state)
|
|
2229
2284
|
return Prompt(step=S_DONE, kind="done")
|
|
2230
2285
|
|
|
2231
2286
|
|
|
2287
|
+
def _submit_group(state: WizardState, prompt: Prompt, value: str) -> dict[str, Any]:
|
|
2288
|
+
"""pick_group 답(JSON 객체)을 각 멤버 submit() 으로 라우팅한다.
|
|
2289
|
+
|
|
2290
|
+
멤버 submit 이 WizardError 를 던지면 그대로 전파되어 같은 그룹을 재-프롬프트한다.
|
|
2291
|
+
answered 마킹은 모든 멤버 submit 이 통과한 뒤에만 일괄 수행한다(answered 단위의
|
|
2292
|
+
전부-아니면-전무). 개별 멤버가 변경한 state 필드는 롤백하지 않지만, 재-프롬프트 시
|
|
2293
|
+
같은 그룹이 다시 나와 사용자 입력으로 덮어쓰므로 무해하다.
|
|
2294
|
+
"""
|
|
2295
|
+
try:
|
|
2296
|
+
answers = json.loads(value or "{}")
|
|
2297
|
+
except json.JSONDecodeError as exc:
|
|
2298
|
+
raise WizardError(f"pick_group answer must be a JSON object: {exc}")
|
|
2299
|
+
if not isinstance(answers, dict):
|
|
2300
|
+
raise WizardError("pick_group answer must be a JSON object")
|
|
2301
|
+
echoes: list[str] = []
|
|
2302
|
+
for q in prompt.questions:
|
|
2303
|
+
echo = STEP_BY_ID[q.step].submit(state, str(answers.get(q.step, "") or ""))
|
|
2304
|
+
if echo:
|
|
2305
|
+
echoes.append(echo)
|
|
2306
|
+
for q in prompt.questions:
|
|
2307
|
+
if q.step not in state.answered:
|
|
2308
|
+
state.answered.append(q.step)
|
|
2309
|
+
nxt = next_prompt(state)
|
|
2310
|
+
return {"echo": "; ".join(echoes), "next": nxt.to_json()}
|
|
2311
|
+
|
|
2312
|
+
|
|
2232
2313
|
def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
2233
2314
|
"""Validate the answer for the *currently active* step and advance.
|
|
2234
2315
|
|
|
@@ -2238,6 +2319,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
2238
2319
|
prompt = next_prompt(state)
|
|
2239
2320
|
if prompt.kind == "done":
|
|
2240
2321
|
return {"echo": "", "next": prompt.to_json()}
|
|
2322
|
+
if prompt.kind == "pick_group":
|
|
2323
|
+
return _submit_group(state, prompt, value)
|
|
2241
2324
|
step = STEP_BY_ID[prompt.step]
|
|
2242
2325
|
echo = step.submit(state, value or "")
|
|
2243
2326
|
if prompt.step not in state.answered:
|
|
@@ -87,7 +87,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
87
87
|
" - trade-off matrix across options (complexity, risk, reversibility, test cost, rollout cost) and recommended option with rationale tied to isolation / single-responsibility / YAGNI principles\n"
|
|
88
88
|
" - bite-sized stepwise execution order for the recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
|
|
89
89
|
" - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
|
|
90
|
-
" - every unresolved ambiguity registered as a `Blocks=approval` row in the `##
|
|
90
|
+
" - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
|
|
91
91
|
" - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
|
|
92
92
|
" - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)"
|
|
93
93
|
),
|