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.
- package/README.kr.md +8 -7
- package/README.md +8 -7
- package/bin/okstra +2 -0
- package/docs/kr/architecture.md +15 -16
- package/docs/kr/cli.md +5 -5
- package/docs/project-structure-overview.md +10 -6
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +15 -11
- package/runtime/agents/workers/claude-worker.md +3 -3
- package/runtime/agents/workers/codex-worker.md +2 -2
- package/runtime/agents/workers/gemini-worker.md +2 -2
- 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-team-reconcile.sh +28 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/launch.template.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
- 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 +284 -125
- package/runtime/python/okstra_ctl/render_final_report.py +31 -0
- package/runtime/python/okstra_ctl/run.py +507 -245
- 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 +129 -133
- package/runtime/python/okstra_ctl/worktree.py +13 -5
- package/runtime/schemas/final-report-v1.0.schema.json +4 -0
- 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-inspect/SKILL.md +100 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
- package/runtime/templates/reports/final-report.template.md +1 -0
- package/runtime/templates/worker-prompt-preamble.md +3 -3
- 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,
|
|
@@ -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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 = (
|
|
1111
|
-
/
|
|
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 = (
|
|
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 = (
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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=
|
|
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
|
|
1333
|
-
|
|
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.
|
|
1336
|
-
|
|
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 ==
|
|
1339
|
-
|
|
1340
|
-
state.
|
|
1341
|
-
|
|
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.
|
|
1375
|
+
setattr(state, spec.pending_attr, True)
|
|
1344
1376
|
return None
|
|
1345
1377
|
raise WizardError(
|
|
1346
|
-
f"unexpected
|
|
1347
|
-
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})"
|
|
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
|
-
|
|
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
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
236
|
-
|
|
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
|
|
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 =
|
|
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.
|