okstra 0.138.0 → 0.140.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.md +1 -0
- package/docs/architecture.md +4 -4
- package/docs/cli.md +4 -3
- package/docs/for-ai/README.md +2 -0
- package/docs/for-ai/skills/okstra-code-review.md +57 -0
- package/docs/project-structure-overview.md +5 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/coding-preflight/architectures/hexagonal.md +34 -0
- package/runtime/prompts/coding-preflight/clean-code.md +109 -0
- package/runtime/prompts/coding-preflight/languages/javascript-typescript.md +11 -0
- package/runtime/prompts/coding-preflight/overview.md +3 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/profiles/_implementation-diff-review.md +10 -4
- package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +12 -3
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/release-handoff.md +2 -1
- package/runtime/python/okstra_ctl/code_review_paths.py +64 -0
- package/runtime/python/okstra_ctl/code_review_target.py +152 -0
- package/runtime/python/okstra_ctl/worktree.py +3 -2
- package/runtime/python/okstra_project/__init__.py +2 -0
- package/runtime/python/okstra_project/state.py +142 -0
- package/runtime/skills/okstra-code-review/SKILL.md +180 -0
- package/runtime/skills/okstra-code-review/references/census-rules.md +100 -0
- package/runtime/skills/okstra-code-review/references/review-calibration.md +86 -0
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/code-review.mjs +35 -0
- package/src/lib/skill-catalog.mjs +1 -0
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
- `change base branch` — return to Q2 and re-ask the base selection.
|
|
45
45
|
- `cancel` — end the run without push or PR; record the cancellation in the final report.
|
|
46
46
|
- The probe is read-only. It MUST NOT run `git merge`, `git rebase`, `git pull`, or any command that mutates the working tree or local refs.
|
|
47
|
-
3. **PR title + PR body confirmation** — show the lead's inline draft verbatim and capture one of:
|
|
47
|
+
3. **PR title + PR body confirmation** — show the lead's inline draft verbatim, together with the filled-body self-check result (either `body self-check: clean` or the list of hits with their lines, per the drafting rules below), and capture one of:
|
|
48
48
|
- `use as-is` — proceed with the drafted text.
|
|
49
49
|
- `edit then proceed` — accept inline edits from the user, then proceed with the edited text.
|
|
50
50
|
- `cancel` — end the run without executing push or PR commands; record the cancellation in the final report.
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
- produce **two artifacts** before showing them to the user:
|
|
55
55
|
1. **PR title** — by default the subject of the most recent implementation commit, or a concise Conventional Commits-style summary of the committed range.
|
|
56
56
|
2. **PR body** — markdown filled from `PR_TEMPLATE_PATH`. The user-confirmation step's diff (Q3 `edit then proceed`) is computed against the filled template, not against the raw template file.
|
|
57
|
+
- **Filled-body self-check (runs on the drafted body, before Q3 shows it).** "Fill in the placeholders" is only observable if someone checks that they were filled, so scan the drafted body for these and carry the result into Q3: a residual HTML comment or template marker (`<!-- … -->`, `_Describe your changes…_`, a bare `TODO` / `FIXME` the diff did not introduce), an unchecked `- [ ]` box with no inline N/A justification, a section heading whose body is empty, and a whole body short enough to carry no substance. Each hit is reported to the user with its line — never silently left in, and never auto-filled with invented content. The user remains free to accept it; the point is that they see it before choosing `use as-is`.
|
|
57
58
|
- Allowed actions during the run (Claude lead only):
|
|
58
59
|
- read-only inspection: `git status`, `git status --short`, `git diff`, `git log`, `git rev-parse`, `git ls-remote --heads origin <name>`, `gh pr list --head <branch>`, `gh pr view <url>`.
|
|
59
60
|
- merge-conflict probe (only when the user picked `push + PR`): `git fetch origin <chosen-base>` and `git merge-tree --write-tree --merge-base origin/<chosen-base> HEAD origin/<chosen-base>`. Both are non-mutating with respect to the working tree.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Filesystem layout for code-review result files.
|
|
2
|
+
|
|
3
|
+
The two modes deliberately live under different roots: an okstra session
|
|
4
|
+
review belongs to its task bundle, while a session-independent branch
|
|
5
|
+
review belongs to the authoring tree. Centralising the assembly here keeps
|
|
6
|
+
skill markdown from re-deriving literal paths.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import paths
|
|
14
|
+
|
|
15
|
+
REVIEW_DIRNAME = "code-reviews"
|
|
16
|
+
BRANCH_REVIEW_ROOT = ".project-docs"
|
|
17
|
+
|
|
18
|
+
_STAGE_FILE_RE = re.compile(r"^stage-(\d+)(?:-r(\d+))?\.md$")
|
|
19
|
+
_BRANCH_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-(\d+)\.md$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def stage_review_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
23
|
+
"""Review directory inside the task bundle."""
|
|
24
|
+
return paths.task_dir(project_root, task_group, task_id) / REVIEW_DIRNAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def branch_review_dir(project_root: Path, branch: str) -> Path:
|
|
28
|
+
"""Review directory for a branch. Slashes stay as nested directories."""
|
|
29
|
+
return Path(project_root) / BRANCH_REVIEW_ROOT / REVIEW_DIRNAME / branch
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _existing_names(review_dir: Path) -> list[str]:
|
|
33
|
+
if not Path(review_dir).is_dir():
|
|
34
|
+
return []
|
|
35
|
+
return [entry.name for entry in Path(review_dir).iterdir() if entry.is_file()]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def next_stage_review(review_dir: Path, stage: int) -> tuple[Path, int]:
|
|
39
|
+
"""Next round's file path and round number for one stage.
|
|
40
|
+
|
|
41
|
+
Round 1 carries no suffix; later rounds are `-r<N>`.
|
|
42
|
+
"""
|
|
43
|
+
highest = 0
|
|
44
|
+
for name in _existing_names(review_dir):
|
|
45
|
+
matched = _STAGE_FILE_RE.match(name)
|
|
46
|
+
if not matched or int(matched.group(1)) != stage:
|
|
47
|
+
continue
|
|
48
|
+
highest = max(highest, int(matched.group(2) or 1))
|
|
49
|
+
round_no = highest + 1
|
|
50
|
+
stem = f"stage-{stage:02d}"
|
|
51
|
+
suffix = "" if round_no == 1 else f"-r{round_no}"
|
|
52
|
+
return Path(review_dir) / f"{stem}{suffix}.md", round_no
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def next_branch_review(review_dir: Path, date: str) -> tuple[Path, int]:
|
|
56
|
+
"""Next file path and sequence number for a branch review on `date`."""
|
|
57
|
+
highest = 0
|
|
58
|
+
for name in _existing_names(review_dir):
|
|
59
|
+
matched = _BRANCH_FILE_RE.match(name)
|
|
60
|
+
if not matched or matched.group(1) != date:
|
|
61
|
+
continue
|
|
62
|
+
highest = max(highest, int(matched.group(2)))
|
|
63
|
+
seq = highest + 1
|
|
64
|
+
return Path(review_dir) / f"{date}-{seq:02d}.md", seq
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""코드 리뷰가 무엇을 읽고 결과 파일을 어디에 쓰는지 해소한다.
|
|
2
|
+
|
|
3
|
+
두 모드를 한 진입점에 모아 스킬이 base 커밋도, 리뷰 파일 경로도, 회차도 다시
|
|
4
|
+
유도하지 않게 한다. stage 모드는 read side 에 그대로 위임하고, branch 모드만
|
|
5
|
+
여기서 해소한다 — caller 가 base 를 지정하지 않으면 기본 브랜치와의
|
|
6
|
+
merge-base 를 쓴다. 이 모듈은 인자 검증과 JSON 정형화만 하고, 경로·회차 조립은
|
|
7
|
+
code_review_paths 가, stage 규칙은 code_review_target_snapshot 이 소유한다.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from okstra_project import (
|
|
20
|
+
ResolverError,
|
|
21
|
+
StateError,
|
|
22
|
+
code_review_target_snapshot,
|
|
23
|
+
resolve_project_root,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .code_review_paths import branch_review_dir, next_branch_review
|
|
27
|
+
|
|
28
|
+
DEFAULT_BRANCH_CANDIDATES = ("main", "master")
|
|
29
|
+
|
|
30
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _git_out(repo: Path, *args: str) -> str:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["git", "-C", str(repo), *args],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False,
|
|
39
|
+
)
|
|
40
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_branch_ref(repo: Path) -> str:
|
|
44
|
+
"""merge-base 를 잴 기준 ref — origin/HEAD 가 있으면 그것, 없으면 관례 이름."""
|
|
45
|
+
remote_head = _git_out(repo, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
46
|
+
if remote_head:
|
|
47
|
+
return remote_head
|
|
48
|
+
for candidate in DEFAULT_BRANCH_CANDIDATES:
|
|
49
|
+
if _git_out(repo, "rev-parse", "--verify", "--quiet", candidate):
|
|
50
|
+
return candidate
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _merge_base(repo: Path, branch: str) -> str:
|
|
55
|
+
default_ref = _default_branch_ref(repo)
|
|
56
|
+
merge_base = _git_out(repo, "merge-base", default_ref, branch) if default_ref else ""
|
|
57
|
+
if not merge_base:
|
|
58
|
+
raise StateError(
|
|
59
|
+
f"no merge-base for {branch!r} against the default branch — "
|
|
60
|
+
"pass --base explicitly",
|
|
61
|
+
stage="base_unresolved",
|
|
62
|
+
)
|
|
63
|
+
return merge_base
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _review_date(raw: str) -> str:
|
|
67
|
+
"""branch 결과 파일명에 쓸 날짜.
|
|
68
|
+
|
|
69
|
+
next_branch_review 는 넘겨받은 문자열을 그대로 파일명에 쓰지만 되읽을 때는
|
|
70
|
+
`YYYY-MM-DD` 만 인식한다. 어긋난 날짜를 통과시키면 회차가 늘 1 로 돌아가
|
|
71
|
+
직전 리뷰 파일을 덮어쓴다.
|
|
72
|
+
"""
|
|
73
|
+
if not raw:
|
|
74
|
+
return date.today().isoformat()
|
|
75
|
+
if not _DATE_RE.match(raw):
|
|
76
|
+
raise StateError(f"--date must be YYYY-MM-DD, got {raw!r}", stage="args")
|
|
77
|
+
return raw
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _branch_snapshot(repo: Path, branch: str, base: str, review_date: str) -> dict:
|
|
81
|
+
head = _git_out(repo, "rev-parse", branch)
|
|
82
|
+
if not head:
|
|
83
|
+
raise StateError(f"branch {branch!r} not found", stage="branch_missing")
|
|
84
|
+
base_commit = base or _merge_base(repo, branch)
|
|
85
|
+
review_path, seq = next_branch_review(branch_review_dir(repo, branch), review_date)
|
|
86
|
+
return {
|
|
87
|
+
"mode": "branch",
|
|
88
|
+
"worktreePath": str(repo),
|
|
89
|
+
"branch": branch,
|
|
90
|
+
"baseCommit": base_commit,
|
|
91
|
+
"headCommit": head,
|
|
92
|
+
"reviewPath": str(review_path),
|
|
93
|
+
"round": seq,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
description="Resolve a code review's diff range and result file path."
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("--task-key", default="", help="project-id:task-group:task-id")
|
|
102
|
+
parser.add_argument("--stage", type=int, default=0, help="stage number to review")
|
|
103
|
+
parser.add_argument("--branch", default="", help="branch to review")
|
|
104
|
+
parser.add_argument("--base", default="", help="explicit base ref for branch mode")
|
|
105
|
+
parser.add_argument("--date", default="", help="YYYY-MM-DD for branch result naming")
|
|
106
|
+
parser.add_argument("--project-root", default="", help="explicit project root")
|
|
107
|
+
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
108
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
109
|
+
return parser
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve(args: argparse.Namespace, project_root: Path) -> dict:
|
|
113
|
+
if args.task_key:
|
|
114
|
+
if args.stage < 1:
|
|
115
|
+
raise StateError("--task-key needs --stage <N>, N >= 1", stage="args")
|
|
116
|
+
return code_review_target_snapshot(project_root, args.task_key, args.stage)
|
|
117
|
+
if args.branch:
|
|
118
|
+
return _branch_snapshot(
|
|
119
|
+
project_root, args.branch, args.base, _review_date(args.date)
|
|
120
|
+
)
|
|
121
|
+
raise StateError("pass either --task-key --stage or --branch", stage="args")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
args = _build_parser().parse_args(argv)
|
|
126
|
+
try:
|
|
127
|
+
project_root = resolve_project_root(
|
|
128
|
+
explicit_root=args.project_root, cwd=args.cwd
|
|
129
|
+
)
|
|
130
|
+
except ResolverError as exc:
|
|
131
|
+
print(json.dumps({"ok": False, "stage": "resolve", "reason": str(exc)}))
|
|
132
|
+
return 2
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
snapshot = _resolve(args, Path(project_root))
|
|
136
|
+
except StateError as exc:
|
|
137
|
+
print(json.dumps({
|
|
138
|
+
"ok": False,
|
|
139
|
+
"stage": exc.stage or "target",
|
|
140
|
+
"reason": str(exc),
|
|
141
|
+
}))
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
print(json.dumps(
|
|
145
|
+
{"ok": True, "projectRoot": str(project_root), **snapshot},
|
|
146
|
+
ensure_ascii=False, indent=2,
|
|
147
|
+
))
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -57,8 +57,9 @@ from .seeding import (
|
|
|
57
57
|
# worktree at provision time. Symlinks (not copies) so every task sees the
|
|
58
58
|
# live shared state and disk/CPU cost stays near zero; the trade-off is
|
|
59
59
|
# that any write through the link reaches the main worktree, which is
|
|
60
|
-
# acceptable because okstra only
|
|
61
|
-
# subdirectory (e.g. `.okstra/tasks/<task-id>/runs/...`)
|
|
60
|
+
# acceptable because okstra writes only to paths it owns: its task-scoped
|
|
61
|
+
# subdirectory (e.g. `.okstra/tasks/<task-id>/runs/...`) and, for a
|
|
62
|
+
# branch-mode code review, `.project-docs/code-reviews/<branch>/`.
|
|
62
63
|
#
|
|
63
64
|
# Override precedence (most-specific first):
|
|
64
65
|
# 1. `OKSTRA_WORKTREE_SYNC_DIRS` env var — colon-separated list, REPLACES
|
|
@@ -28,6 +28,7 @@ from .resolver import (
|
|
|
28
28
|
)
|
|
29
29
|
from .state import (
|
|
30
30
|
StateError,
|
|
31
|
+
code_review_target_snapshot,
|
|
31
32
|
find_task_root,
|
|
32
33
|
list_project_tasks,
|
|
33
34
|
parse_task_key,
|
|
@@ -53,6 +54,7 @@ __all__ = [
|
|
|
53
54
|
"StateError",
|
|
54
55
|
"TASKS_RELATIVE",
|
|
55
56
|
"TASK_CATALOG_RELATIVE",
|
|
57
|
+
"code_review_target_snapshot",
|
|
56
58
|
"discovery_dir",
|
|
57
59
|
"find_task_root",
|
|
58
60
|
"list_project_tasks",
|
|
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import json
|
|
25
25
|
import re
|
|
26
|
+
import subprocess
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
from typing import Optional
|
|
28
29
|
|
|
@@ -404,3 +405,144 @@ def stage_map_read_side_snapshot(project_root: Path, task_key: str) -> dict:
|
|
|
404
405
|
"stages": stages,
|
|
405
406
|
"doneStages": done,
|
|
406
407
|
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def code_review_target_snapshot(
|
|
411
|
+
project_root: Path, task_key: str, stage: int
|
|
412
|
+
) -> dict:
|
|
413
|
+
"""stage 코드 리뷰가 무엇을 읽고 결과를 어디에 쓰는지 해소한다.
|
|
414
|
+
|
|
415
|
+
stage_map_read_side_snapshot 과 같은 결: caller 는 registry 행도, run-root
|
|
416
|
+
레이아웃도, stage base 규칙도 보지 않는다. stage_targets 의
|
|
417
|
+
StageTargetError 도 StateError 로 바꿔 던진다 — caller 가 그 예외를 잡으려고
|
|
418
|
+
okstra_ctl 을 import 하지 않도록.
|
|
419
|
+
|
|
420
|
+
base 는 stage worktree 를 만들 때 registry 행에 적힌 `base_ref` 다 — 그
|
|
421
|
+
stage 가 실제로 갈라져 나온 커밋. 리뷰 시점에 규칙으로 다시 계산하면
|
|
422
|
+
표류한다: 다중의존 stage 는 오늘의 task-key worktree HEAD 를 받게 되어
|
|
423
|
+
whole-task final-verification 이 stage 들을 병합한 뒤에는 base..head 구간이
|
|
424
|
+
비거나 뒤집히고, 단일의존 stage 는 선행 stage 가 재실행되면 어긋난다.
|
|
425
|
+
"""
|
|
426
|
+
from okstra_ctl import code_review_paths, worktree_registry
|
|
427
|
+
from okstra_ctl.implementation_outcome import load_stage_map
|
|
428
|
+
|
|
429
|
+
identity = resolve_task_identity(project_root, task_key)
|
|
430
|
+
task_root = Path(identity["taskRoot"])
|
|
431
|
+
stages = load_stage_map(task_root, identity["manifest"])
|
|
432
|
+
selected = next((s for s in stages if s["stage_number"] == stage), None)
|
|
433
|
+
if selected is None:
|
|
434
|
+
raise StateError(
|
|
435
|
+
f"stage {stage} is not in the Stage Map for {task_key}",
|
|
436
|
+
stage="stage_missing",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
coords = (identity["projectId"], identity["taskGroup"], identity["taskId"])
|
|
440
|
+
stage_row = worktree_registry.get_stage_row(*coords, stage) or {}
|
|
441
|
+
worktree_view = _stage_worktree_view(project_root, stage_row, stage)
|
|
442
|
+
base_commit = stage_row.get("base_ref") or _legacy_stage_base_commit(
|
|
443
|
+
identity, stages, selected
|
|
444
|
+
)
|
|
445
|
+
review_path, round_no = code_review_paths.next_stage_review(
|
|
446
|
+
code_review_paths.stage_review_dir(
|
|
447
|
+
project_root, identity["taskGroup"], identity["taskId"]
|
|
448
|
+
),
|
|
449
|
+
stage,
|
|
450
|
+
)
|
|
451
|
+
return {
|
|
452
|
+
"mode": "stage",
|
|
453
|
+
"taskKey": identity["taskKey"],
|
|
454
|
+
"taskRoot": identity["taskRoot"],
|
|
455
|
+
"stage": stage,
|
|
456
|
+
**worktree_view,
|
|
457
|
+
"baseCommit": base_commit,
|
|
458
|
+
"reviewPath": str(review_path),
|
|
459
|
+
"round": round_no,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _legacy_stage_base_commit(
|
|
464
|
+
identity: dict, stages: list[dict], selected: dict
|
|
465
|
+
) -> str:
|
|
466
|
+
"""`base_ref` 를 남기지 않은 옛 registry 행의 base 를 규칙에서 되살린다.
|
|
467
|
+
|
|
468
|
+
provision 이 base_ref 를 기록하기 전에 만들어진 stage 행에만 쓰인다. 규칙
|
|
469
|
+
자체는 stage_targets 가 소유하며 여기서 다시 유도하지 않는다.
|
|
470
|
+
"""
|
|
471
|
+
from okstra_ctl import stage_targets, worktree_registry
|
|
472
|
+
from okstra_ctl.paths import RunRef
|
|
473
|
+
|
|
474
|
+
coords = (identity["projectId"], identity["taskGroup"], identity["taskId"])
|
|
475
|
+
plan_run_root = RunRef.from_task_root(
|
|
476
|
+
Path(identity["taskRoot"]), "implementation-planning"
|
|
477
|
+
).run_dir
|
|
478
|
+
lifecycle = stage_targets.read_stage_lifecycle_snapshot(
|
|
479
|
+
stages, plan_run_root, recover_from_carry=True
|
|
480
|
+
)
|
|
481
|
+
# The anchor and the multi-dep candidate base must come from the task-key
|
|
482
|
+
# worktree HEAD, where stage work accumulates — not from the invocation
|
|
483
|
+
# cwd, which may sit on an older or unrelated commit.
|
|
484
|
+
task_entry = worktree_registry.lookup(*coords)
|
|
485
|
+
task_worktree = Path(
|
|
486
|
+
(task_entry.worktree_path if task_entry else "") or identity["projectRoot"]
|
|
487
|
+
)
|
|
488
|
+
anchor = worktree_registry.get_implementation_base(*coords) or ""
|
|
489
|
+
try:
|
|
490
|
+
return stage_targets.resolve_stage_base_commit(
|
|
491
|
+
selected,
|
|
492
|
+
lifecycle.done_rows,
|
|
493
|
+
anchor_base_commit=anchor,
|
|
494
|
+
candidate_base=_git_out(task_worktree, "rev-parse", "HEAD"),
|
|
495
|
+
project_root=task_worktree,
|
|
496
|
+
plan_run_root=plan_run_root,
|
|
497
|
+
)
|
|
498
|
+
except stage_targets.StageTargetError as exc:
|
|
499
|
+
raise StateError(
|
|
500
|
+
f"stage {selected['stage_number']} has no recorded base_ref and its "
|
|
501
|
+
f"base could not be derived: {exc}",
|
|
502
|
+
stage="stage_base_unresolved",
|
|
503
|
+
) from exc
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _stage_worktree_view(project_root: Path, stage_row: dict, stage: int) -> dict:
|
|
507
|
+
"""한 stage 의 worktree 경로 / 브랜치 / head 커밋.
|
|
508
|
+
|
|
509
|
+
철거된 stage worktree 는 오류가 아니다: whole-task final-verification 이
|
|
510
|
+
디렉터리를 지워도 브랜치는 남고, 리뷰가 필요한 커밋은 그 브랜치에 그대로
|
|
511
|
+
있다.
|
|
512
|
+
"""
|
|
513
|
+
branch = stage_row.get("branch") or ""
|
|
514
|
+
if not branch:
|
|
515
|
+
raise StateError(
|
|
516
|
+
f"stage {stage} has no registry branch — review it in branch mode",
|
|
517
|
+
stage="stage_branch_missing",
|
|
518
|
+
)
|
|
519
|
+
worktree_path = ""
|
|
520
|
+
if stage_row.get("status") == "active" and stage_row.get("worktree_path"):
|
|
521
|
+
candidate = Path(stage_row["worktree_path"])
|
|
522
|
+
if candidate.is_dir():
|
|
523
|
+
worktree_path = str(candidate)
|
|
524
|
+
head_source = Path(worktree_path) if worktree_path else Path(project_root)
|
|
525
|
+
head_ref = "HEAD" if worktree_path else branch
|
|
526
|
+
head_commit = _git_out(head_source, "rev-parse", head_ref)
|
|
527
|
+
if not head_commit:
|
|
528
|
+
raise StateError(
|
|
529
|
+
f"stage {stage} head could not be resolved: branch {branch!r} is "
|
|
530
|
+
f"not readable from {head_source}",
|
|
531
|
+
stage="stage_head_unresolved",
|
|
532
|
+
)
|
|
533
|
+
return {
|
|
534
|
+
"worktreePath": worktree_path,
|
|
535
|
+
"branch": branch,
|
|
536
|
+
"headCommit": head_commit,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _git_out(repo: Path, *args: str) -> str:
|
|
541
|
+
"""git 호출의 stdout. 실패는 빈 문자열 — 호출자가 부재를 판단한다."""
|
|
542
|
+
result = subprocess.run(
|
|
543
|
+
["git", "-C", str(repo), *args],
|
|
544
|
+
capture_output=True,
|
|
545
|
+
text=True,
|
|
546
|
+
check=False,
|
|
547
|
+
)
|
|
548
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: okstra-code-review
|
|
3
|
+
description: >-
|
|
4
|
+
Use when the user wants changed code reviewed and the result written to a
|
|
5
|
+
file — either an okstra task's implementation stage, or any branch
|
|
6
|
+
unrelated to an okstra run. The tell is a review request over a diff:
|
|
7
|
+
"review this stage", "review my branch", "code review", "leave the review
|
|
8
|
+
in a file". The orchestrator censuses the diff into an explicit worklist,
|
|
9
|
+
four parallel reviewers return a verdict for every cell against this
|
|
10
|
+
project's coding-preflight rules, and a coverage audit re-dispatches any
|
|
11
|
+
gap. NOT for writing a PR body (okstra-pr-gen), starting a run
|
|
12
|
+
(okstra-run), or inspecting a finished task (okstra-inspect).
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# OKSTRA Code Review
|
|
16
|
+
|
|
17
|
+
Review what a diff changed — one okstra implementation stage, or any branch — against this project's coding-preflight rules, and leave the result as a file under the project.
|
|
18
|
+
|
|
19
|
+
## What makes this one-run-complete
|
|
20
|
+
|
|
21
|
+
A reviewer left to build its own worklist enumerates the diff differently every run, so every run finds different things. Here the worklist — the **census** — is built **once, by you, deterministically**, before any reviewer is dispatched. Reviewers receive their slice as input; they never rebuild, reinterpret, or extend it.
|
|
22
|
+
|
|
23
|
+
Each reviewer returns a **verdict for every cell it was given** (`clean`, or findings), so a skipped cell is visible instead of silently absent, and Step 3.5 re-dispatches every cell that came back without one. Same census + full verdicts = the same process every run.
|
|
24
|
+
|
|
25
|
+
## Rules
|
|
26
|
+
|
|
27
|
+
The rules themselves are **not in this skill**. They live in this project's coding-preflight pack — `overview.md` (core principles plus the three-stage resource routing), `clean-code.md`, and the routed `languages/` / `frameworks/` / `architectures/` resources. Step 2 decides which of them apply; reviewers read them from the absolute paths in their brief. Never restate a rule in a brief or in the report — point at it.
|
|
28
|
+
|
|
29
|
+
This skill owns review calibration only:
|
|
30
|
+
|
|
31
|
+
- `references/census-rules.md` — how a diff becomes cells: the four axes, pack allocation, exclusions, and the two completion criteria.
|
|
32
|
+
- `references/review-calibration.md` — the per-cell verdict format, severity definitions and points, when `clean` is the correct verdict, and the report's section order.
|
|
33
|
+
|
|
34
|
+
## Step 0 — Preflight
|
|
35
|
+
|
|
36
|
+
<!-- BEGIN FRAGMENT: bash-invocation-rule -->
|
|
37
|
+
Run one Bash tool call, starting with the literal token `okstra` (never wrapped in `if`/`eval`/`export`/`$(...)`/`VAR=...`/`||`/`&&`/`npx` — a non-literal leading token defeats the `Bash(okstra:*)` permission match):
|
|
38
|
+
<!-- END FRAGMENT: bash-invocation-rule -->
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
okstra preflight --runtime claude-code --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Branch on the stdout JSON:
|
|
45
|
+
- `ok: true` → carry `projectRoot` as a literal string; every later step is anchored on it.
|
|
46
|
+
- `ok: false` → the check only sees the cwd of the Bash call, so a project that is not the cwd reads as missing setup. Ask whether the user pointed at a specific project directory; if they did, re-run targeting it: `okstra preflight --runtime claude-code --cwd <that-dir> --json` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match). Only if this **also** returns `ok:false` do you tell the user: "this project has no okstra setup. Run `/okstra-setup` first." Then stop.
|
|
47
|
+
|
|
48
|
+
<!-- BEGIN FRAGMENT: preflight-outdated-cli -->
|
|
49
|
+
If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary).
|
|
50
|
+
<!-- END FRAGMENT: preflight-outdated-cli -->
|
|
51
|
+
|
|
52
|
+
<!-- BEGIN FRAGMENT: python-bootstrap-note -->
|
|
53
|
+
Every subsequent `okstra <subcmd>` call self-bootstraps its Python path, so this skill never needs `okstra paths --shell` / `export PYTHONPATH=...`.
|
|
54
|
+
<!-- END FRAGMENT: python-bootstrap-note -->
|
|
55
|
+
|
|
56
|
+
## Step 1 — Resolve the review target
|
|
57
|
+
|
|
58
|
+
Pick the mode from what the user actually gave you:
|
|
59
|
+
|
|
60
|
+
- an okstra task token (a task-id like `DEV-9184`, or a full `project-id:task-group:task-id` key) → **stage mode**
|
|
61
|
+
- a branch name → **branch mode**
|
|
62
|
+
- neither → ask which of the two first. Do not guess a mode.
|
|
63
|
+
|
|
64
|
+
**Stage mode.**
|
|
65
|
+
|
|
66
|
+
1. `okstra resolve-task-key <token> --project-root <projectRoot> --json` → branch on `matches[]`: **0** → report the task cannot be found and stop; **1** → take that entry's `taskKey`; **N** → list the candidates (with `updatedAt`) and ask via a 3-option picker (1–2 recommendations + `Enter directly`).
|
|
67
|
+
2. `okstra stage-map <taskKey> --project <projectRoot> --json` → `stages[]` (`stage_number`, `title`, `depends_on`) and `doneStages[]`.
|
|
68
|
+
3. Choose the stage with a 3-option picker — recommend the highest done stage (the work that just finished) first, a second plausible stage if there is one, and `Enter directly` last. Pick silently only when exactly one stage exists.
|
|
69
|
+
|
|
70
|
+
**Branch mode.**
|
|
71
|
+
|
|
72
|
+
1. If the user named no branch, list `git -C <projectRoot> branch --format='%(refname:short)'` and offer a 3-option picker with the current branch as the first recommendation.
|
|
73
|
+
2. Detached HEAD — `git -C <projectRoot> symbolic-ref --quiet HEAD` prints nothing — is refused: ask the user to name a branch. Do not review a bare commit.
|
|
74
|
+
|
|
75
|
+
**Then call the target CLI.** One call resolves everything the rest of this skill needs; the only reason to call it again is a base the user overrides in the confirmation step below.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
okstra code-review target --task-key <taskKey> --stage <N> --project-root <projectRoot> --json
|
|
79
|
+
okstra code-review target --branch <name> [--base <ref>] --project-root <projectRoot> --json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`ok: true` carries `projectRoot`, `mode`, `worktreePath`, `branch`, `baseCommit`, `headCommit`, `reviewPath`, `round`; stage mode adds `taskKey`, `taskRoot`, `stage`. Carry every field verbatim into the later steps — none of them is recomputed anywhere below.
|
|
83
|
+
|
|
84
|
+
`ok: false` carries `stage` (a failure code) and `reason`. Report both and stop, unless the Exceptions table names that case.
|
|
85
|
+
|
|
86
|
+
**You never derive the base.** Pass `--base <ref>` only when the user named one; otherwise the CLI resolves it. `baseCommit` is a **ref, not necessarily a commit id** — a caller-supplied `--base` passes through verbatim — so use it as given in `git diff <baseCommit>..<headCommit>` and never present it as "commit `<sha>`".
|
|
87
|
+
|
|
88
|
+
**Where to run git.** Use `worktreePath` when it is non-empty; otherwise run git in `projectRoot` and read the stage's `branch` ref. An empty `worktreePath` does **not** mean the stage is gone: a completed stage's registry row is `released`, so the field is empty even when the directory is still on disk. Either way the commits are on the branch.
|
|
89
|
+
|
|
90
|
+
**Then show the base and confirm it — branch mode and stage mode alike.** The CLI's answer is a recommendation the user has not seen yet, and a base nobody looked at is how unrelated commits slip into a review unnoticed. Print `baseCommit` verbatim next to `git -C <workdir> log -1 --oneline <baseCommit>` so the commit it names is legible, then ask with a 3-option picker:
|
|
91
|
+
|
|
92
|
+
1. `Use <baseCommit>` — **the recommendation**, the value the CLI returned.
|
|
93
|
+
2. `Show what this range covers` — print `git -C <workdir> log --oneline <baseCommit>..<headCommit>`, then ask the same three options again.
|
|
94
|
+
3. `Enter directly` — always last: the user types a ref, and you call the target CLI a second time with `--base <that ref>`. Every field from the second call replaces the first call's, including `reviewPath` and `round`.
|
|
95
|
+
|
|
96
|
+
## Step 2 — Census the diff
|
|
97
|
+
|
|
98
|
+
1. **Read `references/census-rules.md`** (next to this file) and follow it. It owns the cells; this step owns only the inputs and what you print.
|
|
99
|
+
2. Collect the diff from the work directory chosen in Step 1 — `git -C <workdir> diff --name-status <baseCommit>..<headCommit>` for the file list, and `git -C <workdir> diff <baseCommit>..<headCommit>` for the hunks. Before trusting that range, run `git -C <workdir> rev-list --count <headCommit>..<baseCommit>`: anything but `0` is the rewritten-history case in the Exceptions table. An empty diff skips Steps 3–3.5 (also in Exceptions).
|
|
100
|
+
3. **Route the packs once, here — and fix both absolute paths the briefs carry.** `okstra paths --field home` prints the okstra home; read `<okstraHome>/prompts/coding-preflight/overview.md` and walk all three stages of its routed resource selection over the changed-file list. The result is the applied pack list — the absolute paths reviewers will read. This routing happens exactly once per run; no reviewer repeats it.
|
|
101
|
+
|
|
102
|
+
The second path is this skill's own calibration file. A subagent has no "next to this file" coordinate, so the brief must spell it out: the installed skill home is `~/.claude/skills/okstra-code-review/`, making the literal path `~/.claude/skills/okstra-code-review/references/review-calibration.md` — the same string whether the skill was copied in or dev-linked. Carry it, together with the pack list, into every Step 3 brief.
|
|
103
|
+
4. Build the cells per `references/census-rules.md` and print, in this response: one cell table per axis, the exclusion list with a reason on every entry, and the applied pack list.
|
|
104
|
+
5. Restate both completion criteria and show they hold: censused files + exclusions = files in the diff, and every hunk maps to a censused function or to file-level code.
|
|
105
|
+
|
|
106
|
+
**Membership is mechanical; judgment only ever decides a cell's verdict.** Never drop a cell because you doubt the rule applies to it — that doubt is the reviewer's answer, and its name is `clean`. Over-inclusion costs one line; a judged exclusion is the run-to-run variance this census exists to remove.
|
|
107
|
+
|
|
108
|
+
A large census is never truncated. Report the cell count and confirm before dispatching — a silent cut is a false "I looked at everything" signal.
|
|
109
|
+
|
|
110
|
+
## Step 3 — Dispatch four reviewers in parallel
|
|
111
|
+
|
|
112
|
+
Send all four Agent calls in **one message**, `subagent_type: "general-purpose"`. Every brief carries:
|
|
113
|
+
|
|
114
|
+
- the diff, plus the work directory path so the reviewer can read whole files for context
|
|
115
|
+
- the project layout in one or two lines (where source, tests, and — if the routing found one — domain / ports / adapters live)
|
|
116
|
+
- **its own axis's cell list**, verbatim from the census
|
|
117
|
+
- the absolute paths of the packs its axis reads (from step 2's routing)
|
|
118
|
+
- the calibration path, written out in full as Step 2 fixed it — `~/.claude/skills/okstra-code-review/references/review-calibration.md`. The verdict format, the severity points, and the rules for a legitimate `clean` are defined there, not in the brief; a reviewer that cannot open this file cannot return a usable verdict, so never hand it a relative path or a "next to the skill" hint
|
|
119
|
+
|
|
120
|
+
Each axis is **one rule group**, so a cell is `target × <axis>` — never `target × <individual rule>`. The reviewer names the specific rule it found violated inside the verdict's `rule` field, and one cell may carry findings from several rules of its group.
|
|
121
|
+
|
|
122
|
+
Axis scope — the `Reads` column names that group's rules, and a brief never restates them; the bodies are in the packs:
|
|
123
|
+
|
|
124
|
+
| Axis | Cell | Reads |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `structural` | non-test source file × `structural` | core principles 1, 2, 3, 6; `clean-code.md` DRY / KISS / SOLID / YAGNI; the completion sweep's domain-literal and documented-fork items; plus the architecture pack when one was routed |
|
|
127
|
+
| `semantic` | function × `semantic` | core principles 4, 5; `clean-code.md` meaningful naming, functions-do-one-thing, the plain-English summary test, the 50-line cap, nesting depth, magic numbers, comments-explain-why |
|
|
128
|
+
| `state-and-tests` | non-test source file × `state-and-tests`, test file × `state-and-tests` | `clean-code.md` "Mutation and state boundaries" and "Testing discipline"; plus the routed language pack's self-mock signals |
|
|
129
|
+
| `general` | non-test source file × `general` | correctness bugs, swallowed errors, security, clear performance problems, core principle 7 (fix at the cause — nothing previously green edited to silence a failure); `clean-code.md` "Wrapping a third-party call" — a recovery branch the library already performs, a comment naming a condition the call site never establishes, a rethrow that drops the original error; plus the routed language pack's language-specific traps |
|
|
130
|
+
|
|
131
|
+
Say it plainly in every brief: **the census is law** — work the cells exactly as given, return a verdict for every one, never re-derive the list. There is no length budget; dropping a finding to stay brief is the failure this skill exists to prevent.
|
|
132
|
+
|
|
133
|
+
## Step 3.5 — Audit the coverage
|
|
134
|
+
|
|
135
|
+
Diff each reviewer's returned cells against the slice you handed it. Any cell without a verdict → re-dispatch **one gap-fill agent per axis**, carrying only the missing cells and the same brief. Repeat until every cell of every axis has a verdict.
|
|
136
|
+
|
|
137
|
+
A missing verdict is unfinished work, never an implicit `clean`. Do not start Step 4 while a single cell is unaccounted for.
|
|
138
|
+
|
|
139
|
+
## Step 4 — Merge and write
|
|
140
|
+
|
|
141
|
+
1. **Read `references/review-calibration.md`** (next to this file) and follow its report section — you are the one writing the file, and it fixes the Coverage sentence, the per-finding line format, the Score table columns, and the total row. The reviewers were given it for their verdicts; the report obeys it too.
|
|
142
|
+
2. **Dedupe across axes.** The same defect surfaced by two axes stays once, under the rule that explains it best.
|
|
143
|
+
3. **Severity is the reviewer's.** The merge concatenates and dedupes; it never re-grades. If a verdict looks wrong, the brief was wrong — improve the brief for next run and ship this one as returned.
|
|
144
|
+
4. **Write the report to `reviewPath`** with the Write tool (it creates the parent directories). Frontmatter fields, in this order:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
mode: stage | branch
|
|
148
|
+
taskKey: <task-key> # stage mode
|
|
149
|
+
branch: <branch> # branch mode
|
|
150
|
+
stage: <N> # stage mode only
|
|
151
|
+
round: <round>
|
|
152
|
+
baseCommit: <exactly as the CLI returned it>
|
|
153
|
+
headCommit: <headCommit>
|
|
154
|
+
packs: [<applied coding-preflight pack paths>]
|
|
155
|
+
generatedAt: <YYYY-MM-DD HH:MM>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Body sections, in this order: `## Coverage`, `## Must-fix`, `## Should-fix`, `## Nits`, `## Score`. Empty severity sections are omitted; `Coverage` and `Score` are always present, and a review with no findings still emits the Score table with a total of 0.
|
|
159
|
+
5. **In the session, print only** the `reviewPath`, the count per severity, and the score total. The file is the deliverable — do not replay the findings in chat.
|
|
160
|
+
|
|
161
|
+
## Exceptions
|
|
162
|
+
|
|
163
|
+
| Situation | Handling |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `preflight` returns `ok:false` | retry with `--cwd <dir>`; if that also fails, tell the user to run `/okstra-setup` first and stop |
|
|
166
|
+
| `unknown command: code-review` | the `okstra` binary predates this skill — tell the user to update it (`npm i -g okstra@latest`) and stop |
|
|
167
|
+
| `worktreePath` is empty | not a hard stop and not a missing stage: run git in `projectRoot` against the stage's `branch` ref and continue |
|
|
168
|
+
| `baseCommit` is not an ancestor of `headCommit` — `git -C <workdir> rev-list --count <headCommit>..<baseCommit>` returns a **non-zero** count, meaning a rebase or squash rewrote the history the stage was recorded against, and `<baseCommit>..<headCommit>` would drag predecessor work in backwards | code-review is read-only, so do not force a reconcile. Offer two options: review against the branch's current tip, or run `okstra git-reconcile` first and retry |
|
|
169
|
+
| the diff is empty | dispatch no reviewers; write the "no changes" report to `reviewPath` and stop. It is a normal report, not a free-form note: the same frontmatter, `## Coverage` reading "0 changed files → 0 cells on every axis, 0 files excluded" plus the applied packs, every severity section omitted, and `## Score` carrying the table with its single total row reading 0 |
|
|
170
|
+
| the census is large | never truncate — report the cell count and confirm before dispatching |
|
|
171
|
+
| detached HEAD (branch mode) | refuse and ask the user to name a branch |
|
|
172
|
+
|
|
173
|
+
## Principles
|
|
174
|
+
|
|
175
|
+
- **Stay in the diff.** Every finding cites a line this diff changed. A cell whose only wart sits on untouched lines verdicts `clean` — pre-existing issues are not this change's problem.
|
|
176
|
+
- **Don't manufacture findings.** A census fully verdicted `clean` is a valid, useful result.
|
|
177
|
+
- **No finding without a fix.** Readability findings carry a pseudocode sketch; naming findings carry a concrete alternative name.
|
|
178
|
+
- **The census is law.** A reviewer that rebuilds its own worklist reintroduces exactly the run-to-run variance this skill exists to kill.
|
|
179
|
+
- **Every cell gets a verdict.** `clean` is a result, not an omission; the audit treats a gap as unfinished work.
|
|
180
|
+
- **The report prose is Korean.** Paths, identifiers, rule names, and quoted code stay verbatim.
|