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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.222.0",
3
+ "version": "0.223.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.222.0",
3
- "builtAt": "2026-10-21T15:15:40.772Z",
2
+ "package": "0.223.0",
3
+ "builtAt": "2026-10-22T04:17:27.258Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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
- task_type,
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
- task_type: str,
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 task_type == "final-verification":
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 _require_eligible, compute_eligibility, _parse_stages_csv
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 the stage branches. If it is, re-ask.
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.
@@ -325,6 +325,7 @@ def project_convergence(
325
325
  "proposed": critic.get("gapsProposed", 0),
326
326
  "merged": critic.get("gapsMerged", 0),
327
327
  "unverified": critic.get("gapsUnverified", 0),
328
+ "duplicate": critic.get("gapsDuplicate", 0),
328
329
  }
329
330
  return result
330
331
 
@@ -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 _is_inside_non_main_worktree, is_git_work_tree
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
- return WorktreeDecision(
59
- status="reused", path=existing.worktree_path,
60
- branch=existing.branch, base_ref=existing.base_ref,
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
- if isinstance(proposed, int) and isinstance(merged, int):
4237
- if merged + unverified > proposed:
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}) > proposed({proposed}) "
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