okstra 0.11.0 → 0.13.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 -1
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +11 -8
- package/runtime/prompts/launch.template.md +12 -28
- package/runtime/prompts/profiles/implementation.md +8 -8
- package/runtime/prompts/profiles/release-handoff.md +22 -22
- package/runtime/python/okstra_ctl/render.py +48 -0
- package/runtime/python/okstra_ctl/run.py +19 -11
- package/runtime/python/okstra_ctl/workflow.py +3 -3
- package/runtime/python/okstra_ctl/worktree.py +212 -90
- package/runtime/python/okstra_ctl/worktree_registry.py +211 -0
- package/runtime/python/okstra_project/resolver.py +9 -6
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -1
- package/runtime/skills/okstra-run/SKILL.md +52 -8
- package/runtime/skills/okstra-setup/SKILL.md +26 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Global task-worktree registry.
|
|
2
|
+
|
|
3
|
+
Tracks which `(project_id, task_group, task_id)` task-key owns which
|
|
4
|
+
on-disk worktree path and branch, across concurrent okstra runs on the
|
|
5
|
+
same machine. The registry lives under `OKSTRA_HOME` (default
|
|
6
|
+
`~/.okstra`) and is guarded by an `fcntl` exclusive lock so that two
|
|
7
|
+
processes cannot race to reserve the same path or branch.
|
|
8
|
+
|
|
9
|
+
Why a global registry:
|
|
10
|
+
- A single task-key spans multiple phases (requirements-discovery →
|
|
11
|
+
error-analysis → implementation-planning → implementation). All
|
|
12
|
+
phases must land in the **same** worktree on the **same** branch.
|
|
13
|
+
Re-entry from any phase must look up the existing entry instead of
|
|
14
|
+
creating a duplicate.
|
|
15
|
+
- Two different task-keys must never collide on the same branch name.
|
|
16
|
+
A global branch index makes that detectable cheaply.
|
|
17
|
+
- Cleanup of stale entries (worktree dir removed manually) needs a
|
|
18
|
+
single source of truth.
|
|
19
|
+
|
|
20
|
+
The registry is intentionally JSON-on-disk (no SQLite): the data set is
|
|
21
|
+
tiny (one row per active task on this machine) and the human-readable
|
|
22
|
+
file is useful for debugging.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import fcntl
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
REGISTRY_FILENAME = "registry.json"
|
|
37
|
+
LOCK_FILENAME = "registry.lock"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _okstra_worktrees_dir() -> Path:
|
|
41
|
+
home_env = os.environ.get("OKSTRA_HOME", "").strip()
|
|
42
|
+
base = Path(home_env) if home_env else (Path.home() / ".okstra")
|
|
43
|
+
return base / "worktrees"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def task_key(project_id: str, task_group: str, task_id: str) -> str:
|
|
47
|
+
"""Canonical task-key string used as the registry primary key.
|
|
48
|
+
|
|
49
|
+
Segments are NOT re-slugified here — callers must pass already
|
|
50
|
+
sanitised segments (see `worktree._safe_segment`). The key form
|
|
51
|
+
`<project>/<group>/<task>` is the same shape used for filesystem
|
|
52
|
+
paths so a key can be visually correlated with the worktree dir.
|
|
53
|
+
"""
|
|
54
|
+
return f"{project_id}/{task_group}/{task_id}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class WorktreeEntry:
|
|
59
|
+
task_key: str
|
|
60
|
+
project_id: str
|
|
61
|
+
task_group: str
|
|
62
|
+
task_id: str
|
|
63
|
+
worktree_path: str
|
|
64
|
+
branch: str
|
|
65
|
+
base_ref: str
|
|
66
|
+
created_at: str
|
|
67
|
+
last_phase: str = ""
|
|
68
|
+
status: str = "active" # "active" | "released"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextlib.contextmanager
|
|
72
|
+
def _registry_lock():
|
|
73
|
+
"""Exclusive flock on `<worktrees>/registry.lock`. Mirrors the
|
|
74
|
+
`central_lock` pattern from `locks.py` but scoped to the registry
|
|
75
|
+
so we do not serialise unrelated central operations.
|
|
76
|
+
"""
|
|
77
|
+
root = _okstra_worktrees_dir()
|
|
78
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
lockfile = root / LOCK_FILENAME
|
|
80
|
+
if not lockfile.exists():
|
|
81
|
+
lockfile.touch()
|
|
82
|
+
f = lockfile.open("r+")
|
|
83
|
+
try:
|
|
84
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
f.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _registry_path() -> Path:
|
|
91
|
+
return _okstra_worktrees_dir() / REGISTRY_FILENAME
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load() -> dict:
|
|
95
|
+
p = _registry_path()
|
|
96
|
+
if not p.exists():
|
|
97
|
+
return {"tasks": {}, "branches": {}}
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(p.read_text())
|
|
100
|
+
except (OSError, json.JSONDecodeError):
|
|
101
|
+
return {"tasks": {}, "branches": {}}
|
|
102
|
+
data.setdefault("tasks", {})
|
|
103
|
+
data.setdefault("branches", {})
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _save(data: dict) -> None:
|
|
108
|
+
p = _registry_path()
|
|
109
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
tmp = p.with_suffix(".json.tmp")
|
|
111
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
112
|
+
os.replace(tmp, p)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def lookup(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
|
|
116
|
+
"""Return the registered entry for this task-key, or None.
|
|
117
|
+
|
|
118
|
+
Does not validate that `worktree_path` still exists on disk — that
|
|
119
|
+
is the caller's responsibility (so reclaim logic can decide policy).
|
|
120
|
+
"""
|
|
121
|
+
key = task_key(project_id, task_group, task_id)
|
|
122
|
+
with _registry_lock():
|
|
123
|
+
data = _load()
|
|
124
|
+
row = data["tasks"].get(key)
|
|
125
|
+
if not row:
|
|
126
|
+
return None
|
|
127
|
+
return WorktreeEntry(task_key=key, **row)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def reserve(
|
|
131
|
+
*,
|
|
132
|
+
project_id: str,
|
|
133
|
+
task_group: str,
|
|
134
|
+
task_id: str,
|
|
135
|
+
worktree_path: str,
|
|
136
|
+
branch: str,
|
|
137
|
+
base_ref: str,
|
|
138
|
+
phase: str = "",
|
|
139
|
+
) -> WorktreeEntry:
|
|
140
|
+
"""Atomically insert a new entry. Raises RuntimeError if the
|
|
141
|
+
task-key already exists or the branch is already owned by a
|
|
142
|
+
different task-key. Callers should `lookup()` first when re-entry
|
|
143
|
+
is expected.
|
|
144
|
+
"""
|
|
145
|
+
key = task_key(project_id, task_group, task_id)
|
|
146
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S%z") or time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
147
|
+
with _registry_lock():
|
|
148
|
+
data = _load()
|
|
149
|
+
if key in data["tasks"]:
|
|
150
|
+
existing = data["tasks"][key]
|
|
151
|
+
raise RuntimeError(
|
|
152
|
+
f"task-key already has a worktree registered: {key} → "
|
|
153
|
+
f"{existing['worktree_path']} (branch {existing['branch']}). "
|
|
154
|
+
"Use `lookup` to reuse it, or release it before reserving anew."
|
|
155
|
+
)
|
|
156
|
+
owner = data["branches"].get(branch)
|
|
157
|
+
if owner and owner != key:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"branch {branch!r} is already registered to a different "
|
|
160
|
+
f"task-key: {owner}. Choose a different work-category or "
|
|
161
|
+
"release the conflicting task first."
|
|
162
|
+
)
|
|
163
|
+
row = {
|
|
164
|
+
"project_id": project_id,
|
|
165
|
+
"task_group": task_group,
|
|
166
|
+
"task_id": task_id,
|
|
167
|
+
"worktree_path": worktree_path,
|
|
168
|
+
"branch": branch,
|
|
169
|
+
"base_ref": base_ref,
|
|
170
|
+
"created_at": now,
|
|
171
|
+
"last_phase": phase,
|
|
172
|
+
"status": "active",
|
|
173
|
+
}
|
|
174
|
+
data["tasks"][key] = row
|
|
175
|
+
data["branches"][branch] = key
|
|
176
|
+
_save(data)
|
|
177
|
+
return WorktreeEntry(task_key=key, **row)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def touch_phase(project_id: str, task_group: str, task_id: str, phase: str) -> None:
|
|
181
|
+
"""Record the most recent phase observed on this worktree.
|
|
182
|
+
Best-effort: silently no-ops if the task-key is not registered.
|
|
183
|
+
"""
|
|
184
|
+
key = task_key(project_id, task_group, task_id)
|
|
185
|
+
with _registry_lock():
|
|
186
|
+
data = _load()
|
|
187
|
+
row = data["tasks"].get(key)
|
|
188
|
+
if not row:
|
|
189
|
+
return
|
|
190
|
+
row["last_phase"] = phase
|
|
191
|
+
_save(data)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def release(project_id: str, task_group: str, task_id: str) -> Optional[WorktreeEntry]:
|
|
195
|
+
"""Mark the entry as `released` (worktree dir intact — preservation
|
|
196
|
+
is the project's policy). The branch index is freed so future
|
|
197
|
+
reservations of the same branch name are not blocked.
|
|
198
|
+
Returns the prior entry, or None when not found.
|
|
199
|
+
"""
|
|
200
|
+
key = task_key(project_id, task_group, task_id)
|
|
201
|
+
with _registry_lock():
|
|
202
|
+
data = _load()
|
|
203
|
+
row = data["tasks"].get(key)
|
|
204
|
+
if not row:
|
|
205
|
+
return None
|
|
206
|
+
row["status"] = "released"
|
|
207
|
+
# Free the branch slot so reuse / new task can reclaim the name.
|
|
208
|
+
if data["branches"].get(row["branch"]) == key:
|
|
209
|
+
del data["branches"][row["branch"]]
|
|
210
|
+
_save(data)
|
|
211
|
+
return WorktreeEntry(task_key=key, **row)
|
|
@@ -117,12 +117,15 @@ def upsert_project_json(project_root: Path, project_id: str, *,
|
|
|
117
117
|
f"or manually delete {target} if you intend to re-register this directory "
|
|
118
118
|
f"under a different id. "
|
|
119
119
|
f"(projectId 불일치: 한 PROJECT_ROOT 에는 하나의 projectId 만 허용됩니다.)")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
# Preserve any user-managed fields (e.g. `worktreeSyncDirs`,
|
|
121
|
+
# `mcpServers`) so manual edits to project.json are not wiped
|
|
122
|
+
# by the per-run self-registration upsert. Only the canonical
|
|
123
|
+
# identity/timestamp fields below are owned by this function.
|
|
124
|
+
result = dict(data) if isinstance(data, dict) else {}
|
|
125
|
+
result["projectId"] = project_id
|
|
126
|
+
result["projectRoot"] = abs_root
|
|
127
|
+
result["createdAt"] = str(data.get("createdAt") or when)
|
|
128
|
+
result["updatedAt"] = when
|
|
126
129
|
else:
|
|
127
130
|
result = {
|
|
128
131
|
"projectId": project_id,
|
|
@@ -199,7 +199,9 @@ The final-report template `okstra-final-report.template.md` Section 4.5 already
|
|
|
199
199
|
|
|
200
200
|
### Release-handoff section contract (release-handoff runs only)
|
|
201
201
|
|
|
202
|
-
When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 4.6 Release Handoff Deliverables` with all seven sub-sections (`4.6.1` Source Verification Report, `4.6.2` Feature Branch & Working-Tree State, `4.6.3` User Selections, `4.6.4` Executed Commands, `4.6.5` Commit List, `4.6.6` Pull Request Outcome, `4.6.7` Routing Recommendation).
|
|
202
|
+
When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 4.6 Release Handoff Deliverables` with all seven sub-sections (`4.6.1` Source Verification Report, `4.6.2` Feature Branch & Working-Tree State, `4.6.3` User Selections, `4.6.4` Executed Commands, `4.6.5` Commit List, `4.6.6` Pull Request Outcome, `4.6.7` Routing Recommendation). Every entry is dictated by the lead's recorded git/gh command log and the user's verbatim answers to the H1/H2/H3 menu prompts. If the user picked `skip` (H1) or `cancel` (H3), keep 4.6.3 populated but leave 4.6.4–4.6.6 explicitly empty per the template's empty-state lines.
|
|
203
|
+
|
|
204
|
+
**Single-lead authorship (release-handoff only):** release-handoff has no worker roster (no `Report writer worker`, no `Claude worker` drafter). The Claude lead authors the final-report file directly — there is no `Report writer worker` dispatch to perform in Phase 6, no resume-safe dispatch concern, and no mandatory worker-results file for a report-writer role. The rest of this skill's dispatch / resume / fallback machinery applies ONLY when `Report writer worker` is in the roster (i.e. every task-type other than `release-handoff`).
|
|
203
205
|
|
|
204
206
|
The final-report template `okstra-final-report.template.md` Section 4.6 already encodes this contract — copy that block verbatim and fill in. For non-`release-handoff` runs, omit Section 4.6 entirely.
|
|
205
207
|
|
|
@@ -148,15 +148,59 @@ If `implementation` chosen, ask two more `AskUserQuestion` in order:
|
|
|
148
148
|
|
|
149
149
|
## Step 6 (optional): Directive / workers / models / related / clarification
|
|
150
150
|
|
|
151
|
-
Single `AskUserQuestion` first: `"
|
|
151
|
+
Single `AskUserQuestion` first: `"기본 워커/모델로 진행할까요, 아니면 커스터마이즈할까요?"` (options: `Use defaults`, `Customize`).
|
|
152
152
|
|
|
153
153
|
- `Use defaults` → all overrides remain empty.
|
|
154
|
-
- `Customize` → ask
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
- `Customize` → the prompts you ask depend on the `task_type` chosen in Step 4. Blank answer always means "use default". Never call the prompt label "worker CSV" — use plain Korean labels as shown below.
|
|
155
|
+
|
|
156
|
+
### 6a. `implementation` phase (executor-driven)
|
|
157
|
+
|
|
158
|
+
In this phase the roster is fixed by the profile (executor + two verifiers + report-writer). The Step 4 `executor` answer already determines who mutates code; verifier models use phase-specific defaults (`Claude verifier`=sonnet, `Codex verifier`=gpt-5.5, `Gemini verifier`=auto). So ask **only three model prompts**, plus directive/related/clarification:
|
|
159
|
+
|
|
160
|
+
1. `"리더(Claude lead) 모델? (빈 칸 = 기본값)"` → `lead_model`
|
|
161
|
+
2. `"실행자({executor-provider}) 모델? (빈 칸 = 기본값)"` → maps to `claude_model` / `codex_model` / `gemini_model` based on the Step 4 executor choice. The other two provider model fields stay empty (verifiers use defaults).
|
|
162
|
+
3. `"리포트 작성자(report-writer) 모델? (빈 칸 = 기본값)"` → `report_writer_model`
|
|
163
|
+
4. `"추가 directive (선택, 빈 칸 가능)"` → `directive`
|
|
164
|
+
5. `"관련 task id 목록, 쉼표 구분 (선택, 빈 칸 가능)"` → `related_tasks_raw`
|
|
165
|
+
|
|
166
|
+
Do NOT ask for `workers_override` in implementation — the profile's required roster must be preserved (verifier slots are mandatory). Leave `workers_override=""`.
|
|
167
|
+
|
|
168
|
+
### 6b. Other phases (`requirements-discovery`, `error-analysis`, `implementation-planning`, `final-verification`, `release-handoff`)
|
|
169
|
+
|
|
170
|
+
Ask each in turn (free text, blank = default):
|
|
171
|
+
|
|
172
|
+
1. `"참여 워커 목록 (쉼표 구분, 빈 칸 = 프로필 기본값). 선택지: claude, codex, gemini, report-writer"` → `workers_override`
|
|
173
|
+
2. `"리더(Claude lead) 모델? (빈 칸 = 기본값)"` → `lead_model`
|
|
174
|
+
3. `"claude 워커 모델? (빈 칸 = 기본값)"` → `claude_model`
|
|
175
|
+
4. `"codex 워커 모델? (빈 칸 = 기본값)"` → `codex_model`
|
|
176
|
+
5. `"gemini 워커 모델? (빈 칸 = 기본값)"` → `gemini_model`
|
|
177
|
+
6. `"리포트 작성자 모델? (빈 칸 = 기본값)"` → `report_writer_model`
|
|
178
|
+
7. `"추가 directive (선택, 빈 칸 가능)"` → `directive`
|
|
179
|
+
8. `"관련 task id 목록, 쉼표 구분 (선택, 빈 칸 가능)"` → `related_tasks_raw`
|
|
180
|
+
9. `"clarification-response 파일 경로 (follow-up 시에만, 빈 칸 가능)"` → `clarification_response_path`
|
|
181
|
+
|
|
182
|
+
For prompts whose target worker is NOT in the resolved workers list (after override), skip the prompt and present a single line such as `gemini-model 생략 (workers에 gemini 없음)`.
|
|
183
|
+
|
|
184
|
+
## Step 6.5: Confirm selections before rendering
|
|
185
|
+
|
|
186
|
+
Before calling `prepare_task_bundle`, echo the resolved selections back to the user in a compact block so they can verify what will be passed. Show the **effective** values, not the raw input — i.e. when the user left a field blank, display `default` (and where known, the actual default such as `opus` / `sonnet`). Example for an `implementation` run:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
선택 확인:
|
|
190
|
+
task-type : implementation
|
|
191
|
+
task-key : <group>/<id>
|
|
192
|
+
executor : codex
|
|
193
|
+
workers : (프로필 기본 — executor + verifier 2 + report-writer)
|
|
194
|
+
lead-model : default (opus)
|
|
195
|
+
codex-model : gpt-5.5-high ← executor model
|
|
196
|
+
claude-model : default (sonnet) ← verifier
|
|
197
|
+
gemini-model : default (auto) ← verifier
|
|
198
|
+
report-writer : default (opus)
|
|
199
|
+
directive : (none)
|
|
200
|
+
approved-plan : <abs path>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Then `AskUserQuestion`: `"이대로 진행할까요?"` with options `Proceed` / `Edit`. On `Edit`, return to the relevant Step 6 sub-prompt.
|
|
160
204
|
|
|
161
205
|
## Step 7: Call `prepare_task_bundle` directly
|
|
162
206
|
|
|
@@ -182,7 +226,7 @@ out = prepare_task_bundle(PrepareInputs(
|
|
|
182
226
|
task_type="<task-type>",
|
|
183
227
|
brief_path=brief_abs,
|
|
184
228
|
directive="<directive or empty>",
|
|
185
|
-
workers_override="<
|
|
229
|
+
workers_override="<comma-separated worker list, or empty for profile default; MUST be empty for implementation>",
|
|
186
230
|
lead_model="...", claude_model="...", codex_model="...",
|
|
187
231
|
gemini_model="...", report_writer_model="...",
|
|
188
232
|
related_tasks_raw="...",
|
|
@@ -116,6 +116,32 @@ print(result)
|
|
|
116
116
|
PY
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
## Step 4.5 (optional): customise worktree sync dirs
|
|
120
|
+
|
|
121
|
+
Each okstra run provisions a task-scoped git worktree under
|
|
122
|
+
`~/.okstra/worktrees/.../`. A small set of project-root-relative
|
|
123
|
+
directories is symlinked from the main checkout into that worktree so
|
|
124
|
+
every task sees the shared state. The built-in default is
|
|
125
|
+
`.project-docs`, `.scratch`, `graphify-out`, `.claude`.
|
|
126
|
+
|
|
127
|
+
To override per-project, add a `worktreeSyncDirs` array to
|
|
128
|
+
`project.json`. Empty array disables the feature; the field is
|
|
129
|
+
preserved across the runtime's auto-upserts (only `projectId`,
|
|
130
|
+
`projectRoot`, `createdAt`, `updatedAt` are runtime-owned).
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"projectId": "...",
|
|
135
|
+
"projectRoot": "...",
|
|
136
|
+
"worktreeSyncDirs": [".project-docs", ".scratch", ".claude", "my-custom-dir"]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Resolution precedence: `OKSTRA_WORKTREE_SYNC_DIRS` env var → this
|
|
141
|
+
field → built-in default. Only edit when defaults don't cover the
|
|
142
|
+
project's working files (e.g. additional cache or local-config dirs
|
|
143
|
+
that must follow the executor into the worktree).
|
|
144
|
+
|
|
119
145
|
## Step 5: Verify
|
|
120
146
|
|
|
121
147
|
```bash
|