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.
- package/README.kr.md +2 -0
- package/README.md +3 -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 +14 -10
- package/runtime/prompts/launch.template.md +6 -3
- 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 +34 -13
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/workflow.py +3 -3
- package/runtime/python/okstra_ctl/worktree.py +158 -75
- package/runtime/python/okstra_ctl/worktree_registry.py +211 -0
- package/runtime/skills/okstra-history/SKILL.md +8 -3
- package/runtime/skills/okstra-report-finder/SKILL.md +8 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -1
- package/runtime/skills/okstra-run/SKILL.md +11 -3
- package/runtime/skills/okstra-schedule/SKILL.md +8 -3
- package/runtime/skills/okstra-setup/SKILL.md +8 -2
- package/runtime/skills/okstra-status/SKILL.md +8 -3
- package/runtime/skills/okstra-time-summary/SKILL.md +8 -3
- package/runtime/validators/validate-run.py +22 -0
|
@@ -1,19 +1,34 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Per-task git worktree provisioning.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
The
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
33
|
-
# worktree at provision time. Symlinks (not copies) so
|
|
34
|
-
#
|
|
35
|
-
# any write through the link reaches the
|
|
36
|
-
# acceptable because
|
|
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 `
|
|
83
|
+
"""Result of `provision_task_worktree`.
|
|
66
84
|
|
|
67
85
|
status:
|
|
68
86
|
- "created": fresh worktree at `path` on `branch`
|
|
69
|
-
- "
|
|
70
|
-
|
|
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
|
|
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(
|
|
119
|
+
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
89
120
|
return subprocess.run(
|
|
90
|
-
["git", "-C", str(
|
|
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(
|
|
121
|
-
res = _git(
|
|
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(
|
|
142
|
-
"""Symlink each configured
|
|
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
|
|
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 = (
|
|
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.
|
|
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"
|
|
183
|
-
/
|
|
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
|
-
|
|
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
|
|
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
|
|
257
|
+
"""Materialise (or reuse) the task worktree for this run.
|
|
207
258
|
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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;
|
|
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;
|
|
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=
|
|
246
|
-
task_group_segment=
|
|
247
|
-
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=
|
|
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"
|
|
259
|
-
"Remove it with `git worktree remove <path>`
|
|
260
|
-
"not a registered worktree) before retrying
|
|
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"
|
|
265
|
-
"Delete it (`git branch -D <branch>`) or
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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 "$(
|
|
26
|
+
eval "$($OKSTRA_CMD paths --shell)"
|
|
22
27
|
export PYTHONPATH="$OKSTRA_PYTHONPATH"
|
|
23
|
-
OKSTRA_PROJECT_INFO="$(
|
|
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
|
-
|
|
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 "$(
|
|
27
|
+
eval "$($OKSTRA_CMD paths --shell)"
|
|
23
28
|
export PYTHONPATH="$OKSTRA_PYTHONPATH"
|
|
24
|
-
OKSTRA_PROJECT_INFO="$(
|
|
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).
|
|
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
|
|