okstra 0.50.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.
Files changed (57) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +15 -11
  10. package/runtime/agents/workers/claude-worker.md +3 -3
  11. package/runtime/agents/workers/codex-worker.md +2 -2
  12. package/runtime/agents/workers/gemini-worker.md +2 -2
  13. package/runtime/bin/lib/okstra/cli.sh +8 -1
  14. package/runtime/bin/lib/okstra/globals.sh +3 -0
  15. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  16. package/runtime/bin/lib/okstra/usage.sh +6 -0
  17. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  18. package/runtime/bin/okstra.sh +2 -0
  19. package/runtime/prompts/launch.template.md +3 -1
  20. package/runtime/prompts/profiles/_common-contract.md +4 -4
  21. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  24. package/runtime/prompts/profiles/implementation.md +1 -1
  25. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  26. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  27. package/runtime/python/okstra_ctl/migrate.py +2 -12
  28. package/runtime/python/okstra_ctl/paths.py +22 -0
  29. package/runtime/python/okstra_ctl/render.py +284 -125
  30. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  31. package/runtime/python/okstra_ctl/run.py +507 -245
  32. package/runtime/python/okstra_ctl/sequence.py +2 -5
  33. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  34. package/runtime/python/okstra_ctl/wizard.py +129 -133
  35. package/runtime/python/okstra_ctl/worktree.py +13 -5
  36. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  37. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  38. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  39. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  40. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  47. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  48. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  49. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  50. package/runtime/skills/okstra-run/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  52. package/runtime/templates/reports/final-report.template.md +1 -0
  53. package/runtime/templates/worker-prompt-preamble.md +3 -3
  54. package/src/_python-helper.mjs +3 -3
  55. package/src/context-cost.mjs +27 -0
  56. package/src/install.mjs +1 -0
  57. 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 = (tasks_root(project_root)
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 preview_worktree_decision
50
- from okstra_project.dirs import project_json_path, tasks_root
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,
@@ -421,15 +426,12 @@ def _validate_approved_plan(path_str: str, project_root: Path) -> Path:
421
426
 
422
427
 
423
428
  def _git_main_worktree(project_root: Path) -> Path:
424
- try:
425
- common = subprocess.check_output(
426
- ["git", "-C", str(project_root), "rev-parse",
427
- "--path-format=absolute", "--git-common-dir"],
428
- stderr=subprocess.DEVNULL,
429
- ).decode().strip()
430
- except (subprocess.CalledProcessError, FileNotFoundError) as exc:
431
- raise WizardError(f"git unavailable in {project_root}: {exc}")
432
- 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)
433
435
 
434
436
 
435
437
  def _validate_base_ref(ref: str, project_root: Path) -> str:
