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.
@@ -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
- result = {
121
- "projectId": project_id,
122
- "projectRoot": abs_root,
123
- "createdAt": str(data.get("createdAt") or when),
124
- "updatedAt": when,
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). The drafter does **not** invent values for these sub-sections — 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.
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: `"Use default workers and models, or customize?"`
151
+ Single `AskUserQuestion` first: `"기본 워커/모델로 진행할까요, 아니면 커스터마이즈할까요?"` (options: `Use defaults`, `Customize`).
152
152
 
153
153
  - `Use defaults` → all overrides remain empty.
154
- - `Customize` → ask each in turn (free text, blank = use default):
155
- - workers CSV (subset of `claude,codex,gemini,report-writer`)
156
- - `lead-model`, `claude-model`, `codex-model`, `gemini-model`, `report-writer-model`
157
- - `directive`
158
- - `related-tasks` CSV
159
- - `clarification-response` path (relevant for follow-up `requirements-discovery` / `error-analysis` runs)
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="<workers csv or empty>",
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