okstra 0.10.0 → 0.12.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.
@@ -1,19 +1,34 @@
1
- """Implementation-phase git worktree provisioning.
1
+ """Per-task git worktree provisioning.
2
2
 
3
- Implementation runs operate on an isolated git worktree rooted under
4
- `~/.okstra/worktrees/<project_id>/<task_group_segment>/<task_id_segment>-<run_seq>`.
5
- The executor mutates files there; verifiers read from the same path.
6
- The worktree is always kept after the run for inspection, manual PR
7
- authoring, and rollback verification.
3
+ Every okstra task regardless of task-type/phase runs inside an
4
+ isolated git worktree rooted at
5
+ `~/.okstra/worktrees/<project_id>/<task_group>/<task_id>/`. The same
6
+ worktree is reused across all phases of one task-key (requirements-
7
+ discovery error-analysis → implementation-planning → implementation),
8
+ so phase N picks up exactly the working-tree state phase N-1 left
9
+ behind.
8
10
 
9
- Pre-conditions handled here:
10
- - Skip non-`implementation` task-types entirely.
11
- - Skip when `project_root` itself already sits inside a non-main git
12
- worktree (the run reuses the caller's working tree).
13
- - Refuse to clobber an existing path or branch — raise PrepareError.
11
+ A global registry (`worktree_registry.py`) maps task-keys to the
12
+ on-disk path + branch and serialises reservations, so two concurrent
13
+ okstra runs cannot collide on the same path or branch name.
14
14
 
15
- Side effects: `git worktree add -b <branch> <path> <base_ref>` is invoked
16
- in `project_root`. The function does NOT chdir.
15
+ Pre-conditions handled here:
16
+ - Skip when `project_root` is not a git repo (degrade gracefully).
17
+ - Skip when `project_root` itself is already a non-main worktree
18
+ (caller's tree is already an isolated workspace; reuse it).
19
+ - Re-entry of the same task-key returns the existing worktree.
20
+ - Branch / path collisions across task-keys raise PrepareError-like
21
+ RuntimeError.
22
+
23
+ Side effects:
24
+ - `git worktree add -b <branch> <path> <base_ref>` invoked in the
25
+ main worktree of `project_root` (NOT `project_root` itself when it
26
+ is also a worktree — base must be the main checkout).
27
+ - Per-task sync dirs (.project-docs, .scratch, graphify-out by
28
+ default) symlinked from the **main worktree** into the new
29
+ worktree so every task sees the same shared state, irrespective of
30
+ which worktree the caller invoked okstra from.
31
+ - The function does NOT chdir.
17
32
  """
18
33
  from __future__ import annotations
19
34
 
@@ -23,17 +38,20 @@ from dataclasses import dataclass
23
38
  from pathlib import Path
24
39
  from typing import Optional
25
40
 
41
+ from .ids import _safe_fs_segment
42
+ from . import worktree_registry
43
+
26
44
 
27
45
  OKSTRA_WORKTREES_RELATIVE = Path(".okstra/worktrees")
28
46
 
29
47
 
30
48
  # Project-root directories that hold okstra task state, ignored by git, or
31
49
  # otherwise required for the executor to operate but NOT carried across by
32
- # `git worktree add`. Each is symlinked from project_root into the new
33
- # worktree at provision time. Symlinks (not copies) so the executor sees
34
- # the live state and disk/CPU cost stays near zero; the trade-off is that
35
- # any write through the link reaches the original project_root, which is
36
- # acceptable because the executor only writes inside its own task-scoped
50
+ # `git worktree add`. Each is symlinked from the MAIN worktree into the new
51
+ # worktree at provision time. Symlinks (not copies) so every task sees the
52
+ # live shared state and disk/CPU cost stays near zero; the trade-off is
53
+ # that any write through the link reaches the main worktree, which is
54
+ # acceptable because okstra only writes inside its own task-scoped
37
55
  # subdirectory (e.g. `.project-docs/okstra/tasks/<task-id>/runs/...`).
38
56
  #
39
57
  # Override via the `OKSTRA_WORKTREE_SYNC_DIRS` env var: a colon-separated
@@ -62,32 +80,45 @@ _WORK_CATEGORY_PREFIX = {
62
80
 
63
81
  @dataclass
64
82
  class WorktreeProvision:
65
- """Result of `provision_implementation_worktree`.
83
+ """Result of `provision_task_worktree`.
66
84
 
67
85
  status:
68
86
  - "created": fresh worktree at `path` on `branch`
69
- - "skipped-non-implementation": task-type was not `implementation`
70
- - "skipped-in-worktree": project_root is already inside a non-main
87
+ - "reused": registry already had this task-key; same path/branch
88
+ returned and no new `git worktree add` was executed
89
+ - "skipped-in-worktree": project_root is itself a non-main
71
90
  worktree; the run reuses `project_root` and no new worktree is
72
- materialised
91
+ materialised (registry NOT updated — that caller is already
92
+ isolated by virtue of its own worktree)
73
93
  - "skipped-not-git": project_root has no `.git` (worktree path
74
94
  cannot be provisioned; degrade gracefully)
75
95
  """
76
96
  status: str
77
- path: str = "" # absolute path of the executor worktree (or project_root when reused)
97
+ path: str = "" # absolute path of the task worktree (or project_root when reused)
78
98
  branch: str = "" # branch checked out in the worktree (empty when reused / not-git)
79
99
  base_ref: str = "" # commit SHA the worktree was branched from (empty when not created)
80
100
  note: str = "" # human-readable explanation, surfaced in team-state / manifests
81
101
 
82
102
 
103
+ def _safe_segment(value: str) -> str:
104
+ """Sanitise a single path/branch segment.
105
+
106
+ Forbidden chars (`/`, `:`, spaces, anything outside `[a-z0-9-]`)
107
+ are collapsed to `-`. Empty result becomes `_` so we never create
108
+ an empty path component. Delegates to the canonical slugifier in
109
+ `ids.py` to stay in lock-step with run-id / manifest segmentation.
110
+ """
111
+ return _safe_fs_segment(value)
112
+
113
+
83
114
  def _work_category_prefix(work_category: str) -> str:
84
115
  key = (work_category or "").strip().lower()
85
116
  return _WORK_CATEGORY_PREFIX.get(key, "task")
86
117
 
87
118
 
88
- def _git(project_root: Path, *args: str) -> subprocess.CompletedProcess:
119
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
89
120
  return subprocess.run(
90
- ["git", "-C", str(project_root), *args],
121
+ ["git", "-C", str(cwd), *args],
91
122
  capture_output=True, text=True, check=False,
92
123
  )
93
124
 
@@ -101,7 +132,6 @@ def _is_inside_non_main_worktree(project_root: Path) -> bool:
101
132
  per_tree = _git(project_root, "rev-parse", "--git-dir")
102
133
  if common.returncode != 0 or per_tree.returncode != 0:
103
134
  return False
104
- # Both paths can be relative to project_root; resolve before compare.
105
135
  common_abs = (project_root / common.stdout.strip()).resolve()
106
136
  per_tree_abs = (project_root / per_tree.stdout.strip()).resolve()
107
137
  return common_abs != per_tree_abs
@@ -117,13 +147,31 @@ def _branch_exists(project_root: Path, branch: str) -> bool:
117
147
  return res.returncode == 0
118
148
 
119
149
 
120
- def _head_sha(project_root: Path) -> str:
121
- res = _git(project_root, "rev-parse", "HEAD")
150
+ def _head_sha(cwd: Path) -> str:
151
+ res = _git(cwd, "rev-parse", "HEAD")
122
152
  if res.returncode != 0:
123
153
  return ""
124
154
  return res.stdout.strip()
125
155
 
126
156
 
157
+ def _main_worktree_path(project_root: Path) -> Path:
158
+ """Locate the repository's MAIN worktree (the original checkout).
159
+
160
+ `git worktree list --porcelain` lists worktrees in a stable order
161
+ where the first `worktree <path>` block is the main checkout.
162
+ Falls back to `project_root` if parsing fails — caller still gets
163
+ a working path, sync-dir links just point at the caller's tree
164
+ (the prior behaviour).
165
+ """
166
+ res = _git(project_root, "worktree", "list", "--porcelain")
167
+ if res.returncode != 0:
168
+ return project_root
169
+ for line in res.stdout.splitlines():
170
+ if line.startswith("worktree "):
171
+ return Path(line[len("worktree "):].strip())
172
+ return project_root
173
+
174
+
127
175
  def _resolve_sync_dirs() -> tuple[str, ...]:
128
176
  """Return the list of project-root-relative dirs to symlink into the
129
177
  new worktree. Reads `OKSTRA_WORKTREE_SYNC_DIRS` if set (colon-separated,
@@ -138,11 +186,12 @@ def _resolve_sync_dirs() -> tuple[str, ...]:
138
186
  return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
139
187
 
140
188
 
141
- def _link_sync_dirs(project_root: Path, worktree_path: Path) -> list[str]:
142
- """Symlink each configured project-root dir into the new worktree.
189
+ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
190
+ """Symlink each configured dir from `source_root` (the MAIN
191
+ worktree) into the new worktree.
143
192
 
144
193
  Skip rules:
145
- - Source missing in project_root → silently skipped.
194
+ - Source missing in `source_root` → silently skipped.
146
195
  - Target path already exists in worktree (e.g. tracked content
147
196
  checked out by `git worktree add`) → skipped to avoid clobbering
148
197
  version-controlled files.
@@ -153,7 +202,7 @@ def _link_sync_dirs(project_root: Path, worktree_path: Path) -> list[str]:
153
202
  """
154
203
  notes: list[str] = []
155
204
  for rel in _resolve_sync_dirs():
156
- src = (project_root / rel).resolve()
205
+ src = (source_root / rel).resolve()
157
206
  if not src.exists():
158
207
  continue
159
208
  dst = worktree_path / rel
@@ -170,17 +219,20 @@ def compute_worktree_path(
170
219
  project_id: str,
171
220
  task_group_segment: str,
172
221
  task_id_segment: str,
173
- run_seq: int,
174
222
  ) -> Path:
175
- """Pure path computation. Mirrors `okstra_root` location convention.
223
+ """Pure path computation. One worktree dir per task-key.
176
224
 
177
- Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`.
225
+ Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`. Note
226
+ there is NO run-seq segment — every phase of the same task-key
227
+ shares this dir.
178
228
  """
179
229
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
180
230
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
181
231
  return (
182
- base / "worktrees" / project_id
183
- / task_group_segment / f"{task_id_segment}-{int(run_seq):03d}"
232
+ base / "worktrees"
233
+ / _safe_segment(project_id)
234
+ / _safe_segment(task_group_segment)
235
+ / _safe_segment(task_id_segment)
184
236
  )
185
237
 
186
238
 
@@ -188,46 +240,40 @@ def compute_branch_name(
188
240
  *,
189
241
  work_category: str,
190
242
  task_id_segment: str,
191
- run_seq: int,
192
243
  ) -> str:
193
- return f"{_work_category_prefix(work_category)}-{task_id_segment}-{int(run_seq):03d}"
244
+ """One branch per task-key. No run-seq — phases share the branch."""
245
+ return f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
194
246
 
195
247
 
196
- def provision_implementation_worktree(
248
+ def provision_task_worktree(
197
249
  *,
198
250
  task_type: str,
199
251
  project_root: Path,
200
252
  project_id: str,
201
253
  task_group_segment: str,
202
254
  task_id_segment: str,
203
- run_seq: int,
204
255
  work_category: str,
205
256
  ) -> WorktreeProvision:
206
- """Materialise (or skip) the executor worktree for this run.
257
+ """Materialise (or reuse) the task worktree for this run.
207
258
 
208
- The caller passes the same `run_seq` used by the reports/manifests
209
- artefacts so the worktree directory is colocated by sequence number.
259
+ First phase of a task-key creates the worktree on a new branch.
260
+ Subsequent phases of the same task-key look up the registry and
261
+ return the existing path + branch unchanged.
210
262
 
211
263
  Raises:
212
- PrepareError-like RuntimeError when worktree creation fails
213
- (path clash, branch clash, `git worktree add` non-zero). The
214
- caller (`run.py`) catches and re-raises as PrepareError to keep
215
- a single error surface.
264
+ RuntimeError when worktree creation fails (path clash on disk
265
+ that the registry does not know about, branch clash with a
266
+ different task-key, `git worktree add` non-zero). The caller
267
+ (`run.py`) catches and re-raises as PrepareError to keep a
268
+ single error surface.
216
269
  """
217
- if task_type != "implementation":
218
- return WorktreeProvision(
219
- status="skipped-non-implementation",
220
- path=str(project_root),
221
- note="worktree provisioning skipped: task-type is not 'implementation'",
222
- )
223
-
224
270
  if not _is_git_repo(project_root):
225
271
  return WorktreeProvision(
226
272
  status="skipped-not-git",
227
273
  path=str(project_root),
228
274
  note=(
229
275
  "worktree provisioning skipped: project_root is not inside a git "
230
- "repository; executor will operate directly on project_root"
276
+ "repository; task will operate directly on project_root"
231
277
  ),
232
278
  )
233
279
 
@@ -237,44 +283,62 @@ def provision_implementation_worktree(
237
283
  path=str(project_root),
238
284
  note=(
239
285
  "worktree provisioning skipped: project_root is already inside a "
240
- "non-main git worktree; executor reuses the caller's worktree"
286
+ "non-main git worktree; task reuses the caller's worktree"
287
+ ),
288
+ )
289
+
290
+ safe_project = _safe_segment(project_id)
291
+ safe_group = _safe_segment(task_group_segment)
292
+ safe_task = _safe_segment(task_id_segment)
293
+
294
+ # Registry lookup first — same task-key across phases must reuse.
295
+ existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
296
+ if existing is not None and existing.status == "active":
297
+ worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
298
+ return WorktreeProvision(
299
+ status="reused",
300
+ path=existing.worktree_path,
301
+ branch=existing.branch,
302
+ base_ref=existing.base_ref,
303
+ note=(
304
+ f"task worktree reused at {existing.worktree_path} on branch "
305
+ f"{existing.branch} (base {existing.base_ref[:12]}); phase {task_type}"
241
306
  ),
242
307
  )
243
308
 
244
309
  worktree_path = compute_worktree_path(
245
- project_id=project_id,
246
- task_group_segment=task_group_segment,
247
- task_id_segment=task_id_segment,
248
- run_seq=run_seq,
310
+ project_id=safe_project,
311
+ task_group_segment=safe_group,
312
+ task_id_segment=safe_task,
249
313
  )
250
314
  branch = compute_branch_name(
251
315
  work_category=work_category,
252
- task_id_segment=task_id_segment,
253
- run_seq=run_seq,
316
+ task_id_segment=safe_task,
254
317
  )
255
318
 
256
319
  if worktree_path.exists():
257
320
  raise RuntimeError(
258
- f"executor worktree path already exists: {worktree_path}. "
259
- "Remove it with `git worktree remove <path>` (or `rm -rf` if it is "
260
- "not a registered worktree) before retrying this implementation run."
321
+ f"task worktree path already exists but is not in the registry: "
322
+ f"{worktree_path}. Remove it with `git worktree remove <path>` "
323
+ "(or `rm -rf` if it is not a registered worktree) before retrying."
261
324
  )
262
325
  if _branch_exists(project_root, branch):
263
326
  raise RuntimeError(
264
- f"executor worktree branch already exists: {branch}. "
265
- "Delete it (`git branch -D <branch>`) or bump OKSTRA_RUN_SEQ_OVERRIDE "
266
- "before retrying."
327
+ f"task worktree branch already exists: {branch}. "
328
+ "Delete it (`git branch -D <branch>`) or choose a different "
329
+ "work-category before retrying."
267
330
  )
268
331
 
269
- base_ref = _head_sha(project_root)
332
+ main_root = _main_worktree_path(project_root)
333
+ base_ref = _head_sha(main_root)
270
334
  if not base_ref:
271
335
  raise RuntimeError(
272
- "could not resolve HEAD sha in project_root; cannot create worktree"
336
+ "could not resolve HEAD sha in main worktree; cannot create task worktree"
273
337
  )
274
338
 
275
339
  worktree_path.parent.mkdir(parents=True, exist_ok=True)
276
340
  res = _git(
277
- project_root,
341
+ main_root,
278
342
  "worktree", "add", "-b", branch, str(worktree_path), base_ref,
279
343
  )
280
344
  if res.returncode != 0:
@@ -283,16 +347,35 @@ def provision_implementation_worktree(
283
347
  f"{(res.stderr or res.stdout).strip()}"
284
348
  )
285
349
 
286
- linked = _link_sync_dirs(project_root, worktree_path)
350
+ # Sync dirs sourced from the MAIN worktree so every task sees the
351
+ # same shared state regardless of which checkout invoked okstra.
352
+ linked = _link_sync_dirs(main_root, worktree_path)
287
353
  linked_suffix = f"; linked {', '.join(linked)}" if linked else ""
288
354
 
355
+ try:
356
+ worktree_registry.reserve(
357
+ project_id=safe_project,
358
+ task_group=safe_group,
359
+ task_id=safe_task,
360
+ worktree_path=str(worktree_path),
361
+ branch=branch,
362
+ base_ref=base_ref,
363
+ phase=task_type,
364
+ )
365
+ except RuntimeError:
366
+ # Roll back the on-disk worktree so the next attempt is not
367
+ # blocked by the lingering directory / branch.
368
+ _git(main_root, "worktree", "remove", "--force", str(worktree_path))
369
+ _git(main_root, "branch", "-D", branch)
370
+ raise
371
+
289
372
  return WorktreeProvision(
290
373
  status="created",
291
374
  path=str(worktree_path),
292
375
  branch=branch,
293
376
  base_ref=base_ref,
294
377
  note=(
295
- f"executor worktree created at {worktree_path} on branch {branch} "
296
- f"(base {base_ref[:12]}){linked_suffix}"
378
+ f"task worktree created at {worktree_path} on branch {branch} "
379
+ f"(base {base_ref[:12]}; phase {task_type}){linked_suffix}"
297
380
  ),
298
381
  )
@@ -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)
@@ -14,13 +14,18 @@ description: Use when the user asks to list past okstra runs, check execution hi
14
14
  ## Step 0: Verify okstra runtime + project setup
15
15
 
16
16
  ```bash
17
- npx -y okstra@latest ensure-installed >/dev/null 2>&1 || {
17
+ if command -v okstra >/dev/null 2>&1; then
18
+ OKSTRA_CMD="okstra"
19
+ else
20
+ OKSTRA_CMD="npx -y okstra@latest"
21
+ fi
22
+ $OKSTRA_CMD ensure-installed >/dev/null 2>&1 || {
18
23
  echo "FAIL: okstra not installed; tell the user to run: npx okstra@latest install" >&2
19
24
  exit 1
20
25
  }
21
- eval "$(npx -y okstra@latest paths --shell)"
26
+ eval "$($OKSTRA_CMD paths --shell)"
22
27
  export PYTHONPATH="$OKSTRA_PYTHONPATH"
23
- OKSTRA_PROJECT_INFO="$(npx -y okstra@latest check-project --json)" || {
28
+ OKSTRA_PROJECT_INFO="$($OKSTRA_CMD check-project --json)" || {
24
29
  echo "FAIL: this project has no okstra setup. Tell the user to run /okstra-setup first." >&2
25
30
  echo "$OKSTRA_PROJECT_INFO" >&2
26
31
  exit 1
@@ -15,13 +15,18 @@ user-invocable: false
15
15
  ## Step 0: Verify okstra runtime + project setup
16
16
 
17
17
  ```bash
18
- npx -y okstra@latest ensure-installed >/dev/null 2>&1 || {
18
+ if command -v okstra >/dev/null 2>&1; then
19
+ OKSTRA_CMD="okstra"
20
+ else
21
+ OKSTRA_CMD="npx -y okstra@latest"
22
+ fi
23
+ $OKSTRA_CMD ensure-installed >/dev/null 2>&1 || {
19
24
  echo "FAIL: okstra not installed; tell the user to run: npx okstra@latest install" >&2
20
25
  exit 1
21
26
  }
22
- eval "$(npx -y okstra@latest paths --shell)"
27
+ eval "$($OKSTRA_CMD paths --shell)"
23
28
  export PYTHONPATH="$OKSTRA_PYTHONPATH"
24
- OKSTRA_PROJECT_INFO="$(npx -y okstra@latest check-project --json)" || {
29
+ OKSTRA_PROJECT_INFO="$($OKSTRA_CMD check-project --json)" || {
25
30
  echo "FAIL: this project has no okstra setup. Tell the user to run /okstra-setup first." >&2
26
31
  echo "$OKSTRA_PROJECT_INFO" >&2
27
32
  exit 1
@@ -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