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.
@@ -1,48 +1,73 @@
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
 
35
+ import json
20
36
  import os
21
37
  import subprocess
22
38
  from dataclasses import dataclass
23
39
  from pathlib import Path
24
40
  from typing import Optional
25
41
 
42
+ from .ids import _safe_fs_segment
43
+ from . import worktree_registry
44
+
26
45
 
27
46
  OKSTRA_WORKTREES_RELATIVE = Path(".okstra/worktrees")
28
47
 
29
48
 
30
49
  # Project-root directories that hold okstra task state, ignored by git, or
31
50
  # 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
51
+ # `git worktree add`. Each is symlinked from the MAIN worktree into the new
52
+ # worktree at provision time. Symlinks (not copies) so every task sees the
53
+ # live shared state and disk/CPU cost stays near zero; the trade-off is
54
+ # that any write through the link reaches the main worktree, which is
55
+ # acceptable because okstra only writes inside its own task-scoped
37
56
  # subdirectory (e.g. `.project-docs/okstra/tasks/<task-id>/runs/...`).
38
57
  #
39
- # Override via the `OKSTRA_WORKTREE_SYNC_DIRS` env var: a colon-separated
40
- # list of project-root-relative paths that REPLACES this default. Use an
41
- # empty string to disable the feature entirely.
58
+ # Override precedence (most-specific first):
59
+ # 1. `OKSTRA_WORKTREE_SYNC_DIRS` env var — colon-separated list, REPLACES
60
+ # defaults. Empty string disables the feature entirely. One-off
61
+ # operator override.
62
+ # 2. `worktreeSyncDirs` array in `.project-docs/okstra/project.json` —
63
+ # project-level config, persists across runs. Same semantics: array
64
+ # REPLACES defaults, empty array disables.
65
+ # 3. The built-in `DEFAULT_WORKTREE_SYNC_DIRS` below.
42
66
  DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
43
67
  ".project-docs",
44
68
  ".scratch",
45
69
  "graphify-out",
70
+ ".claude",
46
71
  )
47
72
 
48
73
 