@@ -1107,10 +1109,8 @@ def _list_implementation_planning_reports(
1107
1109
  # Run seq lives in the filename, not a per-run subdirectory: every
1108
1110
  # implementation-planning run writes into the same flat `reports/`
1109
1111
  # dir (see paths.py — `run_reports = runs/<task-type>/reports`).
1110
- reports_dir = (tasks_root(state.project_root)
1111
- / slugify_task_segment(state.task_group)
1112
- / slugify_task_segment(state.task_id)
1113
- / "runs" / "implementation-planning" / "reports")
1112
+ reports_dir = (task_runs_dir(state.project_root, state.task_group, state.task_id)
1113
+ / "implementation-planning" / "reports")
1114
1114
  if not reports_dir.is_dir():
1115
1115
  return []
1116
1116
  pat = re.compile(r"^final-report-implementation-planning-(\d+)\.md$")
@@ -1256,9 +1256,7 @@ def _suggest_latest_final_report(state: WizardState) -> str:
1256
1256
  """
1257
1257
  if not state.task_group or not state.task_id or not state.project_root:
1258
1258
  return ""
1259
- runs_base = (tasks_root(state.project_root)
1260
- / slugify_task_segment(state.task_group)
1261
- / slugify_task_segment(state.task_id) / "runs")
1259
+ runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
1262
1260
  if not runs_base.is_dir():
1263
1261
  return ""
1264
1262
  candidates = [
@@ -1285,9 +1283,7 @@ def _suggest_last_directive(state: WizardState) -> str:
1285
1283
  """같은 task 의 가장 최근 run-inputs-*.json 에서 directive 값을 자동 추출."""
1286
1284
  if not state.task_group or not state.task_id or not state.project_root:
1287
1285
  return ""
1288
- runs_base = (tasks_root(state.project_root)
1289
- / slugify_task_segment(state.task_group)
1290
- / slugify_task_segment(state.task_id) / "runs")
1286
+ runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
1291
1287
  if not runs_base.is_dir():
1292
1288
  return ""
1293
1289
  candidates: list[tuple[float, Path]] = []
@@ -1311,43 +1307,96 @@ def _suggest_last_directive(state: WizardState) -> str:
1311
1307
  return val if isinstance(val, str) else ""
1312
1308
 
1313
1309
 
1314
- def _build_directive_pick(state: WizardState) -> Prompt:
1315
- last = _suggest_last_directive(state)
1316
- t = _p(state.workspace_root, "directive_pick")
1317
- options: list[Option] = []
1318
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1319
- if last:
1320
- snippet = last[:60] + ("…" if len(last) > 60 else "")
1321
- label_template = t["labels"]["reuse_last"]
1322
- options.append(_opt(_REUSE_LAST_TOKEN, label_template.format(snippet=snippet)))
1323
- state.last_directive_cached = last
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)
1324
1351
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1325
1352
  return Prompt(
1326
- step=S_DIRECTIVE_PICK, kind="pick",
1353
+ step=spec.step, kind="pick",
1327
1354
  label=t["label"], options=options,
1328
1355
  echo_template=t["echo_template"],
1329
1356
  )
1330
1357
 
1331
1358
 
1332
- def _submit_directive_pick(state: WizardState, value: str) -> Optional[str]:
1333
- t = _p(state.workspace_root, "directive_pick")
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)
1334
1363
  if value == PICK_SKIP:
1335
- state.directive = ""
1336
- state.directive_pending_text = False
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)
1337
1368
  return t["echo_suffixes"]["skip"]
1338
- if value == _REUSE_LAST_TOKEN:
1339
- state.directive = state.last_directive_cached
1340
- state.directive_pending_text = False
1341
- return t["echo_suffixes"]["reuse"].format(value=state.directive)
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)
1342
1374
  if value == PICK_TYPE_CUSTOM:
1343
- state.directive_pending_text = True
1375
+ setattr(state, spec.pending_attr, True)
1344
1376
  return None
1345
1377
  raise WizardError(
1346
- f"unexpected directive value: {value!r} "
1347
- f"(expected {PICK_SKIP!r}, {_REUSE_LAST_TOKEN!r}, or {PICK_TYPE_CUSTOM!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})"
1348
1380
  )
1349
1381
 
1350
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
+
1351
1400
  def _suggest_sibling_task_ids(state: WizardState) -> str:
1352
1401
  """같은 task-group 의 다른 task-id 를 CSV 로 반환 (현재 task 제외, 빈 결과면 '')."""
1353
1402
  if not state.project_root or not state.task_group:
@@ -1369,74 +1418,39 @@ def _suggest_sibling_task_ids(state: WizardState) -> str:
1369
1418
  return ",".join(siblings)
1370
1419
 
1371
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
+
1372
1430
  def _build_related_tasks_pick(state: WizardState) -> Prompt:
1373
- siblings = _suggest_sibling_task_ids(state)
1374
- t = _p(state.workspace_root, "related_tasks_pick")
1375
- options: list[Option] = []
1376
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1377
- if siblings:
1378
- snippet = siblings if len(siblings) <= 60 else siblings[:60] + "…"
1379
- label_template = t["labels"]["siblings"]
1380
- options.append(_opt(_SIBLINGS_TOKEN, label_template.format(snippet=snippet)))
1381
- state.last_siblings_cached = siblings
1382
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1383
- return Prompt(step=S_RELATED_TASKS_PICK, kind="pick",
1384
- label=t["label"], options=options,
1385
- echo_template=t["echo_template"])
1431
+ return _build_optional_cached_pick(state, _RELATED_TASKS_PICK_SPEC)
1386
1432
 
1387
1433
 
1388
1434
  def _submit_related_tasks_pick(state: WizardState, value: str) -> Optional[str]:
1389
- t = _p(state.workspace_root, "related_tasks_pick")
1390
- if value == PICK_SKIP:
1391
- state.related_tasks_raw = ""
1392
- state.related_tasks_pending_text = False
1393
- return t["echo_suffixes"]["skip"]
1394
- if value == _SIBLINGS_TOKEN:
1395
- state.related_tasks_raw = state.last_siblings_cached
1396
- state.related_tasks_pending_text = False
1397
- return t["echo_suffixes"]["siblings"].format(value=state.related_tasks_raw)
1398
- if value == PICK_TYPE_CUSTOM:
1399
- state.related_tasks_pending_text = True
1400
- return None
1401
- raise WizardError(
1402
- f"unexpected related-tasks value: {value!r} "
1403
- f"(expected {PICK_SKIP!r}, {_SIBLINGS_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1404
- )
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
+ )
1405
1446
 
1406
1447
 
1407
1448
  def _build_clarification_pick(state: WizardState) -> Prompt:
1408
- latest = _suggest_latest_final_report(state)
1409
- t = _p(state.workspace_root, "clarification_pick")
1410
- options: list[Option] = []
1411
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1412
- if latest:
1413
- snippet = latest if len(latest) <= 60 else "…" + latest[-60:]
1414
- label_template = t["labels"]["latest_report"]
1415
- options.append(_opt(_LATEST_REPORT_TOKEN, label_template.format(snippet=snippet)))
1416
- state.last_final_report_cached = latest
1417
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1418
- return Prompt(step=S_CLARIFICATION_PICK, kind="pick",
1419
- label=t["label"], options=options,
1420
- echo_template=t["echo_template"])
1449
+ return _build_optional_cached_pick(state, _CLARIFICATION_PICK_SPEC)
1421
1450
 
1422
1451
 
1423
1452
  def _submit_clarification_pick(state: WizardState, value: str) -> Optional[str]:
1424
- t = _p(state.workspace_root, "clarification_pick")
1425
- if value == PICK_SKIP:
1426
- state.clarification_response_path = ""
1427
- state.clarification_pending_text = False
1428
- return t["echo_suffixes"]["skip"]
1429
- if value == _LATEST_REPORT_TOKEN:
1430
- state.clarification_response_path = state.last_final_report_cached
1431
- state.clarification_pending_text = False
1432
- return t["echo_suffixes"]["latest_report"].format(value=state.clarification_response_path)
1433
- if value == PICK_TYPE_CUSTOM:
1434
- state.clarification_pending_text = True
1435
- return None
1436
- raise WizardError(
1437
- f"unexpected clarification value: {value!r} "
1438
- f"(expected {PICK_SKIP!r}, {_LATEST_REPORT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1439
- )
1453
+ return _submit_optional_cached_pick(state, value, _CLARIFICATION_PICK_SPEC)
1440
1454
 
1441
1455
 
1442
1456
  def _suggest_project_pr_template(state: WizardState) -> str:
@@ -1457,42 +1471,24 @@ def _suggest_project_pr_template(state: WizardState) -> str:
1457
1471
  return val if isinstance(val, str) else ""
1458
1472
 
1459
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
+
1460
1486
  def _build_pr_template_pick(state: WizardState) -> Prompt:
1461
- project_default = _suggest_project_pr_template(state)
1462
- t = _p(state.workspace_root, "pr_template_pick")
1463
- options: list[Option] = []
1464
- options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
1465
- if project_default:
1466
- snippet = project_default if len(project_default) <= 60 else "…" + project_default[-60:]
1467
- label_template = t["labels"]["project_default"]
1468
- options.append(_opt(_PROJECT_DEFAULT_TOKEN, label_template.format(snippet=snippet)))
1469
- state.last_pr_template_cached = project_default
1470
- options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1471
- return Prompt(
1472
- step=S_PR_TEMPLATE_PICK, kind="pick",
1473
- label=t["label"], options=options,
1474
- echo_template=t["echo_template"],
1475
- )
1487
+ return _build_optional_cached_pick(state, _PR_TEMPLATE_PICK_SPEC)
1476
1488
 
1477
1489
 
1478
1490
  def _submit_pr_template_pick(state: WizardState, value: str) -> Optional[str]:
1479
- t = _p(state.workspace_root, "pr_template_pick")
1480
- if value == PICK_SKIP:
1481
- state.pr_template_path = ""
1482
- state.pr_template_scope = ""
1483
- state.pr_template_pending_text = False
1484
- return t["echo_suffixes"]["skip"]
1485
- if value == _PROJECT_DEFAULT_TOKEN:
1486
- state.pr_template_path = state.last_pr_template_cached
1487
- state.pr_template_pending_text = False
1488
- return t["echo_suffixes"]["project_default"].format(value=state.pr_template_path)
1489
- if value == PICK_TYPE_CUSTOM:
1490
- state.pr_template_pending_text = True
1491
- return None
1492
- raise WizardError(
1493
- f"unexpected pr-template value: {value!r} "
1494
- f"(expected {PICK_SKIP!r}, {_PROJECT_DEFAULT_TOKEN!r}, or {PICK_TYPE_CUSTOM!r})"
1495
- )
1491
+ return _submit_optional_cached_pick(state, value, _PR_TEMPLATE_PICK_SPEC)
1496
1492
 
1497
1493
 
1498
1494
  CRITIC_CHOICES = ["off", "claude", "codex", "gemini"]
@@ -172,7 +172,7 @@ def preview_worktree_decision(
172
172
  helpers so preview never diverges from the actual provisioning result.
173
173
  """
174
174
  project_root = Path(project_root)
175
- if not _is_git_repo(project_root):
175
+ if not is_git_work_tree(project_root):
176
176
  return WorktreeDecision(status="skipped-not-git", path=str(project_root))
177
177
  if _is_inside_non_main_worktree(project_root):
178
178
  return WorktreeDecision(status="skipped-in-worktree", path=str(project_root))
@@ -232,8 +232,16 @@ def _is_inside_non_main_worktree(project_root: Path) -> bool:
232
232
  return common_abs != per_tree_abs
233
233
 
234
234
 
235
- def _is_git_repo(project_root: Path) -> bool:
236
- res = _git(project_root, "rev-parse", "--is-inside-work-tree")
235
+ def is_git_work_tree(project_root: Path) -> bool:
236
+ """project_root git work tree 내부인지 판정하는 public git-introspection seam.
237
+
238
+ git 미설치(FileNotFoundError) 를 포함한 모든 실패는 False — 호출자(migrate
239
+ 등)가 non-git 레이아웃으로 안전하게 폴백할 수 있도록 한다. 과거 migrate.py
240
+ 가 같은 판정을 자체 구현(`--show-toplevel`)했는데 이 seam 으로 통합한다."""
241
+ try:
242
+ res = _git(project_root, "rev-parse", "--is-inside-work-tree")
243
+ except (OSError, FileNotFoundError):
244
+ return False
237
245
  return res.returncode == 0 and res.stdout.strip() == "true"
238
246
 
239
247
 
@@ -260,7 +268,7 @@ def _resolve_commit_sha(cwd: Path, ref: str) -> str:
260
268
  return res.stdout.strip()
261
269
 
262
270
 
263
- def _main_worktree_path(project_root: Path) -> Path:
271
+ def main_worktree_path(project_root: Path) -> Path:
264
272
  """Locate the repository's MAIN worktree (the original checkout).
265
273
 
266
274
  `git worktree list --porcelain` lists worktrees in a stable order
@@ -601,7 +609,7 @@ def provision_task_worktree(
601
609
  "work-category before retrying."
602
610
  )
603
611
 
604
- main_root = _main_worktree_path(project_root)
612
+ main_root = main_worktree_path(project_root)
605
613
  requested_base = (base_ref or "").strip()
606
614
  if not requested_base and require_base_ref:
607
615
  raise RuntimeError(
@@ -83,6 +83,10 @@
83
83
  "approved": {
84
84
  "type": "boolean",
85
85
  "description": "사용자 승인 플래그. report-writer 는 항상 false 로 발행하고, 사용자가 `--approve` 또는 직접 편집으로 true 로 토글합니다. `implementation` task-type 의 prepare 단계가 `--approved-plan` 으로 지정된 보고서의 이 필드를 읽어 진입 여부를 결정합니다. 다른 task-type 에서는 의미 없이 false 로 유지됩니다."
86
+ },
87
+ "implementationOption": {
88
+ "type": "string",
89
+ "description": "유저가 implementation-planning final-report 에서 고른 Option Candidate 이름. report-writer 는 항상 빈 문자열로 발행하고, 사용자가 `--implementation-option <name>` 또는 직접 편집으로 채웁니다. `implementation` task-type 이 이 값으로 진행하며, 비어 있으면 plan 의 `Recommended Option` 으로 폴백합니다. 다른 task-type 에서는 의미 없이 빈 값으로 유지됩니다."
86
90
  }
87
91
  }
88
92
  },
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: okstra-coding-preflight
3
+ description: Provides language-specific coding conventions, idioms, and test-writing guidance for Java, Kotlin, JavaScript, TypeScript, Node.js, Python, SQL, and Rust. okstra lead/workers MUST consult this skill before writing, editing, refactoring, or testing code in any of these languages — including any new file, function, or test — even when the task seems simple enough to handle directly. Detect the target language from the file extension, project config (package.json/Cargo.toml/pyproject.toml/pom.xml/build.gradle), or task brief, and apply the matching conventions.
4
+ user-invocable: false
5
+ ---
6
+
7
+ # okstra Code Preflight
8
+
9
+ ## These apply to every worker writing or editing project code (executor and verifiers alike). Enforcement is self-check: the agent runs each rule's check immediately before reporting "done"; skipping a check is itself a contract violation.
10
+
11
+ 1. **DRY — single reference point** — One implementation per capability. A second caller signals "extract shared logic", not "duplicate path".
12
+ 2. **KISS — simplest sufficient design** — Add abstraction layers (helper modules, strategy/factory, configuration flags, indirection) only when an existing concrete call site requires them. *Self-check: name the second caller now; if you cannot, inline.* *Example violation: extracting `formatUserName()` helper used by exactly one call site, "in case we need it elsewhere".*
13
+ 3. **YAGNI — build only for current requirements** — No speculative parameters, optional configs, "future-proof" hooks, or pre-1.0 backwards-compat shims. *Self-check: every newly introduced identifier has ≥1 current internal caller, or was explicitly user-requested.* *Example violation: adding `options?: { retries?, timeout? }` parameter when the current call passes nothing.*
14
+ 4. **Clean Code — names carry WHAT, comments explain WHY** — Identifiers must make intent obvious; if a comment would describe WHAT the code does, rename instead. Reserve comments for non-obvious WHY (hidden constraint, workaround, surprising invariant). Delete dead/commented-out code immediately — git history is the archive. *Example — WHAT (rename instead): `// increment counter` above `i++`. WHY (keep): `// retry up to 3x: upstream returns 502 during deploys`.*
15
+ 5. **Function length cap — 50 lines** — A single function/method body must stay within 50 lines, counting only effective code (exclude blank lines, comments, and pure data declarations such as large enums, lookup tables, or constant maps). Crossing the cap is an extraction signal, not a style nit. *Self-check: for any function newly added or substantially edited, count effective body lines; if over 50, split before declaring complete, or surface the violation and confirm with the user.*
16
+
17
+
18
+ ## Quick start
19
+
20
+ Before writing or editing **any** code:
21
+
22
+ 1. Identify the target language (file extension, project config, or explicit user request).
23
+ 2. Read the matching language reference from `languages/`.
24
+ 3. Read [clean-code.md](clean-code.md) for the language-agnostic principles.
25
+ 4. **Check for architecture overlays.** If the project uses ports-and-adapters (signals: `domain/` + `ports/` + `adapters/` folders, `*.port.*` files, NestJS hex split), also read [architecture/hexagonal.md](architecture/hexagonal.md). Record the detected layout in one line so later edits don't re-discover it.
26
+ 5. In **one short sentence** to the user, state which conventions you will apply (e.g., *"Applying Kotlin conventions + hexagonal overlay; domain at `src/domain/`."*).
27
+ 6. Then write code.
28
+
29
+ If the language is not listed below, stop and ask the user for the canonical style guide they want to follow before writing code.
30
+
31
+ ## Language router
32
+
33
+ | Language | Reference | Triggers |
34
+ |---|---|---|
35
+ | Java | [languages/java.md](languages/java.md) | `.java`, `pom.xml`, `build.gradle` |
36
+ | Kotlin | [languages/kotlin.md](languages/kotlin.md) | `.kt`, `.kts`, `build.gradle.kts` |
37
+ | JavaScript / TypeScript | [languages/javascript-typescript.md](languages/javascript-typescript.md) | `.js`, `.ts`, `.tsx`, `.jsx` |
38
+ | Node.js (server) | [languages/nodejs.md](languages/nodejs.md) | `package.json` with server entry, `express`, `fastify`, `nestjs` |
39
+ | Python | [languages/python.md](languages/python.md) | `.py`, `pyproject.toml`, `requirements.txt`, `setup.py`, `setup.cfg` |
40
+ | SQL | [languages/sql.md](languages/sql.md) | `.sql`, migration directories, `prisma/schema.prisma`, raw queries embedded in code |
41
+ | Rust | [languages/rust.md](languages/rust.md) | `.rs`, `Cargo.toml` |
42
+
43
+ For Node.js work, load **both** `javascript-typescript.md` and `nodejs.md`.
44
+
45
+ ## Architecture overlays
46
+
47
+ Loaded **in addition to** the language reference when the project matches. Overlays add architectural rules that cut across languages.
48
+
49
+ | Overlay | Reference | When to load |
50
+ |---|---|---|
51
+ | Hexagonal (ports & adapters) | [architecture/hexagonal.md](architecture/hexagonal.md) | Project has `domain/` + `ports/` + `adapters/` (or equivalent: `core/`, `infrastructure/`, `application/`), `*.port.*` files, or `abstract class` files at a domain boundary. Confirm once with the user if ambiguous. |
52
+
53
+ If you suspect an overlay applies but the layout is non-standard, ask one question — *"does this project follow ports-and-adapters? where is the domain?"* — and record the answer.
54
+
55
+ ## Mandatory pre-write checks (every language)
56
+
57
+ - [ ] Language reference read for this turn.
58
+ - [ ] `clean-code.md` principles applied: DRY, KISS, SOLID, YAGNI, meaningful naming (truthful + standalone), single-purpose functions, plain-English summary test, 50-line cap, no magic numbers, shallow nesting, comments-explain-why.
59
+ - [ ] Tests planned: which test(s) cover this change. New behaviour without a test is **incomplete** unless the user has explicitly opted out for this change.
60
+ - [ ] **Testing discipline:** the test does not stub/spy methods on the SUT itself (collaborators are fine), and assertions are on outcomes (return values, state, events, boundary calls) — not on which internal helper was called.
61
+ - [ ] **Hexagonal overlay (if loaded):** no business logic inside any port body, adapter methods are I/O only (no post-fetch JS filtering on domain state, no `findValid*`/`findActive*` adapter names hiding rules), all domain objects declared under `domain/`.
62
+ - [ ] Existing code searched: `grep` for the symbol / file / identifier you are about to add. Do not duplicate.
63
+ - [ ] Project conventions checked: `.editorconfig`, `CONTRIBUTING.md`, formatter config (`.prettierrc`, `rustfmt.toml`, `ktlint`, `google-java-format`, etc.). **Project rules override this skill on conflict.**
64
+
65
+ ## Boundaries
66
+
67
+ - This skill does **not** auto-format. Run the project's formatter yourself.
68
+ - This skill does **not** replace repo-local rules. Repo rules win on conflict.
69
+ - This skill does **not** cover every language. If the target language is missing, stop and ask.