okstra 0.11.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.md +1 -1
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +11 -8
- package/runtime/prompts/launch.template.md +5 -28
- package/runtime/prompts/profiles/implementation.md +8 -8
- package/runtime/prompts/profiles/release-handoff.md +22 -22
- package/runtime/python/okstra_ctl/render.py +48 -0
- package/runtime/python/okstra_ctl/run.py +19 -11
- package/runtime/python/okstra_ctl/workflow.py +3 -3
- package/runtime/python/okstra_ctl/worktree.py +158 -75
- package/runtime/python/okstra_ctl/worktree_registry.py +211 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +3 -1
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ Notable flags added in 0.7.0 / 0.8.0:
|
|
|
164
164
|
|
|
165
165
|
Recent workflow additions (post-0.8.0, on `main`):
|
|
166
166
|
|
|
167
|
-
- **Isolated
|
|
167
|
+
- **Isolated task worktree for every task-type** — prepare automatically runs `git worktree add ~/.okstra/worktrees/<project>/<group>/<task>/` on a fresh branch `<work-category-prefix>-<task-id-segment>` branched from the main worktree's `HEAD` the first time a task-key is seen. Every subsequent phase of the same task-key (`requirements-discovery` → `error-analysis` → `implementation-planning` → `implementation`) reuses the same path and branch, so phase N inherits the working-tree state phase N-1 left behind. A global registry at `~/.okstra/worktrees/registry.json` (flock-guarded) reserves task-keys and branches across concurrent runs; all path/branch segments are sanitised (`/`, `:`, etc. → `-`). The worktree is preserved after every run for follow-up phases, PR authoring, and rollback. Skip paths: when the caller is already inside another worktree or `project_root` is not a git repo, provisioning no-ops. Manual cleanup: `git worktree remove <path>` → `git branch -D <branch>` plus removing the task-key entry from the registry. Details: [`docs/kr/architecture.md`](docs/kr/architecture.md) (*Task type* section) and [`docs/kr/cli.md#--executor`](docs/kr/cli.md#--executor).
|
|
168
168
|
- **`release-handoff` lifecycle phase** — runs after `final-verification` returns `verdict=accepted`. The lead drafts a commit message and PR body via a Claude worker, then prompts the user with `AskUserQuestion` for three choices: action (`commit only` / `commit + PR` / `skip`), PR base branch (`staging` / `preprod` / `prod` / `main` / `dev` / free-form), and message handling (`use as-is` / `edit then proceed` / `cancel`). Only user-selected mutating git/gh commands run. Force-push, base-branch direct push, hook bypass (`--no-verify`), and release publishing (`gh release`, `npm publish`, ...) are forbidden. Source code is not edited in this phase. Profile: [`prompts/profiles/release-handoff.md`](prompts/profiles/release-handoff.md).
|
|
169
169
|
|
|
170
170
|
### 3.5 Ops commands
|
package/docs/kr/architecture.md
CHANGED
|
@@ -321,7 +321,7 @@ Claude launch prompt 본문은 항상 `prompts/launch.template.md` 템플릿에
|
|
|
321
321
|
공통 제약:
|
|
322
322
|
|
|
323
323
|
- `implementation`을 제외한 모든 phase는 source code edit, build, migration, deployment, 그 밖의 state-mutating 명령을 금지합니다(`final-verification`은 read-only 테스트 명령만 허용). `implementation`은 승인된 plan의 파일 목록 안에서만 edit/commit이 허용되며, `git push`·publish·deploy·실제 migration·third-party write API는 여전히 금지됩니다.
|
|
324
|
-
-
|
|
324
|
+
- **모든 task-type 격리 worktree (BLOCKING)**: 모든 task-type 의 첫 번째 phase prepare 단계에서 `okstra-ctl` 이 자동으로 task-key 단위 `git worktree` 를 생성하고, 같은 task-key 의 이후 phase (`requirements-discovery` → `error-analysis` → `implementation-planning` → `implementation`) 는 동일한 worktree·브랜치를 재사용합니다. 위치는 `~/.okstra/worktrees/<project-id>/<task-group-segment>/<task-id-segment>/` (segment 의 `/`·`:` 등 특수문자는 `-` 로 정규화) 이고, 브랜치 이름은 `<work-category-prefix>-<task-id-segment>` (예: `feat-dev-9436`, `fix-dev-7311`) 입니다. base ref 는 첫 phase prepare 시점의 main worktree `HEAD`. `~/.okstra/worktrees/registry.json` (flock-guarded) 가 task-key → path/branch 매핑을 전역 관리해 동시 실행 시 path·branch 충돌을 방지합니다. `.project-docs/`, `.scratch/`, `graphify-out/` 은 main worktree 에서 symlink 로 연결되어 모든 task 가 동일한 shared state 를 봅니다. caller 가 이미 다른 worktree 안에 있거나 project_root 가 git repo 가 아니면 provisioning 은 skip 되고 executor 는 project_root 에서 그대로 작업합니다. worktree 는 run 종료 후 자동 삭제되지 않으며 후속 phase·PR 작성·rollback 검증의 권위 artefact 입니다. 수동 cleanup: `git -C <main-worktree> worktree remove <path>` → `git -C <main-worktree> branch -D <branch>` + registry 항목 삭제. 자세한 동작은 `prompts/profiles/implementation.md` 의 *Task worktree* 블록과 `agents/SKILL.md` 의 *Task worktree (BLOCKING for every task-type)* 섹션 참고.
|
|
325
325
|
- `implementation` 과 `release-handoff` 를 제외한 모든 phase 는 source code edit, build, migration, deployment, 그 밖의 state-mutating 명령을 금지합니다 (`final-verification` 은 read-only 테스트 명령만 허용). `implementation` 은 승인된 plan 의 파일 목록 안에서만 edit/commit 이 허용되며, `git push`·publish·deploy·실제 migration·third-party write API 는 여전히 금지됩니다. `release-handoff` 는 source code 자체는 수정하지 않고, 사용자가 메뉴로 선택한 commit / push / PR 명령만 실행합니다 (force push, base 브랜치 직접 push, hook bypass, release publish 는 여전히 금지).
|
|
326
326
|
- 사용자가 "다음 단계 진행해" 같은 표현을 보내도, 그 발화만으로 다음 phase가 자동 시작되지 않습니다. 다음 phase는 새 `okstra.sh` 실행으로만 시작합니다.
|
|
327
327
|
- **Authority & permissions assumption (HARD RULE — 모든 task-type 및 `okstra-schedule` 공통)**: 사용자(및 팀)는 예상되는 모든 작업에 대해 완전한 권한·승인 권한을 보유한다고 가정합니다. 외부 승인, 서드파티 액세스, 역할/IAM 권한, 조직적 sign-off, 법무·보안 검토, 벤더 협의, "권한 보유 여부 확인" 같은 항목을 routing 결정·missing inputs·clarification questions·risk·dependency·open questions·effort/day 추정에 포함하지 않습니다. okstra 내부 phase 핸드오프(`User Approval Request` 등)는 사용자 본인이 즉시 승인 가능한 내부 게이트이므로 영향 없으며, `implementation`의 forbidden actions(`git push`, prod deploy, shared-DB migration 등)도 권한 사유가 아닌 **안전 사유**로 계속 적용됩니다.
|
package/docs/kr/cli.md
CHANGED
|
@@ -288,7 +288,7 @@ fallback 기본값은 아래와 같습니다.
|
|
|
288
288
|
- Executor 의 모델은 provider 별 worker 모델 플래그를 그대로 재사용합니다. 즉 `--executor codex` 이면 Executor 의 모델은 `--codex-model` (기본 `gpt-5.5`), `--executor gemini` 이면 `--gemini-model` (기본 `auto`) 가 됩니다.
|
|
289
289
|
- Claude/Codex/Gemini 세 verifier 는 executor provider 와 관계없이 항상 dispatch 됩니다. Executor 와 같은 provider 라도 별도 CLI 세션으로 verifier 가 호출되어 context 가 분리되므로 self-review 안전장치는 유지됩니다.
|
|
290
290
|
- 실제 파일 변경은 Codex/Gemini 의 경우 각 CLI 의 auto-edit 모드 (예: `codex exec --full-auto`) 를 통해 일어나며, Claude-side Edit/Write tool 을 거치지 않습니다.
|
|
291
|
-
- **
|
|
291
|
+
- **Task worktree (모든 task-type 자동 격리)**: 모든 task-type 의 첫 번째 phase prepare 단계에서 `okstra-ctl` 이 `~/.okstra/worktrees/<project-id>/<task-group-segment>/<task-id-segment>/` 에 `git worktree` 를 생성하고, 브랜치 `<work-category-prefix>-<task-id-segment>` 를 main worktree `HEAD` 에서 분기합니다. 같은 task-key 의 이후 phase 는 동일한 path/branch 를 재사용하므로 status 가 `reused` 로 기록됩니다 (run-prep 시점에 새 `git worktree add` 가 일어나지 않음). 모든 segment 의 `/`·`:` 등 특수문자는 `-` 로 정규화되며, `~/.okstra/worktrees/registry.json` 가 task-key → path/branch 매핑을 전역 관리합니다 (flock-guarded). Executor 의 Edit/Write/build/test/commit, verifier 의 read 는 모두 이 worktree 안에서 수행됩니다. caller 가 이미 다른 worktree 안에 있거나 project_root 가 git repo 가 아니면 provisioning 은 skip 되고 status 가 `skipped-in-worktree` / `skipped-not-git` 로 기록됩니다. 경로·브랜치 충돌은 `PrepareError` 로 즉시 실패시키며, run 종료 후 worktree 는 자동 삭제하지 않습니다 (수동: `git worktree remove` → `git branch -D` + registry 항목 삭제).
|
|
292
292
|
|
|
293
293
|
예:
|
|
294
294
|
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
package/runtime/agents/SKILL.md
CHANGED
|
@@ -107,18 +107,21 @@ Lead MUST dispatch Edit/Write-bearing work only through the `workerAgent` declar
|
|
|
107
107
|
|
|
108
108
|
Executor is chosen at run-prep time via `--executor <claude|codex|gemini>` (or `OKSTRA_DEFAULT_EXECUTOR`, fallback `claude`); the model used by the executor is taken from the corresponding worker model flag (`--claude-model` / `--codex-model` / `--gemini-model`). For Codex/Gemini executors, the underlying file mutation happens inside the executor CLI's own auto-edit mode (e.g. `codex exec --full-auto`), not through Claude-side Edit/Write tools.
|
|
109
109
|
|
|
110
|
-
####
|
|
110
|
+
#### Task worktree (BLOCKING for every task-type)
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
`okstra-ctl` provisions a dedicated `git worktree` per task-key at run-prep time of the **first** phase, and every subsequent phase of the same task-key reuses the same worktree on the same branch. Lead, the Executor, and every verifier MUST treat the provisioned worktree as the canonical working directory regardless of task-type.
|
|
113
113
|
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
114
|
+
- One worktree per task-key — `requirements-discovery`, `error-analysis`, `implementation-planning`, and `implementation` all share the same path and branch so phase N inherits the working-tree state phase N-1 left behind.
|
|
115
|
+
- Location: `~/.okstra/worktrees/<project-id>/<task-group-segment>/<task-id-segment>/` (override `OKSTRA_HOME` only for tests). All segments are sanitised — `/`, `:`, and other special chars collapse to `-`.
|
|
116
|
+
- Branch: `<work-category-prefix>-<task-id-segment>` (e.g. `feat-dev-9436`, `fix-dev-7311`). Branched from `HEAD` of the repo's **main** worktree at the first phase's prep time; base SHA is recorded in `EXECUTOR_WORKTREE_BASE_REF`.
|
|
117
|
+
- A global registry at `~/.okstra/worktrees/registry.json` (flock-guarded) maps each task-key to its path + branch and prevents concurrent runs from colliding. Branch names are globally unique across task-keys on this machine.
|
|
118
|
+
- Sync dirs (`.project-docs`, `.scratch`, `graphify-out` by default; override with `OKSTRA_WORKTREE_SYNC_DIRS`) are symlinked from the **main worktree** so every task observes the same shared state irrespective of which checkout invoked okstra.
|
|
119
|
+
- The path, branch, base ref, and provisioning status (`created` | `reused` | `skipped-in-worktree` | `skipped-not-git`) are exposed through the launch prompt's `## Executor Worktree` section and the implementation profile's worktree block.
|
|
120
|
+
- **Skip conditions** (worktree provisioning is a no-op; task uses `project_root` directly):
|
|
118
121
|
- `project_root` is already inside a non-main worktree (the run reuses the caller's worktree to avoid nesting).
|
|
119
122
|
- `project_root` is not inside a git repository at all.
|
|
120
|
-
- **Failure mode**: any other error during `git worktree add` (path collision, branch collision, detached-HEAD with no SHA) raises `PrepareError`. Re-run after manually removing the stale path/branch — the worktree is intentionally not garbage-collected.
|
|
121
|
-
- **Lifecycle**: kept after the run for manual PR authoring, rollback verification, and
|
|
123
|
+
- **Failure mode**: any other error during `git worktree add` (on-disk path collision not tracked by the registry, branch collision with a different task-key, detached-HEAD with no SHA) raises `PrepareError`. Re-run after manually removing the stale path/branch — the worktree is intentionally not garbage-collected.
|
|
124
|
+
- **Lifecycle**: kept after the run for follow-up phases, manual PR authoring, rollback verification, and `final-verification`. Manual cleanup: `git -C <main-worktree> worktree remove <path>` then `git -C <main-worktree> branch -D <branch>`; remove the corresponding registry entry by hand or via `okstra worktree release <task-key>` (when available).
|
|
122
125
|
|
|
123
126
|
## Phase 1: Task-bundle intake and required reading order
|
|
124
127
|
|
|
@@ -14,31 +14,7 @@ Invoke the `okstra` skill now. Read the manifests below for all task metadata, p
|
|
|
14
14
|
- Phase advancement requires a new okstra invocation launched with `--task-type {{WORKFLOW_NEXT_RECOMMENDED_PHASE}}` after this run's final report is written and approved. The lead must not write source code, run builds/migrations/deployments, or otherwise produce artifacts of a different phase from inside this run.
|
|
15
15
|
- See `Lifecycle Phase Boundaries` in the okstra skill (`agents/SKILL.md`) for the canonical rules and the phase-transition checklist.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Before any `Agent` dispatch for workers, you MUST perform Phase 3 of the
|
|
20
|
-
`okstra` skill (`agents/SKILL.md` → "Phase 3 — Team creation"). Skipping
|
|
21
|
-
this gate silently degrades the run to in-process background dispatch and
|
|
22
|
-
loses the Teams split-pane observability surface, even though worker
|
|
23
|
-
outputs may still appear correct on disk.
|
|
24
|
-
|
|
25
|
-
Required actions, in order, regardless of how many workers are selected
|
|
26
|
-
for this run (roster comes from `resultContract.requiredWorkerRoles` in
|
|
27
|
-
`task-manifest.json` — it may be 1, 2, 3, or more workers):
|
|
28
|
-
|
|
29
|
-
1. Invoke the `okstra-team-contract` skill and verify the selected worker
|
|
30
|
-
roster against `task-manifest.json`'s `resultContract.requiredWorkerRoles`.
|
|
31
|
-
2. Call `TeamCreate(team_name: "okstra-{{TASK_KEY}}", description: ...)`.
|
|
32
|
-
3. Record the outcome in team-state under
|
|
33
|
-
`teamCreate: { attempted: true, status: "ok" | "error", error?: <msg> }`
|
|
34
|
-
BEFORE any `Agent(...)` worker dispatch.
|
|
35
|
-
4. Only after `teamCreate` is persisted may you dispatch workers — with
|
|
36
|
-
`team_name` on success, or with `run_in_background: true` and no
|
|
37
|
-
`team_name` ONLY when `teamCreate.status == "error"` was recorded.
|
|
38
|
-
|
|
39
|
-
If the Agent tool rejects a dispatch with `"team must be created first"` /
|
|
40
|
-
`"team을 먼저 생성하거나 team_name 없이 호출해야 합니다"`, the correct
|
|
41
|
-
response is to go back to step 2 — NOT to strip `team_name` and retry.
|
|
17
|
+
{{TEAM_CREATION_GATE}}
|
|
42
18
|
|
|
43
19
|
## Project Root
|
|
44
20
|
|
|
@@ -70,9 +46,10 @@ response is to go back to step 2 — NOT to strip `team_name` and retry.
|
|
|
70
46
|
- Branch: `{{EXECUTOR_WORKTREE_BRANCH}}`
|
|
71
47
|
- Base ref: `{{EXECUTOR_WORKTREE_BASE_REF}}`
|
|
72
48
|
- Note: `{{EXECUTOR_WORKTREE_NOTE}}`
|
|
73
|
-
- For
|
|
74
|
-
-
|
|
75
|
-
-
|
|
49
|
+
- For any task-type with status `created` or `reused`, every role (lead, executor, verifier) MUST anchor reads and writes to the working tree path above — phase N inherits the working-tree state phase N-1 left behind. Verifiers read from the SAME path so they observe the exact diff produced by the Executor.
|
|
50
|
+
- Branch and path are globally reserved per task-key via `~/.okstra/worktrees/registry.json`; concurrent okstra runs on this machine cannot collide.
|
|
51
|
+
- The worktree is preserved after every run (no automatic cleanup) and is reused by every subsequent phase of the same task-key. Manual cleanup when fully done: `git worktree remove <path>` → `git branch -D <branch>` + remove the task-key entry from the registry.
|
|
52
|
+
- For status `skipped-in-worktree` or `skipped-not-git`, the run operates directly in `{{PROJECT_ROOT}}` and this section is informational only.
|
|
76
53
|
|
|
77
54
|
## Available MCP Servers
|
|
78
55
|
|
|
@@ -27,17 +27,17 @@
|
|
|
27
27
|
- **CLI ack** — the user runs `okstra ... --task-type implementation --approved-plan <path> --approve`. The CLI invocation itself is modelled as the user's act of approval; the runtime (`okstra_ctl.run._apply_cli_approval`) flips the checkbox in the report file and appends an audit line `- 승인 일시 (CLI ack): <ISO8601> — recorded by \`okstra --approve\`` before the standard regex validation runs. Use this when running unattended or when you want a single command to both approve and launch the next phase.
|
|
28
28
|
- The `--approve` flag is **only meaningful with `--task-type implementation` and `--approved-plan <path>`**. Passing it with any other task-type causes `PrepareError` (the runtime refuses to silently ignore approval signals). It is also a no-op if the file already carries a valid approval marker (idempotent — only an audit line is appended, the marker is not re-toggled).
|
|
29
29
|
- the file's `Recommended option` and its bite-sized step list become the authoritative scope for this run; any deviation must be justified in the final report and routed back to a new `implementation-planning` run instead of being silently expanded.
|
|
30
|
-
-
|
|
31
|
-
- Status: `{{EXECUTOR_WORKTREE_STATUS}}` (one of: `created` | `skipped-in-worktree` | `skipped-not-git`)
|
|
32
|
-
- Working tree path: `{{EXECUTOR_WORKTREE_PATH}}` — when status is `created`, this is
|
|
33
|
-
- Branch: `{{EXECUTOR_WORKTREE_BRANCH}}` — empty when status is `skipped-*`. The branch name encodes `<work-category-prefix>-<task-id-segment
|
|
34
|
-
- Base ref: `{{EXECUTOR_WORKTREE_BASE_REF}}` — commit SHA the worktree was branched from; canonical `<base>` for every `git diff` / `git log` in this run.
|
|
30
|
+
- Task worktree (provisioned by `okstra-ctl` at the first phase's run-prep time, reused for every subsequent phase of this task-key):
|
|
31
|
+
- Status: `{{EXECUTOR_WORKTREE_STATUS}}` (one of: `created` | `reused` | `skipped-in-worktree` | `skipped-not-git`)
|
|
32
|
+
- Working tree path: `{{EXECUTOR_WORKTREE_PATH}}` — when status is `created` or `reused`, this is the task's `git worktree` rooted at `~/.okstra/worktrees/<project>/<task-group>/<task-id>/` (segments sanitised — `/` `:` → `-`). When skipped, this is the caller's `project_root`.
|
|
33
|
+
- Branch: `{{EXECUTOR_WORKTREE_BRANCH}}` — empty when status is `skipped-*`. The branch name encodes `<work-category-prefix>-<task-id-segment>` and is globally unique across task-keys via `~/.okstra/worktrees/registry.json`.
|
|
34
|
+
- Base ref: `{{EXECUTOR_WORKTREE_BASE_REF}}` — commit SHA the worktree was branched from at the first phase; canonical `<base>` for every `git diff` / `git log` in this run.
|
|
35
35
|
- Provisioning note: `{{EXECUTOR_WORKTREE_NOTE}}`
|
|
36
|
-
- **Executor behaviour**: when status is `created`, the Executor MUST run every Edit / Write / build / test / commit command with the working tree path above as cwd. Treat it as `project_root` for the duration of this run. Do NOT mutate the caller's original checkout. Do NOT `cd` out of the worktree to reach files; if a file outside the worktree is needed, the dependency is a planning gap — record it in `Out-of-plan edits` and continue.
|
|
36
|
+
- **Executor behaviour**: when status is `created` or `reused`, the Executor MUST run every Edit / Write / build / test / commit command with the working tree path above as cwd. Treat it as `project_root` for the duration of this run. Do NOT mutate the caller's original checkout. Do NOT `cd` out of the worktree to reach files; if a file outside the worktree is needed, the dependency is a planning gap — record it in `Out-of-plan edits` and continue.
|
|
37
37
|
- **Verifier behaviour**: all three verifier roles read from the SAME working tree path so they observe the exact diff the Executor produced. Verifiers remain strictly read-only there.
|
|
38
|
-
- **Lifecycle**: the worktree is kept after the run completes (no automatic cleanup)
|
|
38
|
+
- **Lifecycle**: the worktree is kept after the run completes (no automatic cleanup) and is reused by every subsequent phase of the same task-key. Cleanup, when the task is fully done, is manual: `git -C <main-worktree> worktree remove <path>` followed by `git -C <main-worktree> branch -D <branch>`, plus removing the task-key entry from `~/.okstra/worktrees/registry.json`.
|
|
39
39
|
- **Skipped paths**: when status is `skipped-in-worktree` or `skipped-not-git`, the executor operates in `project_root` as before. Cite the status in the final report's metadata header so reviewers know which path was taken.
|
|
40
|
-
- **Synced state directories (symlinks into the
|
|
40
|
+
- **Synced state directories (symlinks into the MAIN worktree)**: at provision time `okstra-ctl` symlinks `.project-docs/`, `.scratch/`, and `graphify-out/` from the repo's **main worktree** into the task worktree (override via `OKSTRA_WORKTREE_SYNC_DIRS`; empty string disables). These are NOT independent copies — writes through them land in the main worktree. Inside this run the executor MUST confine writes under these paths to its own task scope (i.e. only `.project-docs/okstra/tasks/<this-task-id>/...`). Reading from elsewhere under the symlinks (other tasks, `graphify-out/GRAPH_REPORT.md`, `.scratch/` issues) is allowed and expected for context.
|
|
41
41
|
- Pre-implementation context exploration (executor before first edit):
|
|
42
42
|
- **Mandatory skill invocation — `tdd`**: BEFORE the first `Edit` or `Write` call, the executor MUST invoke the `tdd` skill via the `Skill` tool and follow its red-green-refactor loop for every code change in this run. This is a hard requirement, not a recommendation; skipping it is a `contract-violated` outcome. The skill governs HOW each step is executed (failing test first → minimal implementation → refactor); it does not override the approved plan's WHAT/file scope.
|
|
43
43
|
- Order of operations per plan step: (1) write/extend the test that captures the step's acceptance criterion and confirm it fails for the right reason, (2) commit the failing test (`test(<scope>): ...`), (3) implement the minimum change to make it pass, (4) commit the implementation (`feat|fix(<scope>): ...`), (5) refactor without changing behaviour and commit separately if any cleanup is made (`refactor(<scope>): ...`). The failing-then-passing transition between steps (2) and (4) is the `TDD evidence` required by the final report.
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Release Handoff Profile
|
|
2
2
|
|
|
3
3
|
- Purpose: take an `accepted` final-verification verdict and turn it into a delivered commit and/or pull request, with explicit user selection at every mutating step
|
|
4
|
-
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
4
|
+
- **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT run team-mode, does NOT call `TeamCreate`, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The Claude lead performs every step inline (drafting commit/PR text, asking the user, running git, writing the final report) — see "Lead-only contract" below.
|
|
5
|
+
- Required workers: *(none — this profile intentionally has no `- Required workers:` block; the run is executed entirely by the Claude lead)*
|
|
6
|
+
- Lead-only contract (replaces the shared team contract for this phase):
|
|
7
|
+
- The Claude lead is the sole agent for this run. No `Agent(...)` worker dispatch, no `TeamCreate`, no parallel sub-agents, no convergence loop.
|
|
8
|
+
- The lead drafts the commit message and PR body **inline** by reading the run brief, the cited final-verification report, and `git diff <base>..HEAD --stat`. No drafter worker is dispatched.
|
|
9
|
+
- The lead authors the final-report file directly (no `Report writer worker` dispatch). The report still conforms to the standard `okstra-final-report.template.md` structure, including the `## 4.6 Release Handoff Deliverables` section.
|
|
10
|
+
- The shared anti-escalation rule from the common contract still applies: do not start any other lifecycle phase from inside this run.
|
|
11
|
+
- The shared "authority & permissions assumption" rule from the common contract still applies: assume the user holds every permission needed; do not block on hypothetical approvals.
|
|
12
|
+
- The shared "MCP read-only" rule still applies if the brief lists MCP servers, though most release-handoff runs do not use MCP.
|
|
12
13
|
- Pre-handoff entry gate (mandatory — refuse to start if any item fails):
|
|
13
14
|
- the task brief MUST cite the originating `final-verification` final-report path under `## Source Verification Report`. The lead opens that file and confirms section `## 2. Final Verdict` contains exactly the token `accepted`.
|
|
14
15
|
- if the verdict is `conditional-accept`, `blocked`, or any other token (including ambiguous phrasing like "looks good"), the run MUST end immediately with status `blocked` and a routing recommendation back to `error-analysis` or `implementation-planning`. Do NOT prompt the user; do NOT run any git command.
|
|
@@ -28,18 +29,17 @@
|
|
|
28
29
|
- `dev`
|
|
29
30
|
- `직접 입력` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
|
|
30
31
|
The chosen base MUST NOT equal the feature branch. If it does, re-ask.
|
|
31
|
-
3. **Commit message + PR body confirmation** — show the
|
|
32
|
-
- `use as-is` — proceed with the
|
|
32
|
+
3. **Commit message + PR body confirmation** — show the lead's inline draft verbatim and capture one of:
|
|
33
|
+
- `use as-is` — proceed with the drafted text.
|
|
33
34
|
- `edit then proceed` — accept inline edits from the user, then proceed with the edited text.
|
|
34
35
|
- `cancel` — end the run without executing any git command; record the cancellation in the final report.
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
1. **Commit message
|
|
39
|
-
2. **PR body
|
|
40
|
-
- is
|
|
41
|
-
|
|
42
|
-
- Allowed actions during the run (Claude lead only — workers stay read-only):
|
|
36
|
+
- Inline drafting rules (Claude lead):
|
|
37
|
+
- read the run brief, the cited final-verification report, and `git diff <base>..HEAD --stat` to ground the drafted text in actual changes.
|
|
38
|
+
- produce **two artifacts** before showing them to the user:
|
|
39
|
+
1. **Commit message** — a single message in Conventional Commits style (`<type>(<scope>): <subject>` + optional body + optional footer). When `commit + PR` will be opened against a `release-please`-managed repo, the type MUST match a configured changelog section (`feat` / `fix` / `perf` / `revert` / `deps` / `docs` / `refactor` / `build` / `ci` / `chore` / `test`).
|
|
40
|
+
2. **PR body** — markdown with sections `## Summary`, `## Changes`, `## Test plan`, `## Linked issues` (omit a section only if it is genuinely empty).
|
|
41
|
+
- if the diff is empty or no commit can be produced (working tree already matches the base), record "no staged changes; commit skipped" in the final report and skip `git commit` while still proceeding to the PR step if requested.
|
|
42
|
+
- Allowed actions during the run (Claude lead only):
|
|
43
43
|
- read-only inspection: `git status`, `git status --short`, `git diff`, `git log`, `git rev-parse`, `git ls-remote --heads origin <name>`, `gh pr list --head <branch>`, `gh pr view <url>`.
|
|
44
44
|
- local commit: `git add -- <path>...` (prefer explicit file paths over `git add -A` / `git add .`), `git commit -m "<message>"`. Re-use the user-confirmed message exactly.
|
|
45
45
|
- feature-branch push (only when the user picked `commit + PR`): `git push -u origin <current-branch>`. The pushed ref MUST be the feature branch — never the chosen base branch.
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
- source-code edits, refactors, or any modification to files outside the run's own artifact directories (`reports/`, `prompts/`, `state/`, `manifests/`, `worker-results/`, `status/`, `sessions/`). The diff being shipped MUST be exactly what the prior `implementation` run produced; release-handoff packages it, it does not re-author it.
|
|
60
60
|
- executing any mutating command the user did NOT select. Examples: opening a PR when the user picked `commit only`; running `git commit` when the user picked `skip`; switching the PR base branch silently after the user already chose one.
|
|
61
61
|
- retrying a failed git / gh command with weaker safety flags. If `git push` fails with non-fast-forward, the lead MUST stop, explain the failure to the user, and ask for instructions — it MUST NOT add `--force`.
|
|
62
|
-
-
|
|
62
|
+
- `TeamCreate`, `Agent(...)` dispatch of any kind, or any other parallel sub-agent fan-out. This phase runs entirely under the Claude lead.
|
|
63
63
|
- silently treating an unrecognised user reply as one of the menu options. If the user's answer does not match a presented choice, re-ask the question verbatim.
|
|
64
64
|
- Required deliverable shape (final report, in addition to the standard sections):
|
|
65
65
|
- **Source Verification Report**: relative path of the originating `final-verification` final-report file plus the literal quoted `## 2. Final Verdict` line that read `accepted`.
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
- **User Selections**: a block recording each prompt and the user's verbatim answer.
|
|
68
68
|
- Q1 action: `commit only` | `commit + PR` | `skip`.
|
|
69
69
|
- Q2 PR base (if applicable): the chosen branch and how it was selected (menu pick vs free-form input).
|
|
70
|
-
- Q3 message/body: `use as-is` | `edit then proceed` (with a diff between the
|
|
70
|
+
- Q3 message/body: `use as-is` | `edit then proceed` (with a diff between the lead's draft and the final text) | `cancel`.
|
|
71
71
|
- **Executed Commands**: every git / gh command the lead actually ran, with its exit code and a one-line stdout/stderr summary. Read-only inspection commands MAY be summarised; mutating commands MUST be listed verbatim.
|
|
72
72
|
- **Commit List**: each commit SHA (short and full), its subject line, and the files it touched. If no commit was produced (idempotent no-op), state `- No commit was produced (working tree had no staged changes).`
|
|
73
73
|
- **Pull Request Outcome**: one of
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
- `- PR reused: <url>` when an existing PR was found via `gh pr list`
|
|
77
77
|
- `- PR creation skipped: <reason>` for any user-driven cancellation
|
|
78
78
|
- **Routing recommendation**: explicit `done` token, since release-handoff is the terminal lifecycle phase. If the run ended in `skip` or `cancel`, the recommendation MUST also state whether re-entry into release-handoff is appropriate.
|
|
79
|
-
- Self-review pass before finalising the report (`Claude lead` runs this
|
|
79
|
+
- Self-review pass before finalising the report (`Claude lead` runs this):
|
|
80
80
|
1. **Entry-gate audit** — section 2 cites the originating final-verification report path and the literal `accepted` verdict line. If either is missing, the run is invalid and MUST be re-routed to `final-verification`.
|
|
81
81
|
2. **User-selection traceability** — every executed mutating command maps to a user selection captured in the report. Any mutating command without a corresponding user answer is a contract violation.
|
|
82
82
|
3. **Forbidden-action audit** — scan the run's session transcripts (`git`, `gh` invocations) for every entry in the Forbidden actions list above. Any occurrence means the run has crossed into unsafe territory and MUST be flagged as `contract-violated`.
|
|
@@ -86,4 +86,4 @@
|
|
|
86
86
|
- re-litigating the final-verification verdict — release-handoff trusts the cited `accepted` verdict and does not reopen acceptance checks.
|
|
87
87
|
- opening additional PRs, releases, or deployments beyond the single PR the user chose to create.
|
|
88
88
|
- merging the PR. Merging is a separate, manual step performed by the user (or by repo automation) after release-handoff ends; the lead MUST NOT call `gh pr merge`.
|
|
89
|
-
- escalating beyond the menu choices on user phrasing — every mutating action requires an explicit menu selection
|
|
89
|
+
- escalating beyond the menu choices on user phrasing — every mutating action requires an explicit menu selection.
|
|
@@ -986,7 +986,55 @@ def render_template_file(template_path: str, output_path: str, ctx: dict) -> Non
|
|
|
986
986
|
else "- `Gemini worker` is not selected for this run, so no Gemini attempt is required."
|
|
987
987
|
)
|
|
988
988
|
|
|
989
|
+
# Team creation gate block: phase-conditional.
|
|
990
|
+
# - release-handoff (and any other phase that renders with no workers
|
|
991
|
+
# selected) is single-lead and MUST NOT call `TeamCreate`. Emit a
|
|
992
|
+
# short notice instead of the BLOCKING gate.
|
|
993
|
+
# - All other phases keep the full team-creation contract.
|
|
994
|
+
task_type = ctx.get("ANALYSIS_TYPE", "")
|
|
995
|
+
if task_type == "release-handoff" or not selected:
|
|
996
|
+
team_creation_gate_block = (
|
|
997
|
+
"## Single-Lead Phase (no team creation)\n"
|
|
998
|
+
"\n"
|
|
999
|
+
"This run is single-lead. There is no worker roster, no\n"
|
|
1000
|
+
"`TeamCreate` call, no `Agent(...)` worker dispatch, and no\n"
|
|
1001
|
+
"convergence loop. The Claude lead performs every step inline\n"
|
|
1002
|
+
"(reading inputs, drafting commit / PR text when applicable,\n"
|
|
1003
|
+
"asking the user, running git / gh, and writing the final\n"
|
|
1004
|
+
"report). Do NOT call `TeamCreate` or dispatch any sub-agent\n"
|
|
1005
|
+
"from this run — that would be a contract violation."
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
team_creation_gate_block = (
|
|
1009
|
+
"## Team Creation Gate (BLOCKING)\n"
|
|
1010
|
+
"\n"
|
|
1011
|
+
"Before any `Agent` dispatch for workers, you MUST perform Phase 3 of the\n"
|
|
1012
|
+
"`okstra` skill (`agents/SKILL.md` → \"Phase 3 — Team creation\"). Skipping\n"
|
|
1013
|
+
"this gate silently degrades the run to in-process background dispatch and\n"
|
|
1014
|
+
"loses the Teams split-pane observability surface, even though worker\n"
|
|
1015
|
+
"outputs may still appear correct on disk.\n"
|
|
1016
|
+
"\n"
|
|
1017
|
+
"Required actions, in order, regardless of how many workers are selected\n"
|
|
1018
|
+
"for this run (roster comes from `resultContract.requiredWorkerRoles` in\n"
|
|
1019
|
+
"`task-manifest.json` — it may be 1, 2, 3, or more workers):\n"
|
|
1020
|
+
"\n"
|
|
1021
|
+
"1. Invoke the `okstra-team-contract` skill and verify the selected worker\n"
|
|
1022
|
+
" roster against `task-manifest.json`'s `resultContract.requiredWorkerRoles`.\n"
|
|
1023
|
+
f"2. Call `TeamCreate(team_name: \"okstra-{ctx.get('TASK_KEY', '')}\", description: ...)`.\n"
|
|
1024
|
+
"3. Record the outcome in team-state under\n"
|
|
1025
|
+
" `teamCreate: { attempted: true, status: \"ok\" | \"error\", error?: <msg> }`\n"
|
|
1026
|
+
" BEFORE any `Agent(...)` worker dispatch.\n"
|
|
1027
|
+
"4. Only after `teamCreate` is persisted may you dispatch workers — with\n"
|
|
1028
|
+
" `team_name` on success, or with `run_in_background: true` and no\n"
|
|
1029
|
+
" `team_name` ONLY when `teamCreate.status == \"error\"` was recorded.\n"
|
|
1030
|
+
"\n"
|
|
1031
|
+
"If the Agent tool rejects a dispatch with `\"team must be created first\"` /\n"
|
|
1032
|
+
"`\"team을 먼저 생성하거나 team_name 없이 호출해야 합니다\"`, the correct\n"
|
|
1033
|
+
"response is to go back to step 2 — NOT to strip `team_name` and retry."
|
|
1034
|
+
)
|
|
1035
|
+
|
|
989
1036
|
mapping = {
|
|
1037
|
+
"{{TEAM_CREATION_GATE}}": team_creation_gate_block,
|
|
990
1038
|
"{{PROJECT_ID}}": ctx.get("PROJECT_ID", ""),
|
|
991
1039
|
"{{TASK_GROUP}}": ctx.get("TASK_GROUP", ""),
|
|
992
1040
|
"{{TASK_ID}}": ctx.get("TASK_ID", ""),
|
|
@@ -58,7 +58,7 @@ from .session import (
|
|
|
58
58
|
)
|
|
59
59
|
from .workers import normalize_workers, resolve_profile_workers
|
|
60
60
|
from .workflow import compute_workflow_state
|
|
61
|
-
from .worktree import
|
|
61
|
+
from .worktree import provision_task_worktree
|
|
62
62
|
|
|
63
63
|
APPROVED_PLAN_PATTERN = re.compile(
|
|
64
64
|
r"^[ \t]*(?:[-*+][ \t]+)?(APPROVED([ \t]|:|$)|\[x\][ \t]*Approved|"
|
|
@@ -457,10 +457,17 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
457
457
|
raise PrepareError(f"project.json upsert failed for {project_root}: {exc}") from exc
|
|
458
458
|
|
|
459
459
|
# ---- workers resolution ----
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
# release-handoff is intentionally single-lead (no worker dispatch, no
|
|
461
|
+
# TeamCreate, no convergence). The profile has no `- Required workers:`
|
|
462
|
+
# block; force an empty roster regardless of any user override so the
|
|
463
|
+
# rendered task bundle stays consistent with the profile contract.
|
|
464
|
+
if inp.task_type == "release-handoff":
|
|
465
|
+
workers: list[str] = []
|
|
466
|
+
else:
|
|
467
|
+
profile_workers_csv = ",".join(resolve_profile_workers(profile_file))
|
|
468
|
+
workers = normalize_workers(inp.workers_override or profile_workers_csv)
|
|
469
|
+
if not workers:
|
|
470
|
+
raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
|
|
464
471
|
selected_reviewers = ",".join(workers)
|
|
465
472
|
|
|
466
473
|
# ---- model assignments ----
|
|
@@ -517,21 +524,22 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
517
524
|
task_type=inp.task_type, run_seq_override=run_seq_override,
|
|
518
525
|
)
|
|
519
526
|
|
|
520
|
-
# ----
|
|
521
|
-
#
|
|
522
|
-
#
|
|
527
|
+
# ---- task worktree provisioning (every phase, every task-type) ----
|
|
528
|
+
# One worktree per task-key: requirements-discovery, error-analysis,
|
|
529
|
+
# implementation-planning and implementation phases of the same task
|
|
530
|
+
# all share this directory and branch. The global registry handles
|
|
531
|
+
# reservation across concurrent runs.
|
|
523
532
|
try:
|
|
524
|
-
worktree =
|
|
533
|
+
worktree = provision_task_worktree(
|
|
525
534
|
task_type=inp.task_type,
|
|
526
535
|
project_root=project_root,
|
|
527
536
|
project_id=inp.project_id,
|
|
528
537
|
task_group_segment=ctx["TASK_GROUP_SEGMENT"],
|
|
529
538
|
task_id_segment=ctx["TASK_ID_SEGMENT"],
|
|
530
|
-
run_seq=int(ctx["RUN_REPORTS_SEQ"]),
|
|
531
539
|
work_category=inp.work_category,
|
|
532
540
|
)
|
|
533
541
|
except RuntimeError as exc:
|
|
534
|
-
raise PrepareError(f"
|
|
542
|
+
raise PrepareError(f"task worktree provisioning failed: {exc}") from exc
|
|
535
543
|
|
|
536
544
|
ctx.update({
|
|
537
545
|
"EXECUTOR_WORKTREE_PATH": worktree.path,
|
|
@@ -127,11 +127,11 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
127
127
|
" - entering this phase only when the cited final-verification report's verdict is exactly `accepted`\n"
|
|
128
128
|
" - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `commit only`, `commit + PR`, or `skip` (end the run)\n"
|
|
129
129
|
" - asking the user to pick a PR base branch from `staging` | `preprod` | `prod` | `main` | `dev` | a user-supplied branch name\n"
|
|
130
|
-
" -
|
|
130
|
+
" - drafting commit message(s) and PR body **inline as the Claude lead** (no drafter worker, no `Agent` dispatch); the lead reviews its own draft with the user before any git command runs\n"
|
|
131
131
|
" - local git operations: `git status`, `git diff`, `git log`, `git add`, `git commit -m`\n"
|
|
132
132
|
" - pushing the current feature branch to its origin remote via `git push -u origin <current-branch>` (the feature branch only — NEVER the base branch)\n"
|
|
133
133
|
" - creating a pull request via `gh pr create --base <chosen-base> --head <current-branch>`; if a PR with the same head already exists, surface its URL and skip creation\n"
|
|
134
|
-
" -
|
|
134
|
+
" - the lead writes the final report directly (no `Report writer worker` dispatch); the report still conforms to the standard final-report template"
|
|
135
135
|
),
|
|
136
136
|
"forbidden": (
|
|
137
137
|
" - entering this phase when the cited final-verification verdict is `conditional-accept` or `blocked`, or when no final-verification report is cited\n"
|
|
@@ -141,7 +141,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
141
141
|
" - bypassing git hooks (`--no-verify`, `-n`), bypassing GPG signing, or otherwise disabling repo-configured safeguards\n"
|
|
142
142
|
" - release-publishing commands: `gh release`, `npm publish`, `cargo publish`, `pip publish`, `docker push`, `terraform apply`, `kubectl apply` against non-local clusters\n"
|
|
143
143
|
" - executing any command the user did NOT select (e.g. if the user picked `commit only`, opening a PR is forbidden; if the user picked `skip`, the run ends without git commands)\n"
|
|
144
|
-
" -
|
|
144
|
+
" - `TeamCreate`, `Agent(...)` worker dispatch, parallel sub-agent fan-out, or any team-mode orchestration — this phase runs single-lead\n"
|
|
145
145
|
" - silently retrying a failed git/gh command with weaker flags (e.g. retrying `git push` with `--force` after a non-fast-forward rejection)"
|
|
146
146
|
),
|
|
147
147
|
},
|
|
@@ -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)
|
|
@@ -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
|
|