@@ -62,32 +87,45 @@ _WORK_CATEGORY_PREFIX = {
62
87
 
63
88
  @dataclass
64
89
  class WorktreeProvision:
65
- """Result of `provision_implementation_worktree`.
90
+ """Result of `provision_task_worktree`.
66
91
 
67
92
  status:
68
93
  - "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
94
+ - "reused": registry already had this task-key; same path/branch
95
+ returned and no new `git worktree add` was executed
96
+ - "skipped-in-worktree": project_root is itself a non-main
71
97
  worktree; the run reuses `project_root` and no new worktree is
72
- materialised
98
+ materialised (registry NOT updated — that caller is already
99
+ isolated by virtue of its own worktree)
73
100
  - "skipped-not-git": project_root has no `.git` (worktree path
74
101
  cannot be provisioned; degrade gracefully)
75
102
  """
76
103
  status: str
77
- path: str = "" # absolute path of the executor worktree (or project_root when reused)
104
+ path: str = "" # absolute path of the task worktree (or project_root when reused)
78
105
  branch: str = "" # branch checked out in the worktree (empty when reused / not-git)
79
106
  base_ref: str = "" # commit SHA the worktree was branched from (empty when not created)
80
107
  note: str = "" # human-readable explanation, surfaced in team-state / manifests
81
108
 
82
109
 
110
+ def _safe_segment(value: str) -> str:
111
+ """Sanitise a single path/branch segment.
112
+
113
+ Forbidden chars (`/`, `:`, spaces, anything outside `[a-z0-9-]`)
114
+ are collapsed to `-`. Empty result becomes `_` so we never create
115
+ an empty path component. Delegates to the canonical slugifier in
116
+ `ids.py` to stay in lock-step with run-id / manifest segmentation.
117
+ """
118
+ return _safe_fs_segment(value)
119
+
120
+
83
121
  def _work_category_prefix(work_category: str) -> str:
84
122
  key = (work_category or "").strip().lower()
85
123
  return _WORK_CATEGORY_PREFIX.get(key, "task")
86
124
 
87
125
 
88
- def _git(project_root: Path, *args: str) -> subprocess.CompletedProcess:
126
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
89
127
  return subprocess.run(
90
- ["git", "-C", str(project_root), *args],
128
+ ["git", "-C", str(cwd), *args],
91
129
  capture_output=True, text=True, check=False,
92
130
  )
93
131
 
@@ -101,7 +139,6 @@ def _is_inside_non_main_worktree(project_root: Path) -> bool:
101
139
  per_tree = _git(project_root, "rev-parse", "--git-dir")
102
140
  if common.returncode != 0 or per_tree.returncode != 0:
103
141
  return False
104
- # Both paths can be relative to project_root; resolve before compare.
105
142
  common_abs = (project_root / common.stdout.strip()).resolve()
106
143
  per_tree_abs = (project_root / per_tree.stdout.strip()).resolve()
107
144
  return common_abs != per_tree_abs
@@ -117,32 +154,83 @@ def _branch_exists(project_root: Path, branch: str) -> bool:
117
154
  return res.returncode == 0
118
155
 
119
156
 
120
- def _head_sha(project_root: Path) -> str:
121
- res = _git(project_root, "rev-parse", "HEAD")
157
+ def _head_sha(cwd: Path) -> str:
158
+ res = _git(cwd, "rev-parse", "HEAD")
122
159
  if res.returncode != 0:
123
160
  return ""
124
161
  return res.stdout.strip()
125
162
 
126
163
 
127
- def _resolve_sync_dirs() -> tuple[str, ...]:
128
- """Return the list of project-root-relative dirs to symlink into the
129
- new worktree. Reads `OKSTRA_WORKTREE_SYNC_DIRS` if set (colon-separated,
130
- empty string disables); otherwise returns the built-in default.
164
+ def _main_worktree_path(project_root: Path) -> Path:
165
+ """Locate the repository's MAIN worktree (the original checkout).
166
+
167
+ `git worktree list --porcelain` lists worktrees in a stable order
168
+ where the first `worktree <path>` block is the main checkout.
169
+ Falls back to `project_root` if parsing fails — caller still gets
170
+ a working path, sync-dir links just point at the caller's tree
171
+ (the prior behaviour).
131
172
  """
132
- raw = os.environ.get("OKSTRA_WORKTREE_SYNC_DIRS")
133
- if raw is None:
134
- return DEFAULT_WORKTREE_SYNC_DIRS
135
- raw = raw.strip()
136
- if not raw:
137
- return ()
138
- return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
173
+ res = _git(project_root, "worktree", "list", "--porcelain")
174
+ if res.returncode != 0:
175
+ return project_root
176
+ for line in res.stdout.splitlines():
177
+ if line.startswith("worktree "):
178
+ return Path(line[len("worktree "):].strip())
179
+ return project_root
180
+
139
181
 
182
+ def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
183
+ """Read `worktreeSyncDirs` from `.project-docs/okstra/project.json`.
140
184
 
141
- def _link_sync_dirs(project_root: Path, worktree_path: Path) -> list[str]:
142
- """Symlink each configured project-root dir into the new worktree.
185
+ Returns None if the field is absent or the file cannot be parsed (so
186
+ the caller falls back to defaults). Returns an empty tuple if the
187
+ field is explicitly an empty array (caller treats this as "disable").
188
+ A non-list value is treated as missing — we do not raise here because
189
+ sync-dir resolution must never block worktree provisioning.
190
+ """
191
+ target = project_root / ".project-docs" / "okstra" / "project.json"
192
+ if not target.is_file():
193
+ return None
194
+ try:
195
+ data = json.loads(target.read_text(encoding="utf-8"))
196
+ except (OSError, json.JSONDecodeError):
197
+ return None
198
+ if not isinstance(data, dict):
199
+ return None
200
+ value = data.get("worktreeSyncDirs")
201
+ if not isinstance(value, list):
202
+ return None
203
+ cleaned = tuple(
204
+ item.strip() for item in value
205
+ if isinstance(item, str) and item.strip()
206
+ )
207
+ return cleaned
208
+
209
+
210
+ def _resolve_sync_dirs(project_root: Optional[Path] = None) -> tuple[str, ...]:
211
+ """Return the list of project-root-relative dirs to symlink into the
212
+ new worktree. Precedence: env var → project.json → built-in default.
213
+ See the comment above `DEFAULT_WORKTREE_SYNC_DIRS` for full semantics.
214
+ """
215
+ raw = os.environ.get("OKSTRA_WORKTREE_SYNC_DIRS")
216
+ if raw is not None:
217
+ raw = raw.strip()
218
+ if not raw:
219
+ return ()
220
+ return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
221
+ if project_root is not None:
222
+ from_project = _read_project_json_sync_dirs(project_root)
223
+ if from_project is not None:
224
+ return from_project
225
+ return DEFAULT_WORKTREE_SYNC_DIRS
226
+
227
+
228
+ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
229
+ """Symlink each configured dir from `source_root` (the MAIN
230
+ worktree) into the new worktree.
143
231
 
144
232
  Skip rules:
145
- - Source missing in project_root → silently skipped.
233
+ - Source missing in `source_root` → silently skipped.
146
234
  - Target path already exists in worktree (e.g. tracked content
147
235
  checked out by `git worktree add`) → skipped to avoid clobbering
148
236
  version-controlled files.
@@ -152,8 +240,8 @@ def _link_sync_dirs(project_root: Path, worktree_path: Path) -> list[str]:
152
240
  caller can include them in the provisioning note.
153
241
  """
154
242
  notes: list[str] = []
155
- for rel in _resolve_sync_dirs():
156
- src = (project_root / rel).resolve()
243
+ for rel in _resolve_sync_dirs(source_root):
244
+ src = (source_root / rel).resolve()
157
245
  if not src.exists():
158
246
  continue
159
247
  dst = worktree_path / rel
@@ -170,17 +258,20 @@ def compute_worktree_path(
170
258
  project_id: str,
171
259
  task_group_segment: str,
172
260
  task_id_segment: str,
173
- run_seq: int,
174
261
  ) -> Path:
175
- """Pure path computation. Mirrors `okstra_root` location convention.
262
+ """Pure path computation. One worktree dir per task-key.
176
263
 
177
- Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`.
264
+ Uses `OKSTRA_HOME` when set (test hook), else `~/.okstra`. Note
265
+ there is NO run-seq segment — every phase of the same task-key
266
+ shares this dir.
178
267
  """
179
268
  okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
180
269
  base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
181
270
  return (
182
- base / "worktrees" / project_id
183
- / task_group_segment / f"{task_id_segment}-{int(run_seq):03d}"
271
+ base / "worktrees"
272
+ / _safe_segment(project_id)
273
+ / _safe_segment(task_group_segment)
274
+ / _safe_segment(task_id_segment)
184
275
  )
185
276
 
186
277
 
@@ -188,46 +279,40 @@ def compute_branch_name(
188
279
  *,
189
280
  work_category: str,
190
281
  task_id_segment: str,
191
- run_seq: int,
192
282
  ) -> str:
193
- return f"{_work_category_prefix(work_category)}-{task_id_segment}-{int(run_seq):03d}"
283
+ """One branch per task-key. No run-seq — phases share the branch."""
284
+ return f"{_work_category_prefix(work_category)}-{_safe_segment(task_id_segment)}"
194
285
 
195
286
 
196
- def provision_implementation_worktree(
287
+ def provision_task_worktree(
197
288
  *,
198
289
  task_type: str,
199
290
  project_root: Path,
200
291
  project_id: str,
201
292
  task_group_segment: str,
202
293
  task_id_segment: str,
203
- run_seq: int,
204
294
  work_category: str,
205
295
  ) -> WorktreeProvision:
206
- """Materialise (or skip) the executor worktree for this run.
296
+ """Materialise (or reuse) the task worktree for this run.
207
297
 
208
- The caller passes the same `run_seq` used by the reports/manifests
209
- artefacts so the worktree directory is colocated by sequence number.
298
+ First phase of a task-key creates the worktree on a new branch.
299
+ Subsequent phases of the same task-key look up the registry and
300
+ return the existing path + branch unchanged.
210
301
 
211
302
  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.
303
+ RuntimeError when worktree creation fails (path clash on disk
304
+ that the registry does not know about, branch clash with a
305
+ different task-key, `git worktree add` non-zero). The caller
306
+ (`run.py`) catches and re-raises as PrepareError to keep a
307
+ single error surface.
216
308
  """
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
309
  if not _is_git_repo(project_root):
225
310
  return WorktreeProvision(
226
311
  status="skipped-not-git",
227
312
  path=str(project_root),
228
313
  note=(
229
314
  "worktree provisioning skipped: project_root is not inside a git "
230
- "repository; executor will operate directly on project_root"
315
+ "repository; task will operate directly on project_root"
231
316
  ),
232
317
  )
233
318
 
@@ -237,44 +322,62 @@ def provision_implementation_worktree(
237
322
  path=str(project_root),
238
323
  note=(
239
324
  "worktree provisioning skipped: project_root is already inside a "
240
- "non-main git worktree; executor reuses the caller's worktree"
325
+ "non-main git worktree; task reuses the caller's worktree"
326
+ ),
327
+ )
328
+
329
+ safe_project = _safe_segment(project_id)
330
+ safe_group = _safe_segment(task_group_segment)
331
+ safe_task = _safe_segment(task_id_segment)
332
+
333
+ # Registry lookup first — same task-key across phases must reuse.
334
+ existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
335
+ if existing is not None and existing.status == "active":
336
+ worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
337
+ return WorktreeProvision(
338
+ status="reused",
339
+ path=existing.worktree_path,
340
+ branch=existing.branch,
341
+ base_ref=existing.base_ref,
342
+ note=(
343
+ f"task worktree reused at {existing.worktree_path} on branch "
344
+ f"{existing.branch} (base {existing.base_ref[:12]}); phase {task_type}"
241
345
  ),
242
346
  )
243
347
 
244
348
  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,
349
+ project_id=safe_project,
350
+ task_group_segment=safe_group,
351
+ task_id_segment=safe_task,
249
352
  )
250
353
  branch = compute_branch_name(
251
354
  work_category=work_category,
252
- task_id_segment=task_id_segment,
253
- run_seq=run_seq,
355
+ task_id_segment=safe_task,
254
356
  )
255
357
 
256
358
  if worktree_path.exists():
257
359
  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."
360
+ f"task worktree path already exists but is not in the registry: "
361
+ f"{worktree_path}. Remove it with `git worktree remove <path>` "
362
+ "(or `rm -rf` if it is not a registered worktree) before retrying."
261
363
  )
262
364
  if _branch_exists(project_root, branch):
263
365
  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."
366
+ f"task worktree branch already exists: {branch}. "
367
+ "Delete it (`git branch -D <branch>`) or choose a different "
368
+ "work-category before retrying."
267
369
  )
268
370
 
269
- base_ref = _head_sha(project_root)
371
+ main_root = _main_worktree_path(project_root)
372
+ base_ref = _head_sha(main_root)
270
373
  if not base_ref:
271
374
  raise RuntimeError(
272
- "could not resolve HEAD sha in project_root; cannot create worktree"
375
+ "could not resolve HEAD sha in main worktree; cannot create task worktree"
273
376
  )
274
377
 
275
378
  worktree_path.parent.mkdir(parents=True, exist_ok=True)
276
379
  res = _git(
277
- project_root,
380
+ main_root,
278
381
  "worktree", "add", "-b", branch, str(worktree_path), base_ref,
279
382
  )
280
383
  if res.returncode != 0:
@@ -283,16 +386,35 @@ def provision_implementation_worktree(
283
386
  f"{(res.stderr or res.stdout).strip()}"
284
387
  )
285
388
 
286
- linked = _link_sync_dirs(project_root, worktree_path)
389
+ # Sync dirs sourced from the MAIN worktree so every task sees the
390
+ # same shared state regardless of which checkout invoked okstra.
391
+ linked = _link_sync_dirs(main_root, worktree_path)
287
392
  linked_suffix = f"; linked {', '.join(linked)}" if linked else ""
288
393
 
394
+ try:
395
+ worktree_registry.reserve(
396
+ project_id=safe_project,
397
+ task_group=safe_group,
398
+ task_id=safe_task,
399
+ worktree_path=str(worktree_path),
400
+ branch=branch,
401
+ base_ref=base_ref,
402
+ phase=task_type,
403
+ )
404
+ except RuntimeError:
405
+ # Roll back the on-disk worktree so the next attempt is not
406
+ # blocked by the lingering directory / branch.
407
+ _git(main_root, "worktree", "remove", "--force", str(worktree_path))
408
+ _git(main_root, "branch", "-D", branch)
409
+ raise
410
+
289
411
  return WorktreeProvision(
290
412
  status="created",
291
413
  path=str(worktree_path),
292
414
  branch=branch,
293
415
  base_ref=base_ref,
294
416
  note=(
295
- f"executor worktree created at {worktree_path} on branch {branch} "
296
- f"(base {base_ref[:12]}){linked_suffix}"
417
+ f"task worktree created at {worktree_path} on branch {branch} "
418
+ f"(base {base_ref[:12]}; phase {task_type}){linked_suffix}"
297
419
  ),
298
420
  )