okstra 0.222.0 → 0.223.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/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +8 -6
- package/runtime/python/okstra_ctl/phases/release_handoff/entry.py +42 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/report_projections.py +1 -0
- package/runtime/python/okstra_ctl/run.py +1 -0
- package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
- package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
- package/runtime/schemas/final-report-v2.0.schema.json +7 -2
- package/runtime/schemas/final-report-v3.0.schema.json +7 -2
- package/runtime/validators/validate-run.py +5 -4
package/docs/architecture.md
CHANGED
|
@@ -517,7 +517,7 @@ The PR base follows the stage's `depends-on`, after dropping predecessors alread
|
|
|
517
517
|
| one | that stage's branch — a stacked PR | no |
|
|
518
518
|
| several | a branch merging their recorded `done.head_commit`s, named `<work-category-namespace>/<task-id-segment>-g<n1>-<n2>` | yes, by `pr-plan` |
|
|
519
519
|
|
|
520
|
-
A merge-base branch combines independent predecessors and is a base only, never a PR head. When a predecessor advances, `okstra_ctl.stage_rebase` rebases downstream stage branches in dependency order instead of merging the moving base into them. Implementation entry, verification target acquisition, and PR planning share this operation. Old heads are preserved under `refs/okstra/rebase-backups/`; registry bases and completion commits are updated, invalidating previous verification. PR planning stops after a stack change for fresh final-verification. Conflicts abort the current rebase, and dirty or active stages stop synchronization. A published branch that diverges from origin needs explicit delivery instructions; merging old origin history back into it is not recovery. Merge PRs in dependency order with a merge commit or rebase-merge; squashing replaces the commits the next PR's base points at.
|
|
520
|
+
This derivation covers stacking inside one task. The release base must contain the task's implementation anchor base: a task built on another task's unmerged stage branch takes that branch as its release base. Prepare names it as the recommended Q2 option in `release-handoff-input.md`, and `pr-plan` refuses a release base that lacks the anchor, because the PR would also carry the other task's commits. A merge-base branch combines independent predecessors and is a base only, never a PR head. When a predecessor advances, `okstra_ctl.stage_rebase` rebases downstream stage branches in dependency order instead of merging the moving base into them. Implementation entry, verification target acquisition, and PR planning share this operation. Old heads are preserved under `refs/okstra/rebase-backups/`; registry bases and completion commits are updated, invalidating previous verification. PR planning stops after a stack change for fresh final-verification. Conflicts abort the current rebase, and dirty or active stages stop synchronization. A published branch that diverges from origin needs explicit delivery instructions; merging old origin history back into it is not recovery. Merge PRs in dependency order with a merge commit or rebase-merge; squashing replaces the commits the next PR's base points at.
|
|
521
521
|
|
|
522
522
|
Entry is based on **task ID**, without a brief. Briefs are inputs to entry phases; for release-handoff, prepare determines eligibility from the approved plan's Stage Map + Stage Lifecycle Snapshot, then automatically creates an input document (`<task_root>/release-handoff-input.md`) that cites the verification reports. Stage selection comes from the okstra-run wizard's `handoff_stage_pick` multiselect or CLI `--stages <csv>`; an empty selection takes every eligible stage, and the result is exposed to the lead as `HANDOFF_STAGES` in run-context.
|
|
523
523
|
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -117,6 +117,7 @@ def build_analysis_packet(
|
|
|
117
117
|
has_selected_direction: bool = False,
|
|
118
118
|
mcp_servers_text: str = "",
|
|
119
119
|
technical_evidence_text: str = "",
|
|
120
|
+
verification_target_path: str = "",
|
|
120
121
|
) -> str:
|
|
121
122
|
"""Return the primary compact input for Claude/Codex/Antigravity analysers.
|
|
122
123
|
|
|
@@ -139,6 +140,9 @@ def build_analysis_packet(
|
|
|
139
140
|
|
|
140
141
|
`technical_evidence_text` 는 carry-in 이 technical-verification 결과일 때
|
|
141
142
|
`technical_evidence_packet_block` 이 렌더한 증거 위치 절이다.
|
|
143
|
+
|
|
144
|
+
`verification_target_path` 는 final-verification prepare 가 run 별로 기록한
|
|
145
|
+
검증 대상 스냅샷 경로(매니페스트 `verificationTargetPath` 와 같은 값)다.
|
|
142
146
|
"""
|
|
143
147
|
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
144
148
|
group_context_text = _read_optional(group_context_path) if group_context_path else ""
|
|
@@ -152,7 +156,7 @@ def build_analysis_packet(
|
|
|
152
156
|
parts.extend(
|
|
153
157
|
_intro_block(
|
|
154
158
|
task_key,
|
|
155
|
-
|
|
159
|
+
verification_target_path,
|
|
156
160
|
instruction_set_relative_path,
|
|
157
161
|
bool(clarification_response_path),
|
|
158
162
|
bool(group_context_text),
|
|
@@ -354,7 +358,7 @@ def _packet_frontmatter(brief_text: str, task_key: str) -> str:
|
|
|
354
358
|
|
|
355
359
|
def _intro_block(
|
|
356
360
|
task_key: str,
|
|
357
|
-
|
|
361
|
+
verification_target_path: str,
|
|
358
362
|
instruction_set: str,
|
|
359
363
|
has_clarification: bool,
|
|
360
364
|
has_group_context: bool = False,
|
|
@@ -376,10 +380,8 @@ def _intro_block(
|
|
|
376
380
|
f"- Analysis material: `{instruction_set}/analysis-material.md`",
|
|
377
381
|
f"- Reference expectations: `{instruction_set}/reference-expectations.md`",
|
|
378
382
|
]
|
|
379
|
-
if
|
|
380
|
-
lines.append(
|
|
381
|
-
f"- Verification target: `{instruction_set}/verification-target.md`"
|
|
382
|
-
)
|
|
383
|
+
if verification_target_path:
|
|
384
|
+
lines.append(f"- Verification target: `{verification_target_path}`")
|
|
383
385
|
if has_clarification:
|
|
384
386
|
lines.append(
|
|
385
387
|
f"- Clarification response: `{instruction_set}/clarification-response.md`"
|
|
@@ -12,7 +12,10 @@ from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
|
12
12
|
from okstra_ctl.stage_map import parse_stage_map_for_prepare
|
|
13
13
|
from okstra_ctl.phases.catalog import migrated_report_template
|
|
14
14
|
from okstra_ctl.handoff_error import HandoffError
|
|
15
|
-
from .operations import
|
|
15
|
+
from .operations import (
|
|
16
|
+
DEFAULT_RELEASE_BASES, _parse_stages_csv, _require_eligible,
|
|
17
|
+
anchor_base_branches, compute_eligibility,
|
|
18
|
+
)
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def _collect_handoff_source_report_rows(
|
|
@@ -74,6 +77,43 @@ def _select_handoff_stages(stage_map: list, rows: list, selection: str) -> list[
|
|
|
74
77
|
return nums
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
def _render_anchor_base_candidates(project_root: Path, inp) -> str:
|
|
81
|
+
"""구현 기준 커밋을 담은 기본 후보가 없으면, 그 커밋에 있는 origin 브랜치를
|
|
82
|
+
Q2 의 첫 후보로 올린다. 다른 task 의 미병합 stage 위에 쌓인 task 는 기본
|
|
83
|
+
후보 어느 것을 base 로 잡아도 그 task 의 커밋까지 PR 에 실린다."""
|
|
84
|
+
from okstra_ctl import worktree_registry
|
|
85
|
+
from okstra_ctl.work_categories import resolve_work_category
|
|
86
|
+
from okstra_ctl.worktree import is_ancestor
|
|
87
|
+
|
|
88
|
+
anchor = worktree_registry.get_implementation_base(
|
|
89
|
+
inp.project_id, inp.task_group, inp.task_id) or ""
|
|
90
|
+
if not anchor:
|
|
91
|
+
return ""
|
|
92
|
+
if any(is_ancestor(project_root, anchor, f"refs/remotes/origin/{name}")
|
|
93
|
+
for name in DEFAULT_RELEASE_BASES):
|
|
94
|
+
return ""
|
|
95
|
+
work_category = resolve_work_category(
|
|
96
|
+
inp.work_category, project_root=project_root,
|
|
97
|
+
task_group=inp.task_group, task_id=inp.task_id)
|
|
98
|
+
branches = anchor_base_branches(project_root, anchor,
|
|
99
|
+
work_category=work_category, task_id=inp.task_id)
|
|
100
|
+
held = branches["at"] + branches["containing"]
|
|
101
|
+
defaults = ", ".join(f"`{name}`" for name in DEFAULT_RELEASE_BASES)
|
|
102
|
+
lines = [f"- Implementation anchor base: `{anchor}` — no default candidate "
|
|
103
|
+
f"({defaults}) contains it, so a PR on any of them would also carry "
|
|
104
|
+
"the commits of the work this task was built on."]
|
|
105
|
+
if held:
|
|
106
|
+
lines.append(f"- Recommended release base (offer first in Q2): `{held[0]}`")
|
|
107
|
+
else:
|
|
108
|
+
lines.append("- Recommended release base: none — no origin branch holds the "
|
|
109
|
+
"anchor; push the branch it came from before release-handoff.")
|
|
110
|
+
lines.append("- origin branches at the anchor: "
|
|
111
|
+
+ (", ".join(f"`{n}`" for n in branches["at"]) or "none"))
|
|
112
|
+
lines.append("- origin branches containing the anchor: "
|
|
113
|
+
+ (", ".join(f"`{n}`" for n in branches["containing"]) or "none"))
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
77
117
|
def materialize_handoff_input(
|
|
78
118
|
workspace_root: Path, project_root: Path, inp, *, install_hint: str,
|
|
79
119
|
) -> dict:
|
|
@@ -133,6 +173,7 @@ def materialize_handoff_input(
|
|
|
133
173
|
"HANDOFF_STAGES": stages_csv,
|
|
134
174
|
"HANDOFF_SOURCE_REPORTS": source_rows,
|
|
135
175
|
"HANDOFF_CONDITIONS": source_conditions,
|
|
176
|
+
"HANDOFF_ANCHOR_BASE_CANDIDATES": _render_anchor_base_candidates(project_root, inp),
|
|
136
177
|
})
|
|
137
178
|
inp.brief_path = out
|
|
138
179
|
return {"HANDOFF_STAGES": stages_csv}
|
|
@@ -123,6 +123,51 @@ def _merge_base_for(*, project_root, project_id, task_group, task_id,
|
|
|
123
123
|
raise HandoffError(str(exc)) from exc
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
# 입력 문서 `Candidate Release Base Branches` 의 기본 후보와 같은 목록이다.
|
|
127
|
+
DEFAULT_RELEASE_BASES = ("staging", "preprod", "prod", "main", "dev")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _origin_branches(project_root, flag: str, commit: str, own_prefix: str) -> List[str]:
|
|
131
|
+
"""`git branch -r <flag> <commit>` 중 origin 브랜치 이름. 이 task 자신의 stage
|
|
132
|
+
브랜치는 릴리스 base 가 될 수 없으므로 뺀다."""
|
|
133
|
+
res = _git(Path(project_root), "branch", "-r", "--format=%(refname)", flag, commit)
|
|
134
|
+
if res.returncode != 0:
|
|
135
|
+
return []
|
|
136
|
+
prefix = "refs/remotes/origin/"
|
|
137
|
+
names = [line.strip()[len(prefix):] for line in res.stdout.splitlines()
|
|
138
|
+
if line.strip().startswith(prefix)]
|
|
139
|
+
return [n for n in names if n != "HEAD"
|
|
140
|
+
and n != own_prefix and not n.startswith(own_prefix + "-")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def anchor_base_branches(project_root, anchor: str, *, work_category: str,
|
|
144
|
+
task_id: str) -> Dict[str, List[str]]:
|
|
145
|
+
"""구현 기준 커밋을 tip 으로 가진 origin 브랜치와 그 커밋을 담은 브랜치."""
|
|
146
|
+
own = compute_branch_name(work_category=work_category, task_id_segment=task_id)
|
|
147
|
+
at = _origin_branches(project_root, "--points-at", anchor, own)
|
|
148
|
+
containing = [n for n in _origin_branches(project_root, "--contains", anchor, own)
|
|
149
|
+
if n not in at]
|
|
150
|
+
return {"at": at, "containing": containing}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _foreign_anchor_error(*, stage: int, base_branch: str, anchor: str,
|
|
154
|
+
branches: Dict[str, List[str]]) -> HandoffError:
|
|
155
|
+
held = branches["at"] + branches["containing"]
|
|
156
|
+
if held:
|
|
157
|
+
fix = f"re-run pr-plan with --base {held[0]}"
|
|
158
|
+
else:
|
|
159
|
+
fix = ("no origin branch holds that commit — push the branch it came "
|
|
160
|
+
f"from, or wait until it is merged into origin/{base_branch}")
|
|
161
|
+
return HandoffError(
|
|
162
|
+
f"stage {stage} would target origin/{base_branch}, but this task was "
|
|
163
|
+
f"implemented on {anchor}, which origin/{base_branch} does not contain "
|
|
164
|
+
"— the PR would also carry every commit between them, which belong to "
|
|
165
|
+
"other work. pr-plan derives stacking within this task only; a task "
|
|
166
|
+
"built on another task's unmerged branch needs that branch as its "
|
|
167
|
+
f"release base. origin branches at {anchor}: {branches['at'] or 'none'}; "
|
|
168
|
+
f"containing it: {branches['containing'] or 'none'}. {fix}")
|
|
169
|
+
|
|
170
|
+
|
|
126
171
|
def _resolve_stage_base(
|
|
127
172
|
*, stage: int, deps: List[int], selected: List[int], pr_covered: set,
|
|
128
173
|
done: Dict[int, Dict[str, Any]], project_root, base_branch: str,
|
|
@@ -135,6 +180,13 @@ def _resolve_stage_base(
|
|
|
135
180
|
if not is_ancestor(project_root, _done_head(done, d),
|
|
136
181
|
f"origin/{base_branch}")]
|
|
137
182
|
if not live:
|
|
183
|
+
if anchor_base and not is_ancestor(project_root, anchor_base,
|
|
184
|
+
f"origin/{base_branch}"):
|
|
185
|
+
raise _foreign_anchor_error(
|
|
186
|
+
stage=stage, base_branch=base_branch, anchor=anchor_base,
|
|
187
|
+
branches=anchor_base_branches(
|
|
188
|
+
project_root, anchor_base, work_category=work_category,
|
|
189
|
+
task_id=task_id))
|
|
138
190
|
commit = _run_git(["rev-parse", f"origin/{base_branch}"],
|
|
139
191
|
project_root).stdout.strip()
|
|
140
192
|
return {"base_kind": "release-base", "base_branch": base_branch,
|
|
@@ -31,13 +31,14 @@
|
|
|
31
31
|
`okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --stage <N>`
|
|
32
32
|
Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, a run still holds the stage, the target branch already deleted, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra worktree for that stage no longer exists — if the lead's cwd was inside it, EVERY subsequent step (final-report authoring included) MUST use absolute paths or run from the MAIN worktree. Then route to the final-report self-review pass.
|
|
33
33
|
2. **Release base branch** (only when the user picked `push + PR`) — present these options and capture exactly one:
|
|
34
|
+
- the `Recommended release base` from the input document's `## Candidate Release Base Branches`, when that section names one — first, marked recommended
|
|
34
35
|
- `preprod`
|
|
35
36
|
- `main`
|
|
36
37
|
- `direct input` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
|
|
37
|
-
This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage targets that stage's branch. The chosen base MUST NOT be one of
|
|
38
|
+
This is the release base for the run, not necessarily each PR's base: a stage that sits on another stage of this task targets that stage's branch. The release base MUST contain the task's implementation anchor base. `pr-plan` derives stacking inside this task only; when the task was built on another task's unmerged stage branch, the release base is that predecessor task's stage branch, which prepare names as the recommended base. The chosen base MUST NOT be one of this task's stage branches. If it is, re-ask.
|
|
38
39
|
2p. **PR plan** (only when the user picked `push + PR`) — run
|
|
39
40
|
`okstra handoff pr-plan --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <HANDOFF_STAGES> --base <chosen-base>`.
|
|
40
|
-
It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
|
|
41
|
+
It returns one row per stage with `head_branch`, `head_commit`, `base_kind` (`release-base` | `stage` | `merge-base`), `base_branch` and `base_commit`. Exit 2 means the predecessors of a multi-dependency stage conflict with each other: show the `conflicts` paths and stop (route: resolve manually, then retry). Exit 1 means an eligibility or dependency violation — most often a stage whose predecessor is neither in this run nor already PR'd nor merged into the release base: show the error verbatim and re-ask the action selection. When the error says the release base does not contain the implementation anchor, the error names the `--base` that does: show it verbatim and re-ask Q2 with that branch first. **Enforced:** `okstra_ctl.phases.release_handoff.operations._resolve_stage_base` refuses the plan. On success these rows are authoritative for every subsequent step; the lead does not recompute a base by hand.
|
|
41
42
|
A `merge-base` row means `pr-plan` created a branch merging that stage's predecessors. That branch is a PR base only — it is never a PR head — and it MUST be pushed before the PR that targets it.
|
|
42
43
|
The output also carries `push_plan` and `warnings`. `push_plan` is every branch the PRs sit on, already in push order (each `merge-base` first, then the stage heads in dependency order), with the `commit` it must reach and an `action` of `create` (origin does not have it), `fast-forward` (origin is behind) or `up-to-date` (origin already has that commit — do not push it). GitHub resolves a PR's head and base by name on origin, so this list, not the local branch set, is what makes the stack real. A `warnings` row means `pr-plan` fast-forwarded a local branch to origin because origin was ahead: the commits origin adds are outside what this run verified. Quote every `warnings` row verbatim in Executed Commands and say so in the branch/PR explanation — the user is deciding whether to release commits this run did not verify. `pr-plan` refuses outright when a branch moved outside okstra or diverged from origin, since only a force push could deliver it.
|
|
43
44
|
2b. **Pre-merge conflict probe** (only when the user picked `push + PR`) — before pushing, for each planned stage in dependency order:
|
|
@@ -48,7 +48,9 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
48
48
|
|
|
49
49
|
## Candidate Release Base Branches
|
|
50
50
|
|
|
51
|
-
The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them.
|
|
51
|
+
The user picks ONE release base for the whole run. Each stage's own PR base is then derived from its `depends-on` by `okstra handoff pr-plan`: a stage with no live dependency targets the release base, a stage with one targets that stage's branch (a stacked PR), and a stage with several targets a branch merging them. That derivation covers stacking inside this task only. The release base must contain the task's implementation anchor base; when this task was built on another task's unmerged stage branch, the release base is that branch, and `pr-plan` refuses a base that lacks the anchor.
|
|
52
|
+
|
|
53
|
+
{{HANDOFF_ANCHOR_BASE_CANDIDATES}}
|
|
52
54
|
|
|
53
55
|
- Default options offered to the user: `staging` | `preprod` | `prod` | `main` | `dev` | custom input
|
|
54
56
|
- Repo-specific preference, if known (e.g. `main` is the integration branch):
|
|
@@ -141,7 +141,7 @@ stateDiagram-v2
|
|
|
141
141
|
User interaction is exactly three steps.
|
|
142
142
|
|
|
143
143
|
1. Q1 action: `local checkout`, `push + PR`, `skip`
|
|
144
|
-
2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly
|
|
144
|
+
2. Q2 release base: a branch from the profile menu such as `staging`, `preprod`, `main`, or Enter directly. When no default candidate contains the task's implementation anchor base (the task was built on another task's unmerged stage branch), prepare names the origin branch at the anchor as the recommended first option, and `pr-plan` refuses a release base that lacks the anchor
|
|
145
145
|
3. Q3 PR title/body: every stage's draft in one question — `use as-is`, `edit then proceed`, `cancel`
|
|
146
146
|
|
|
147
147
|
The merge-conflict probe happens only for `push + PR`, once per stage against that stage's own base.
|
|
@@ -3063,6 +3063,7 @@ def _write_instruction_set_sources(
|
|
|
3063
3063
|
)
|
|
3064
3064
|
if inp.clarification_response_path else ""
|
|
3065
3065
|
),
|
|
3066
|
+
verification_target_path=ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
3066
3067
|
)
|
|
3067
3068
|
if inp.task_type == "technical-verification":
|
|
3068
3069
|
packet += materialize_technical_input_packet(
|
|
@@ -11,7 +11,12 @@ from dataclasses import dataclass
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from .. import worktree_registry
|
|
14
|
-
from .git_ops import
|
|
14
|
+
from .git_ops import (
|
|
15
|
+
_branch_checkout_path,
|
|
16
|
+
_branch_exists,
|
|
17
|
+
_is_inside_non_main_worktree,
|
|
18
|
+
is_git_work_tree,
|
|
19
|
+
)
|
|
15
20
|
from .naming import _safe_segment, compute_branch_name, compute_worktree_path
|
|
16
21
|
|
|
17
22
|
|
|
@@ -24,11 +29,19 @@ class WorktreeDecision:
|
|
|
24
29
|
- "reused": registry already has this task-key; existing path/branch returned
|
|
25
30
|
- "skipped-in-worktree": project_root is itself a non-main worktree
|
|
26
31
|
- "skipped-not-git": project_root has no .git
|
|
32
|
+
|
|
33
|
+
recovery (only when the active registry row no longer matches git):
|
|
34
|
+
- "restore-from-branch": status "reused"; the recorded branch survives but
|
|
35
|
+
is not checked out at the recorded path, so provision re-adds it there
|
|
36
|
+
- "replace-stale-row": status "new"; the recorded branch is gone, so
|
|
37
|
+
provision releases the row and creates a fresh worktree from the
|
|
38
|
+
requested base, or the recorded base when none is requested
|
|
27
39
|
"""
|
|
28
40
|
status: str
|
|
29
41
|
path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
|
|
30
42
|
branch: str = "" # new: prospective branch; reused: existing branch
|
|
31
43
|
base_ref: str = "" # new: requested base_ref; reused: existing base
|
|
44
|
+
recovery: str = ""
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
def preview_worktree_decision(
|
|
@@ -54,11 +67,21 @@ def preview_worktree_decision(
|
|
|
54
67
|
safe_group = _safe_segment(task_group_segment)
|
|
55
68
|
safe_task = _safe_segment(task_id_segment)
|
|
56
69
|
existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
|
|
70
|
+
recovery = ""
|
|
57
71
|
if existing is not None and existing.status == "active":
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
if _is_checked_out_at(project_root, existing.branch, existing.worktree_path):
|
|
73
|
+
return WorktreeDecision(
|
|
74
|
+
status="reused", path=existing.worktree_path,
|
|
75
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
76
|
+
)
|
|
77
|
+
if _branch_exists(project_root, existing.branch):
|
|
78
|
+
return WorktreeDecision(
|
|
79
|
+
status="reused", path=existing.worktree_path,
|
|
80
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
81
|
+
recovery="restore-from-branch",
|
|
82
|
+
)
|
|
83
|
+
recovery = "replace-stale-row"
|
|
84
|
+
base_ref = base_ref or existing.base_ref
|
|
62
85
|
return WorktreeDecision(
|
|
63
86
|
status="new",
|
|
64
87
|
path=str(compute_worktree_path(
|
|
@@ -66,9 +89,23 @@ def preview_worktree_decision(
|
|
|
66
89
|
task_id_segment=safe_task)),
|
|
67
90
|
branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
|
|
68
91
|
base_ref=base_ref,
|
|
92
|
+
recovery=recovery,
|
|
69
93
|
)
|
|
70
94
|
|
|
71
95
|
|
|
96
|
+
def _is_checked_out_at(project_root: Path, branch: str, worktree_path: str) -> bool:
|
|
97
|
+
"""Whether git itself has `branch` checked out at `worktree_path`.
|
|
98
|
+
|
|
99
|
+
An `active` registry row only records what okstra reserved; the directory
|
|
100
|
+
can be removed by hand and the branch deleted after a merge without the
|
|
101
|
+
registry hearing about it.
|
|
102
|
+
"""
|
|
103
|
+
checkout = _branch_checkout_path(project_root, branch)
|
|
104
|
+
if not checkout or not Path(worktree_path).is_dir():
|
|
105
|
+
return False
|
|
106
|
+
return Path(checkout).resolve() == Path(worktree_path).resolve()
|
|
107
|
+
|
|
108
|
+
|
|
72
109
|
@dataclass
|
|
73
110
|
class StageWorktreeDecision:
|
|
74
111
|
"""Side-effect-free decision for one concrete implementation stage."""
|
|
@@ -141,6 +141,10 @@ def provision_task_worktree(
|
|
|
141
141
|
safe_task = _safe_segment(task_id_segment)
|
|
142
142
|
|
|
143
143
|
if decision.status == "reused":
|
|
144
|
+
restored = ""
|
|
145
|
+
if decision.recovery == "restore-from-branch":
|
|
146
|
+
_restore_task_worktree(project_root, Path(decision.path), decision.branch)
|
|
147
|
+
restored = "; restored from its branch (directory was not a worktree)"
|
|
144
148
|
worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
|
|
145
149
|
_seed_worktree_settings_symlink(Path(decision.path))
|
|
146
150
|
drift = _sync_key_drift_note(
|
|
@@ -155,7 +159,7 @@ def provision_task_worktree(
|
|
|
155
159
|
note=(
|
|
156
160
|
f"task worktree reused at {decision.path} on branch "
|
|
157
161
|
f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
|
|
158
|
-
f"{drift}"
|
|
162
|
+
f"{restored}{drift}"
|
|
159
163
|
),
|
|
160
164
|
)
|
|
161
165
|
|
|
@@ -163,6 +167,8 @@ def provision_task_worktree(
|
|
|
163
167
|
worktree_path = Path(decision.path)
|
|
164
168
|
branch = decision.branch
|
|
165
169
|
|
|
170
|
+
if decision.recovery == "replace-stale-row":
|
|
171
|
+
_remove_settings_seed_shell(worktree_path)
|
|
166
172
|
if worktree_path.exists():
|
|
167
173
|
raise RuntimeError(
|
|
168
174
|
f"task worktree path already exists but is not in the registry: "
|
|
@@ -173,7 +179,7 @@ def provision_task_worktree(
|
|
|
173
179
|
raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
|
|
174
180
|
|
|
175
181
|
main_root = main_worktree_path(project_root)
|
|
176
|
-
requested_base = (base_ref or "").strip()
|
|
182
|
+
requested_base = (decision.base_ref or "").strip()
|
|
177
183
|
if not requested_base and require_base_ref:
|
|
178
184
|
raise RuntimeError(
|
|
179
185
|
"first-phase task worktree requires an explicit base ref; "
|
|
@@ -221,8 +227,15 @@ def provision_task_worktree(
|
|
|
221
227
|
linked_parts.append(f"linked-files {', '.join(linked_files)}")
|
|
222
228
|
if snapshot_files:
|
|
223
229
|
linked_parts.append(f"snapshot {', '.join(snapshot_files)}")
|
|
230
|
+
if decision.recovery == "replace-stale-row":
|
|
231
|
+
linked_parts.append(
|
|
232
|
+
"replaced a stale registry row whose recorded branch no longer exists")
|
|
224
233
|
linked_suffix = ("; " + "; ".join(linked_parts)) if linked_parts else ""
|
|
225
234
|
|
|
235
|
+
if decision.recovery == "replace-stale-row":
|
|
236
|
+
# The recorded branch is already deleted, so freeing its slot keeps the
|
|
237
|
+
# registry's "a free branch name is physically deleted" invariant.
|
|
238
|
+
worktree_registry.release(safe_project, safe_group, safe_task)
|
|
226
239
|
try:
|
|
227
240
|
worktree_registry.reserve(
|
|
228
241
|
project_id=safe_project,
|
|
@@ -260,6 +273,40 @@ def provision_task_worktree(
|
|
|
260
273
|
)
|
|
261
274
|
|
|
262
275
|
|
|
276
|
+
def _remove_settings_seed_shell(path: Path) -> None:
|
|
277
|
+
"""Remove a directory that holds nothing but okstra's settings seed.
|
|
278
|
+
|
|
279
|
+
Earlier prepares re-seeded `.claude/settings.local.json` into the recorded
|
|
280
|
+
path of a stale registry row, which left exactly this shell behind and made
|
|
281
|
+
every later prepare refuse the path. Anything else in the directory is
|
|
282
|
+
left alone.
|
|
283
|
+
"""
|
|
284
|
+
seed = path / ".claude" / "settings.local.json"
|
|
285
|
+
if not path.is_dir() or not (seed.is_symlink() or seed.is_file()):
|
|
286
|
+
return
|
|
287
|
+
if [p.name for p in path.iterdir()] != [".claude"]:
|
|
288
|
+
return
|
|
289
|
+
if [p.name for p in (path / ".claude").iterdir()] != ["settings.local.json"]:
|
|
290
|
+
return
|
|
291
|
+
seed.unlink()
|
|
292
|
+
(path / ".claude").rmdir()
|
|
293
|
+
path.rmdir()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _restore_task_worktree(project_root: Path, path: Path, branch: str) -> None:
|
|
297
|
+
_remove_settings_seed_shell(path)
|
|
298
|
+
if path.exists():
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
f"task worktree path {path} exists but is not a worktree of branch "
|
|
301
|
+
f"{branch}; move it aside so okstra can re-add the worktree from "
|
|
302
|
+
"that branch."
|
|
303
|
+
)
|
|
304
|
+
# A directory deleted by hand stays registered in git until pruned, and
|
|
305
|
+
# `git worktree add` refuses a missing-but-registered path.
|
|
306
|
+
_git(main_worktree_path(project_root), "worktree", "prune")
|
|
307
|
+
restore_stage_worktree(project_root, str(path), branch)
|
|
308
|
+
|
|
309
|
+
|
|
263
310
|
def restore_stage_worktree(project_root: Path, path: str, branch: str) -> None:
|
|
264
311
|
target = Path(path)
|
|
265
312
|
if target.exists():
|
|
@@ -481,11 +481,12 @@
|
|
|
481
481
|
},
|
|
482
482
|
"criticGaps": {
|
|
483
483
|
"type": "object",
|
|
484
|
-
"description": "Coverage-critic gap accounting. `proposed - merged - unverified` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
484
|
+
"description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
485
485
|
"required": [
|
|
486
486
|
"proposed",
|
|
487
487
|
"merged",
|
|
488
|
-
"unverified"
|
|
488
|
+
"unverified",
|
|
489
|
+
"duplicate"
|
|
489
490
|
],
|
|
490
491
|
"additionalProperties": false,
|
|
491
492
|
"properties": {
|
|
@@ -500,6 +501,10 @@
|
|
|
500
501
|
"unverified": {
|
|
501
502
|
"type": "integer",
|
|
502
503
|
"minimum": 0
|
|
504
|
+
},
|
|
505
|
+
"duplicate": {
|
|
506
|
+
"type": "integer",
|
|
507
|
+
"minimum": 0
|
|
503
508
|
}
|
|
504
509
|
}
|
|
505
510
|
},
|
|
@@ -518,11 +518,12 @@
|
|
|
518
518
|
},
|
|
519
519
|
"criticGaps": {
|
|
520
520
|
"type": "object",
|
|
521
|
-
"description": "Coverage-critic gap accounting. `proposed - merged - unverified` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
521
|
+
"description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
522
522
|
"required": [
|
|
523
523
|
"proposed",
|
|
524
524
|
"merged",
|
|
525
|
-
"unverified"
|
|
525
|
+
"unverified",
|
|
526
|
+
"duplicate"
|
|
526
527
|
],
|
|
527
528
|
"additionalProperties": false,
|
|
528
529
|
"properties": {
|
|
@@ -537,6 +538,10 @@
|
|
|
537
538
|
"unverified": {
|
|
538
539
|
"type": "integer",
|
|
539
540
|
"minimum": 0
|
|
541
|
+
},
|
|
542
|
+
"duplicate": {
|
|
543
|
+
"type": "integer",
|
|
544
|
+
"minimum": 0
|
|
540
545
|
}
|
|
541
546
|
}
|
|
542
547
|
},
|
|
@@ -4233,12 +4233,13 @@ def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -
|
|
|
4233
4233
|
|
|
4234
4234
|
proposed = critic.get("proposed")
|
|
4235
4235
|
merged = critic.get("merged")
|
|
4236
|
-
|
|
4237
|
-
|
|
4236
|
+
duplicate = critic.get("duplicate")
|
|
4237
|
+
if isinstance(proposed, int) and isinstance(merged, int) and isinstance(duplicate, int):
|
|
4238
|
+
if merged + unverified + duplicate > proposed:
|
|
4238
4239
|
failures.append(
|
|
4239
4240
|
"final-report data.json: crossVerification.criticGaps has "
|
|
4240
|
-
f"merged({merged}) + unverified({unverified})
|
|
4241
|
-
"— the accounting does not describe a real set of gaps."
|
|
4241
|
+
f"merged({merged}) + unverified({unverified}) + duplicate({duplicate}) "
|
|
4242
|
+
f"> proposed({proposed}) — the accounting does not describe a real set of gaps."
|
|
4242
4243
|
)
|
|
4243
4244
|
|
|
4244
4245
|
|