okstra 0.131.1 → 0.133.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.131.1",
3
+ "version": "0.133.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.131.1",
3
- "builtAt": "2026-07-22T05:33:21.666Z",
2
+ "package": "0.133.0",
3
+ "builtAt": "2026-07-22T19:29:52.565Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -158,8 +158,8 @@ The template renders this table in the merged record-meta shape (same pattern as
158
158
  ```
159
159
 
160
160
  Table Generation Rules:
161
- - The first row derives its agent identifier and role from `team-state.lead.agent` / `team-state.lead.role` and its usage data from `leadUsage`; subsequent rows follow `recommendedWorkers` / `resultContract.requiredWorkerRoles` order.
162
- - Every worker agent identifier and role derives from its persisted team-state entry; the common contract does not infer labels from provider names.
161
+ - **Exactly one row per team-state entry: the lead, then one per `team-state.workers[]` — never more, never fewer.** The first row's agent / role / usage come from `team-state.lead.agent` / `team-state.lead.role` / `leadUsage`. Each later row copies its `agent`, `role`, and `model` **verbatim from a `team-state.workers[]` entry** (e.g. role `Claude worker`, `Codex worker`, `Report writer worker`), ordered to follow `recommendedWorkers` / `resultContract.requiredWorkerRoles`. Do not infer labels from provider names.
162
+ - **Never split one worker into multiple function rows or invent role labels** (`Analysis verifier`, `Acceptance critic`, …). A provider that played several roles — e.g. codex ran both the analysis-verify pass and the acceptance-critic pass — is still **one** `team-state.workers[]` entry with **one** usage figure: name the roles it played in that row's *Summary of Key Findings* cell, not in extra rows. Extra or renamed rows break Phase-7 token attribution, which keys each row's tokens to a single team-state worker — a split or renamed row lands as `--`.
163
163
  - **Raw tokens** = `usage.totalTokens` (input + output + cache_creation + cache_read; the raw volume processed).
164
164
  - **Billable tokens** = `usage.billableEquivalentTokens` (cache reads weighted at 0.1×, cache_creation 1.25×, output 5×; useful as a single number for "how big was this session in cost terms").
165
165
  - **Cost (USD)** = `usage.estimatedCostUsd`. For Codex/Antigravity workers that actually invoked the CLI, append `(+ CLI $X.XX)` from `usage.cliEstimatedCostUsd`.
@@ -314,6 +314,8 @@ Every field MUST anchor its claim with at least one evidence reference — a `pa
314
314
  ### Writing Guidelines
315
315
 
316
316
  - Write in Markdown. **Prefer tables over prose bullet lists** for any section that enumerates multiple items with the same shape (evidence rows, risks, options, dependencies, rollback steps, follow-ups, open questions). Bullets are reserved for short, single-line standalone statements (e.g., "- No additional information requested."). When the template provides a table form, do NOT degrade it back to bullets in the rendered report. **Exception — `## Background and Rationale` (`rationale`) is deliberately prose**: it is connected narrative explaining the *why*, not a same-shape enumeration, so write full sentences there rather than forcing it into a table.
317
+ - **Do not restate the same conclusion verbatim across sections.** The Verdict Card and Reader Summary are *digests*: give the outcome in one or two sentences and point to `## 7. Final Verdict` / `## 5.8.8 Routing Recommendation` for the full reasoning, rather than copying their multi-clause conclusion word-for-word. Only the `Verdict Token` / `Direction` / `Next Step` cells must byte-match §7 (per the Verdict Card contract above); the prose conclusion must not be a duplicate.
318
+ - **Keep each sentence to one main idea.** A single sentence that stacks four or five clauses with em-dashes and nested parentheticals (300+ characters) is hard to read, and the renderer can only line-break at sentence ends — so break such reasoning into separate sentences. Facts, evidence, and IDs still live in the tables; prose carries only the connective *why*.
317
319
  - Write the final report body in the language passed in **Report Language**
318
320
  above (`en` or `ko`). The template's fixed labels (section asides,
319
321
  empty-states, token summary, column headers, release-handoff labels)
@@ -27,7 +27,7 @@
27
27
  - Pre-verification entry gate (resolved & enforced by `okstra render-bundle` prep — the lead does NOT recompute it):
28
28
  - the verification target (scope / worktree / base / stages / source reports / diff stat) is injected as the `VERIFICATION_TARGET` block. The lead MUST treat it as authoritative and MUST NOT re-pick a target from the brief.
29
29
  - **whole-task scope** (`--stage auto`, default): prep has already verified every Stage Map stage is `status:done` in `consumers.jsonl`, every done stage's `head_commit` is an ancestor of the task worktree HEAD (all stage branches merged), and the worktree is clean outside `.okstra/`. If any check failed the run never started (PrepareError); a started whole-task run is therefore a fully-merged, clean target.
30
- - **whole-task is a mutating phase, not a read-only one.** On entry, whole-task mode auto-merges (with `--no-ff`) the done stages not yet merged into the task branch to create an integration commit, then removes the cleanupable stage worktrees, registry stage-keys, and stage branches. If a merge conflict occurs it reports the conflicting files and aborts (the user resolves them manually and retries). A stage worktree with uncommitted changes remaining is preserved. Therefore the "fully-merged, clean target" the entry gate above refers to is the state after this auto-integration step completes, and whole-task final-verification must be treated as a mutating phase that creates the integration commit.
30
+ - **whole-task is a mutating phase, not a read-only one.** On entry, whole-task mode auto-merges (with `--no-ff`) the done stages not yet merged into the task branch to create an integration commit, then removes the cleanupable stage worktrees and marks their registry stage-keys released. The stage branches are kept as the reviewable stack (the target of `okstra handoff local-checkout --stage <N>`). If a merge conflict occurs it reports the conflicting files and aborts (the user resolves them manually and retries). A stage worktree with uncommitted changes remaining is preserved. Therefore the "fully-merged, clean target" the entry gate above refers to is the state after this auto-integration step completes, and whole-task final-verification must be treated as a mutating phase that creates the integration commit.
31
31
  - **single-stage scope** (`--stage N`): prep verified stage N is `status:done` and its isolated stage worktree exists and is clean. Other stages' state is irrelevant. A single-stage run is a partial verification: it MUST NOT recommend plain `release-handoff`, but MAY recommend `release-handoff(stage-group)` when the verdict is `accepted` — the stage becomes PR-eligible for a stage-group handoff.
32
32
  - the lead still captures `git status --short` from the injected worktree to confirm the analysis ran against the delivered work-tree state; an unexpected divergence (dirty tree outside `.okstra/`, missing worktree) is a `tool-failure`, not a silent proceed.
33
33
  - Worker verification procedure:
@@ -29,7 +29,7 @@
29
29
  - Base ref: `{{EXECUTOR_WORKTREE_BASE_REF}}` — canonical `<base>` for every `git diff` / `git log` in this run. Independent stages start from the common anchor (the task-key worktree HEAD, fixed once at first stage entry); dependent stages start from the predecessor done commit or the verified merged task worktree head.
30
30
  - Provisioning note: `{{EXECUTOR_WORKTREE_NOTE}}`
31
31
  - Treat the working-tree path as `project_root` for the duration of this run. Do NOT mutate the caller's original checkout. cwd-sensitive Bash commands MUST be prefixed `cd {{EXECUTOR_WORKTREE_PATH}} && ` in the same Bash invocation (never `bash -lc "..."` wrappers — see executor sidecar for full rules).
32
- - Lifecycle: kept after the run completes as this stage's evidence worktree. Later stages get their own stage worktrees. Manual cleanup: `git worktree remove <path>` → `git branch -D <branch>` → drop the stage-key registry entry (`<task-key>#stage-<N>`). Exception: whole-task `final-verification` auto-merges every done stage and tears down its worktree/branch/registry key (`stage_integrate.integrate_stages`, `teardown=True`) — do not promise the user the worktree survives past that point.
32
+ - Lifecycle: kept after the run completes as this stage's evidence worktree. Later stages get their own stage worktrees. Manual cleanup: `git worktree remove <path>` → `git branch -D <branch>` → drop the stage-key registry entry (`<task-key>#stage-<N>`). Exception: whole-task `final-verification` auto-merges every done stage and removes its worktree directory (`stage_integrate.integrate_stages`, `teardown=True`) — do not promise the user the worktree survives past that point. The stage BRANCH does survive: it is kept so the stack stays reviewable and `okstra handoff local-checkout --stage <N>` still has a target.
33
33
  - Approval gate (phase-specific addendum to shared authority rule):
34
34
  - the pre-implementation gate's recorded user approval marker is the only authorised approval gate at this phase — proceed once it is satisfied without further external coordination.
35
35
  - Forbidden actions — universal (any occurrence → terminal status `contract-violated`):
@@ -20,13 +20,13 @@
20
20
  - the lead MUST confirm `git log --oneline <base>..HEAD` contains at least one implementation commit. If it is empty, the run MUST end with status `blocked` and route back to `implementation`.
21
21
  - User interaction protocol (Claude lead — performed in order, using `AskUserQuestion` or the equivalent interactive prompt):
22
22
  1. **Action selection** — present three choices and capture exactly one:
23
- - `local checkout` (whole-task mode only) — bring the verified task branch into the MAIN worktree for local testing (no push, no PR). See step 1c. When `HANDOFF_MODE` is `stage-group`, do NOT offer this option tell the user "`local checkout` is whole-task mode only" and re-ask.
23
+ - `local checkout` — bring a verified branch into the MAIN worktree for local testing (no push, no PR). Two targets: the whole-task branch (whole-task mode only), or a single stage's stack branch (`--stage N`, available in both modes since stage branches survive verification). See step 1c. When `HANDOFF_MODE` is `stage-group`, offer only the per-stage target.
24
24
  - `push + PR` — push the feature branch, then open or reuse a pull request.
25
25
  - `skip` — cancel delivery actions for now: record that release-handoff was intentionally skipped and end the run without any git command.
26
26
  If the user picks `skip`, route directly to the final-report self-review pass.
27
- 1c. **Local checkout execution** (only when the user picked `local checkout`) — confirm with the user that okstra will remove the okstra task worktree and `git checkout <feature branch>` in the MAIN worktree, and that the base branch is never modified. On confirmation run:
28
- `okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t>`
29
- Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra task worktree no longer exists — the lead's cwd is gone, so 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.
27
+ 1c. **Local checkout execution** (only when the user picked `local checkout`) — first ask which target — when `HANDOFF_MODE` is `whole-task`, offer both the whole-task branch and one stage's stack branch; when it is `stage-group`, offer only the per-stage target — listing the selectable stage numbers (from the approved plan's Stage Map, since the run input document carries no Stage Map). Then confirm with the user that okstra will remove the corresponding okstra worktree (when it still exists) and `git checkout <branch>` in the MAIN worktree, and that the base branch is never modified. On confirmation run:
28
+ `okstra handoff local-checkout --project-root <project root> --project-id <id> --task-group <g> --task-id <t>` — append `--stage <N>` for the per-stage target.
29
+ Exit 1 means a precondition failed (MAIN worktree dirty, registry entry missing, or checkout failed): show the error verbatim and re-ask the action selection. On success the okstra worktree for that target 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.
30
30
  - **stage-group mode step order** (overrides the default Q1→Q3 sequence): after Q1 picks `push + PR`, run (1) base-branch selection first — identical to Q2 below, because the dependency-closure check needs `origin/<base>`; (2) G2 stage confirmation (step 1g); (3) assemble (step 2g); then Q2b and Q3 as usual, with the collector branch as the PR head.
31
31
  1g. **G2 — stage confirmation**: the stage selection already happened before the run (wizard `handoff_stage_pick`, or the CLI `--stages` flag) and is fixed in `HANDOFF_STAGES`. Display it as a one-line confirmation (`PR target stage: <csv> — proceeding`) and proceed; do NOT re-ask the multi-select. Only if `HANDOFF_MODE` is `stage-group` but `HANDOFF_STAGES` is empty (defensive, should not happen) run `okstra handoff eligible --plan-run-root <plan-run-root> --approved-plan <approved plan path>` and ask the user to pick from the eligible stages.
32
32
  2g. **assemble**: run `okstra handoff assemble --plan-run-root <...> --approved-plan <...> --project-root <project root> --project-id <id> --task-group <g> --task-id <t> --work-category <c> --stages <csv> --base <chosen-base>`. Exit 2 means a stage-vs-stage merge conflict: show the `conflicts` paths and stop (route: reshape the group or resolve manually). Exit 1 means an eligibility/closure violation: show the error verbatim and re-ask G2. On success the returned `branch` is the PR head branch for every subsequent step.
@@ -69,6 +69,7 @@
69
69
  - **Feature Branch & Working-Tree State**: branch name from `git rev-parse --abbrev-ref HEAD`, output of `git status --short` at run start.
70
70
  - **User Selections**: a block recording each prompt and the user's verbatim answer.
71
71
  - Q1 action: `local checkout` | `push + PR` | `skip`.
72
+ - Q1c checkout target (only when Q1 is `local checkout`): `whole-task branch` | `stage <N> stack branch`, plus the `--stage <N>` value this answer produced (or `no --stage` for the whole-task branch). Record it in the `User Selections` row `H1c` (`userSelections.h1c`); omit the row entirely when Q1 was not `local checkout`.
72
73
  - Q2 PR base (if applicable): the chosen branch and how it was selected (menu pick vs free-form input).
73
74
  - Q2b merge-conflict probe (if applicable): `not-run` | `clean` (no conflict, no prompt shown) | `proceed anyway` | `change base branch` | `cancel`. Record it in both the `User Selections` row `H2b` and the `Merge Conflict Probe` deliverable. When a conflict was detected, list the conflicting paths.
74
75
  - Q3 title/body: `use as-is` | `edit then proceed` (with a diff between the lead's draft and the final text) | `cancel`.
@@ -85,7 +86,9 @@
85
86
  - `- PR creation skipped: <reason>` for any user-driven cancellation
86
87
  - **Local Checkout Outcome**: one of
87
88
  - `- Not run (user picked push + PR or skip).`
88
- - `- Checked out <branch> into <main worktree path>; okstra task worktree <path> removed. Next: to run a local test and then open a PR, re-enter release-handoff via okstra-run → push + PR.`
89
+ - `- Checked out <branch> into <main worktree path>; okstra worktree <path> removed. Next: to run a local test and then open a PR, re-enter release-handoff via okstra-run → push + PR.`
90
+ - `- Checked out <branch> into <main worktree path>; no okstra worktree removed (already torn down — the branch survived). Next: to run a local test and then open a PR, re-enter release-handoff via okstra-run → push + PR.`
91
+ - Pick between the two `Checked out` variants by the command's `removedWorktree` field: a non-empty path takes the first, an empty string takes the second. Never write a removal that did not happen.
89
92
  - **Stage Group** (stage-group mode only): selected stages, each stage's single-stage verification report path + quoted `Verdict Token` row, collector branch name, merge commit SHAs from assemble, and the dependency-closure verdict (from the assemble output / error).
90
93
  - **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.
91
94
  - Self-review pass before finalising the report (`Claude lead` runs this):
@@ -244,11 +244,12 @@ def assemble(*, project_root, plan_run_root, stage_map, stages, base_branch,
244
244
 
245
245
 
246
246
  def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
247
- """clean 으로 확인된 task-key 워크트리를 제거. 이미 없으면 no-op.
248
- 실패하면 사람이 읽을 detail 문자열, 성공/부재면 None.
249
- nested stage 워크트리가 남아있으면 제거하지 않는다 --force 로 지우면 물리
250
- 디렉터리만 사라지고 git/registry stage-key admin 엔트리가 고아로 남는다.
251
- 통합/teardown 끝나 nested 없을 때만 제거한다."""
247
+ """Remove an okstra worktree — task-key or stage already confirmed clean
248
+ by the caller. No-op when it is already gone. Returns a human-readable
249
+ detail string on failure, None on success or absence.
250
+ Refuses while a nested worktree remains inside the target — a --force
251
+ removal would drop only the physical directory and leave git's and the
252
+ registry's stage-key admin entries orphaned."""
252
253
  if not Path(target).exists():
253
254
  return None # 이미 teardown 됨 — checkout 으로 진행
254
255
  nested = nested_worktree_excludes(target)
@@ -260,40 +261,78 @@ def _remove_task_worktree(main_wt, target: str) -> Optional[str]:
260
261
  return None
261
262
 
262
263
 
263
- def local_checkout(*, project_root, project_id, task_group, task_id) -> Dict[str, Any]:
264
- """whole-task: okstra task-key 워크트리를 제거하고 task 브랜치를 메인
265
- 워크트리에 checkout 한다(브랜치 보존). 메인·대상 워크트리가 dirty 거부.
266
- base 브랜치는 건드리지 않는다."""
267
- entry = worktree_registry.lookup(project_id, task_group, task_id)
268
- if not entry or not entry.branch or not entry.worktree_path:
264
+ def _local_checkout_target(project_id, task_group, task_id,
265
+ stage: Optional[int]) -> Tuple[str, str]:
266
+ """Resolve (branch, worktree_path) for a local checkout. `stage` selects
267
+ the stage-key row; None selects the task-key row. The returned path is ""
268
+ only when the row carries no `worktree_path`; teardown does not clear it,
269
+ so a torn-down stage yields a stale path to a directory that no longer
270
+ exists. The caller must probe the path before acting on it.
271
+
272
+ An `active` row is refused: `status == "active"` is the occupancy SSOT a
273
+ concurrent run holds (`worktree_registry.list_active_stage_numbers` feeds
274
+ the stage resolver), and local checkout would remove that run's worktree
275
+ and release its reservation, letting a third `--stage auto` run claim the
276
+ same stage."""
277
+ if stage is None:
278
+ entry = worktree_registry.lookup(project_id, task_group, task_id)
279
+ if not entry or not entry.branch or not entry.worktree_path:
280
+ raise HandoffError(
281
+ "task-key worktree registry 엔트리가 없습니다 — local checkout 대상 없음")
282
+ if entry.status == "active":
283
+ raise HandoffError(
284
+ "task 워크트리를 실행 중인 런이 점유하고 있습니다 (status=active) — "
285
+ "그 런이 끝나 점유가 해제된 뒤 다시 시도하세요")
286
+ return entry.branch, entry.worktree_path
287
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage)
288
+ if not row or not row.get("branch"):
269
289
  raise HandoffError(
270
- "task-key worktree registry 엔트리가 없습니다 — local checkout 대상 없음")
290
+ f"stage {stage} 의 worktree registry 엔트리가 없습니다 — "
291
+ "local checkout 대상 없음")
292
+ if row.get("status") == "active":
293
+ raise HandoffError(
294
+ f"stage {stage} 를 실행 중인 런이 점유하고 있습니다 (status=active) — "
295
+ "그 런이 끝나 점유가 해제된 뒤 다시 시도하세요")
296
+ return row["branch"], row.get("worktree_path") or ""
297
+
298
+
299
+ def local_checkout(*, project_root, project_id, task_group, task_id,
300
+ stage: Optional[int] = None) -> Dict[str, Any]:
301
+ """okstra 워크트리를 제거하고 대상 브랜치를 메인 워크트리에 checkout 한다
302
+ (브랜치 보존). stage 를 주면 그 stage 의 스택 브랜치가, 주지 않으면 whole-task
303
+ 브랜치가 대상이다. 메인·대상 워크트리가 dirty 면 거부. base 브랜치는
304
+ 건드리지 않는다."""
305
+ branch, wt_path = _local_checkout_target(
306
+ project_id, task_group, task_id, stage)
271
307
  main_wt = main_worktree_path(Path(project_root))
272
308
  if is_dirty_excluding_okstra(main_wt):
273
309
  raise HandoffError(
274
310
  f"메인 워크트리에 미커밋 변경이 있습니다 ({main_wt}) — "
275
311
  "커밋/스태시 후 다시 시도하세요")
276
- # 제거 게이트는 파괴 대상인 task-key 워크트리를 검사해야 한다 (메인 아님).
277
- if (Path(entry.worktree_path).exists()
278
- and is_dirty_excluding_okstra(entry.worktree_path)):
279
- raise HandoffError(
280
- f"task 워크트리에 미커밋 변경이 있습니다 ({entry.worktree_path}) — "
281
- "해당 워크트리에서 커밋/스태시 후 다시 시도하세요")
282
- detail = _remove_task_worktree(main_wt, entry.worktree_path)
283
- if detail is not None:
284
- raise HandoffError(
285
- f"task 워크트리 제거 실패 ({entry.worktree_path}): {detail} — "
286
- "nested stage 워크트리가 남아있거나 통합이 미완료일 수 있습니다. "
287
- "먼저 stage 통합/teardown 하거나 수동으로 "
288
- f"`git worktree remove {entry.worktree_path}` 다시 시도하세요")
289
- worktree_registry.release_status(project_id, task_group, task_id)
290
- co = _git(Path(main_wt), "checkout", entry.branch)
312
+ removed = ""
313
+ # The removal gate must inspect the okstra worktree being destroyed, not main.
314
+ # A torn-down stage leaves a stale path in its row, so probe the directory.
315
+ if wt_path and Path(wt_path).exists():
316
+ if is_dirty_excluding_okstra(wt_path):
317
+ raise HandoffError(
318
+ f"okstra 워크트리에 미커밋 변경이 있습니다 ({wt_path}) — "
319
+ "해당 워크트리에서 커밋/스태시 다시 시도하세요")
320
+ detail = _remove_task_worktree(main_wt, wt_path)
321
+ if detail is not None:
322
+ raise HandoffError(
323
+ f"워크트리 제거 실패 ({wt_path}): {detail} "
324
+ " 사유를 해소한 다시 시도하세요")
325
+ removed = wt_path
326
+ worktree_registry.release_status(project_id, task_group, task_id,
327
+ stage_number=stage)
328
+ co = _git(Path(main_wt), "checkout", branch)
291
329
  if co.returncode != 0:
330
+ removal = f"워크트리는 제거됨({removed})" if removed else "제거된 워크트리 없음"
292
331
  raise HandoffError(
293
- f"git checkout {entry.branch} 실패: {co.stderr.strip()} — 워크트리는 "
294
- f"제거됨; 메인({main_wt})에서 수동 checkout 으로 복구하세요")
295
- return {"ok": True, "branch": entry.branch, "mainWorktreePath": str(main_wt),
296
- "removedWorktree": entry.worktree_path, "status": "checked-out"}
332
+ f"git checkout {branch} 실패: {co.stderr.strip()} — {removal}; "
333
+ f"메인({main_wt})에서 수동 checkout 으로 복구하세요")
334
+ return {"ok": True, "branch": branch, "mainWorktreePath": str(main_wt),
335
+ "removedWorktree": removed, "status": "checked-out", "stage": stage}
297
336
 
298
337
 
299
338
  def _impl_task_key_for(rows: List[Dict[str, Any]], stage: int) -> str:
@@ -413,6 +452,7 @@ def main(argv: Optional[list] = None) -> int:
413
452
  sp.add_argument("--project-id", required=True)
414
453
  sp.add_argument("--task-group", required=True)
415
454
  sp.add_argument("--task-id", required=True)
455
+ sp.add_argument("--stage", type=int, default=None)
416
456
 
417
457
  a = p.parse_args(argv)
418
458
  try:
@@ -436,7 +476,7 @@ def main(argv: Optional[list] = None) -> int:
436
476
  out = local_checkout(
437
477
  project_root=Path(a.project_root).resolve(),
438
478
  project_id=a.project_id, task_group=a.task_group,
439
- task_id=a.task_id)
479
+ task_id=a.task_id, stage=a.stage)
440
480
  else:
441
481
  out = record_pr(plan_run_root=Path(a.plan_run_root),
442
482
  stages=_parse_stages_csv(a.stages),
@@ -71,11 +71,12 @@ def codex_availability(config_path: Path | None = None) -> tuple[str, ...]:
71
71
 
72
72
 
73
73
  def normalize_execution_for_dispatch(
74
- *, provider: str, execution: str, display: str, role: str,
74
+ *, provider: str, execution: str, role: str,
75
75
  ) -> str:
76
- """Return the CLI-accepted spelling of `execution`, or the value unchanged
77
- when discovery is unavailable. Raise ModelUnavailableError when the identity
78
- is definitely not offered (never substitute a different model)."""
76
+ """Return the CLI-accepted spelling of `execution`. When discovery is
77
+ unavailable, fall back to the best-known dispatchable spelling rather than
78
+ blocking. Raise ModelUnavailableError when the identity is definitely not
79
+ offered (never substitute a different model)."""
79
80
  if provider == "codex":
80
81
  available = codex_availability()
81
82
  if not available or execution in available:
@@ -86,13 +87,19 @@ def normalize_execution_for_dispatch(
86
87
  )
87
88
  if provider != "antigravity":
88
89
  return execution # claude runs via the Agent tool
90
+ # agy bakes the tier into the model identity as a slug suffix
91
+ # (`gemini-3.1-pro-high`) and rejects the bare slug with "requires --effort"
92
+ # — a flag the wrapper never sends. So the suffixed form is the only
93
+ # dispatchable spelling, including on the un-verified fallback below.
94
+ tiered = f"{execution}-{role_effort(role).lower()}"
89
95
  available = agy_models()
90
96
  if not available:
91
- return execution # discovery unavailable → status quo, wrapper still guards
92
- want = f"{display} ({role_effort(role)})"
93
- if want in available:
94
- return want
97
+ return tiered # discovery unavailable → dispatch anyway, wrapper still guards
98
+ if execution in available:
99
+ return execution # identity already carries its tier (agy's Claude models)
100
+ if tiered in available:
101
+ return tiered
95
102
  raise ModelUnavailableError(
96
- f"antigravity model {want!r} (from catalog {execution!r}, role {role!r}) "
103
+ f"antigravity model {tiered!r} (from catalog {execution!r}, role {role!r}) "
97
104
  f"is not offered by this agy install. Available: {', '.join(available)}"
98
105
  )
@@ -36,11 +36,12 @@ CLAUDE = {
36
36
  "claude-haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
37
37
  "claude-haiku-4-5-20251001": ModelSpec("haiku-4-5", "claude-haiku-4-5-20251001"),
38
38
  }
39
- # The `agy` CLI accepts DISPLAY names with an effort suffix (e.g.
40
- # "Gemini 3.1 Pro (High)"), not `gemini-3.x` slugs, and its list is
41
- # host/version-specific. model_discovery.normalize_execution_for_dispatch maps
42
- # each ModelSpec.display + role effort to the exact `agy models` identifier at
43
- # dispatch time. agy also serves Claude/GPT-OSS models on some installs.
39
+ # `agy` rejects a bare execution slug it demands a tier, spelled as a suffix
40
+ # on the slug itself ("gemini-3.1-pro-high"), and its list is host/version-
41
+ # specific. model_discovery.normalize_execution_for_dispatch appends the role's
42
+ # effort to ModelSpec.execution at dispatch time. `display` is okstra-facing
43
+ # only and never reaches agy. agy also serves Claude/GPT-OSS models on some
44
+ # installs; those carry their tier inline and take no suffix.
44
45
  ANTIGRAVITY = {
45
46
  "gemini-3.1-pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro", in_picker=True),
46
47
  "gemini 3.1 pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro"),
@@ -308,6 +308,113 @@ def _build_index(headings: list, definitions: list, labels: dict) -> list[str]:
308
308
  return block
309
309
 
310
310
 
311
+ # --- Prose ventilation post-render pass -----------------------------------
312
+ # Split prose paragraphs at sentence boundaries so raw markdown reads one
313
+ # sentence per line; the HTML view turns those newlines into <br>
314
+ # (report_views._markdown_to_html). Prose only — table cells already carry
315
+ # their own <br>, and headings / lists / code / blockquotes must stay intact.
316
+ # A leading-whitespace line is a list continuation, not a prose paragraph:
317
+ # only column-0 non-block lines are treated as prose (allowlist).
318
+ _LIST_LINE_RE = re.compile(r"^\s*(?:[-*]\s|\d+\.\s)")
319
+ _SENT_BOUNDARY_RE = re.compile(r"[.?!][ \t]+(?=\S)")
320
+ # Trailing tokens whose dot is not a sentence end (compared lowercased with
321
+ # the trailing dot stripped).
322
+ _ABBREV = {
323
+ "e.g", "i.e", "etc", "cf", "vs", "al", "no", "fig",
324
+ "dr", "mr", "ms", "inc", "ltd", "jr", "sr",
325
+ }
326
+
327
+
328
+ def _code_span_mask(text: str) -> list[bool]:
329
+ """Mark every char inside an inline-code span (and the backticks that
330
+ fence it) so sentence detection never fires on a `.` in a path or id."""
331
+ mask = [False] * len(text)
332
+ in_code = False
333
+ for i, ch in enumerate(text):
334
+ if ch == "`":
335
+ in_code = not in_code
336
+ mask[i] = True
337
+ else:
338
+ mask[i] = in_code
339
+ return mask
340
+
341
+
342
+ def _ventilate_text(text: str, *, sep: str) -> str:
343
+ """Insert ``sep`` after each sentence-ending ``.``/``?``/``!`` that is
344
+ followed by whitespace. Skips marks inside inline-code spans, numbers /
345
+ decimals, ellipses, and known abbreviations. ``sep`` is ``"\\n"`` for
346
+ prose lines (raw-markdown ventilation) and ``"<br>"`` for table cells /
347
+ list items, where a bare newline would break the row / list structure.
348
+
349
+ Masking code spans (rather than splitting on backticks and ventilating
350
+ each segment) is what lets a boundary sitting right before an inline-code
351
+ span split — the segment approach missed it at the segment edge.
352
+ """
353
+ if not text:
354
+ return text
355
+ mask = _code_span_mask(text)
356
+ out: list[str] = []
357
+ last = 0
358
+ for match in _SENT_BOUNDARY_RE.finditer(text):
359
+ mark = match.start() # index of the punctuation
360
+ if mask[mark]:
361
+ continue
362
+ prev = text[mark - 1] if mark > 0 else ""
363
+ # A number, a bare-digit, or an ellipsis dot is not a sentence end.
364
+ if not prev or prev.isspace() or prev.isdigit() or prev == ".":
365
+ continue
366
+ tail = re.search(r"[A-Za-z.]+$", text[: mark + 1])
367
+ if tail and tail.group(0).rstrip(".").lower() in _ABBREV:
368
+ continue
369
+ out.append(text[last : mark + 1]) # up to and including the mark
370
+ out.append(sep)
371
+ last = match.end() # drop the inter-sentence whitespace
372
+ out.append(text[last:])
373
+ return "".join(out)
374
+
375
+
376
+ def _ventilate_table_row(line: str) -> str:
377
+ """Ventilate each cell of a pipe-table row with ``<br>``. A newline
378
+ would truncate the row, so table cells (which already carry ``<br>`` for
379
+ stacked meta) use ``<br>``. The header separator row (``|---|:--:|``) has
380
+ no prose and is left alone."""
381
+ if set(line.strip()) <= set("|-: "):
382
+ return line
383
+ cells = UNESCAPED_PIPE_RE.split(line)
384
+ for k in range(1, len(cells)): # skip cells[0] (indent / pre-border)
385
+ cells[k] = _ventilate_text(cells[k], sep="<br>")
386
+ return "|".join(cells)
387
+
388
+
389
+ def _ventilate_list_item(line: str) -> str:
390
+ """Ventilate a list item's body with ``<br>`` (a newline would end the
391
+ item), preserving the bullet / number marker and any indent."""
392
+ marker = _LIST_LINE_RE.match(line).group(0)
393
+ return marker + _ventilate_text(line[len(marker):], sep="<br>")
394
+
395
+
396
+ def _ventilate_prose(markdown: str) -> str:
397
+ """Break prose into one sentence per line. Column-0 paragraphs split on a
398
+ newline (clean raw markdown); table cells and list items split on ``<br>``
399
+ (a newline there would break the row / list). Headings, blockquotes, and
400
+ indented continuation lines are left intact. Idempotent: a single-sentence
401
+ span has no internal boundary to split."""
402
+ lines = markdown.split("\n")
403
+ mask = _code_line_mask(lines)
404
+ for i, line in enumerate(lines):
405
+ if mask[i] or not line: # frontmatter / fenced code / blank
406
+ continue
407
+ if _HEADING_RE.match(line) or line.startswith(">"):
408
+ continue
409
+ if line.lstrip().startswith("|"):
410
+ lines[i] = _ventilate_table_row(line)
411
+ elif _LIST_LINE_RE.match(line):
412
+ lines[i] = _ventilate_list_item(line)
413
+ elif line == line.lstrip(): # column-0 prose (not indented continuation)
414
+ lines[i] = _ventilate_text(line, sep="\n")
415
+ return "\n".join(lines)
416
+
417
+
311
418
  def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
312
419
  """Append scroll anchors + a top-of-report index to a rendered report.
313
420
  Idempotent: re-running on already-anchored markdown is a no-op for
@@ -500,7 +607,8 @@ def render(
500
607
  try:
501
608
  template = env.get_template(template_path.name)
502
609
  rendered = template.render(**data)
503
- return _inject_index_and_anchors(rendered, dictionary)
610
+ rendered = _inject_index_and_anchors(rendered, dictionary)
611
+ return _ventilate_prose(rendered)
504
612
  except I18nError as exc:
505
613
  raise FinalReportRenderError(
506
614
  f"i18n lookup failed while rendering {template_path.name}: {exc}"
@@ -399,7 +399,9 @@ def _markdown_to_html(
399
399
  para_lines.append(ln)
400
400
  i += 1
401
401
  if para_lines:
402
- out.append("<p>" + _inline(" ".join(para_lines)) + "</p>")
402
+ # The renderer's prose ventilation puts one sentence per source
403
+ # line; join with <br> so those breaks survive into the HTML.
404
+ out.append("<p>" + _inline("<br>".join(para_lines)) + "</p>")
403
405
 
404
406
  return "\n".join(out), headings
405
407
 
@@ -1421,7 +1421,7 @@ def _normalized_execution(meta: ModelAssignment, provider: str, role: str) -> st
1421
1421
  so the manifest carries the dispatch-correct spelling. SSOT for the mapping
1422
1422
  lives in model_discovery; this is only the run.py call seam."""
1423
1423
  return normalize_execution_for_dispatch(
1424
- provider=provider, execution=meta.execution, display=meta.display, role=role,
1424
+ provider=provider, execution=meta.execution, role=role,
1425
1425
  )
1426
1426
 
1427
1427
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  Phase A: stage done commit 을 task 브랜치에 위상순 --no-ff 머지(이미 머지면
4
4
  skip, 충돌이면 해당 머지만 abort 후 IntegrateError). Phase B: 머지/skip 된
5
- stage 의 worktree·registry 키·stage 브랜치를 정리(dirty 면 skip).
5
+ stage 의 worktree 디렉터리와 registry 키를 정리(dirty 면 skip). stage 브랜치는
6
+ 스택 보존을 위해 삭제하지 않는다.
6
7
  설계: docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md
7
8
  """
8
9
  from __future__ import annotations
@@ -64,7 +65,7 @@ def integrate_stages(
64
65
  ) -> IntegrateResult:
65
66
  """whole-task 통합: Phase A 머지 → Phase B teardown.
66
67
 
67
- teardown=False 면 Phase B(stage worktree·브랜치·registry 키 정리)를 건너뛴다 —
68
+ teardown=False 면 Phase B(stage worktree 디렉터리·registry 키 정리)를 건너뛴다 —
68
69
  container 는 머지만 하고 stage 트리를 보존한다(스펙 §10). final-verification
69
70
  호출은 기본값(True)으로 기존 행위를 유지한다."""
70
71
  from .consumers import latest_done_by_stage
@@ -137,11 +138,39 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
137
138
  return None
138
139
 
139
140
 
141
+ def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str) -> str:
142
+ """Warn text when the stage branch tip is not an ancestor of the task
143
+ worktree HEAD, or "" when it is (or cannot be resolved).
144
+
145
+ Phase A merges `done.head_commit`, never the branch ref, so a stage branch
146
+ can carry commits that whole-task verification never saw. `local-checkout
147
+ --stage <N>` hands that branch to the user as a verified branch, so the
148
+ divergence has to be reported."""
149
+ from .worktree import is_ancestor
150
+
151
+ tip = _git(task_wt, "rev-parse", "--verify", "--quiet", branch)
152
+ if tip.returncode != 0:
153
+ return ""
154
+ sha = tip.stdout.strip()
155
+ head = _git(task_wt, "rev-parse", "HEAD").stdout.strip()
156
+ if is_ancestor(task_wt, sha, head):
157
+ return ""
158
+ return (f"stage {stage_n} 브랜치 {branch} 의 tip {sha[:12]} 이 검증된 task "
159
+ f"HEAD 에 포함되지 않습니다 — 검증에 포함되지 않은 커밋이 있습니다")
160
+
161
+
140
162
  def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
141
- """clean stage worktree 제거 + registry release + branch -d.
142
- branch -d 성공한 뒤에만 branch 슬롯을 free 한다 물리 브랜치가
143
- 살아있는 채로 슬롯이 풀려 동시 run 이 같은 이름을 재예약하는 윈도우를
144
- 막기 위함. 반환 ('torn_down'|'skipped'|'warn', detail)."""
163
+ """Remove the clean stage worktree directory and mark the registry row
164
+ released. The stage branch is deliberately kept: the per-stage stack
165
+ branches must survive whole-task verification so that
166
+ `okstra handoff local-checkout --stage <N>` still has a target. The
167
+ branch's registry slot is not touched here — `consumers`'
168
+ `_release_stage_reservation` already freed it at stage-done time.
169
+
170
+ Warns when the surviving branch tip carries commits the verified merge
171
+ never included. Teardown itself is already complete at that point, so the
172
+ warning reports the divergence rather than undoing the cleanup. Returns
173
+ ('torn_down'|'skipped'|'warn', detail)."""
145
174
  from . import worktree_registry
146
175
 
147
176
  wt = (row or {}).get("worktree_path") or ""
@@ -152,12 +181,9 @@ def _teardown_stage(project_id, task_group, task_id, task_wt, stage_n, row):
152
181
  return outcome
153
182
  worktree_registry.release_status(project_id, task_group, task_id, stage_number=stage_n)
154
183
  if branch:
155
- bd = _git(task_wt, "branch", "-d", branch)
156
- if bd.returncode != 0:
157
- return ("warn", f"stage {stage_n} branch -d {branch} 실패(미머지?): "
158
- f"{bd.stderr.strip()}")
159
- worktree_registry.free_branch_slot(project_id, task_group, task_id,
160
- stage_number=stage_n)
184
+ warning = _unmerged_branch_tip_warning(task_wt, stage_n, branch)
185
+ if warning:
186
+ return ("warn", warning)
161
187
  return ("torn_down", "")
162
188
 
163
189
 
@@ -41,6 +41,7 @@ _NON_BODY_PREFIXES = (
41
41
  "**Worker Preamble Path:**",
42
42
  *ERRORS_PATH_HEADERS,
43
43
  "**Read scope:**",
44
+ "**File write mode:**",
44
45
  "**Worktree:**",
45
46
  "**Verification scope:**",
46
47
  "**Verification base ref:**",
@@ -63,6 +64,9 @@ _WORKER_SPECIFIC_PREFIXES = (
63
64
  "Assigned worker prompt history path:",
64
65
  "**Errors sidecar path:**",
65
66
  "**Worker Result Path:**",
67
+ # Only antigravity carries this (worker_prompt_headers.PLAIN_FILE_WRITE_HEADER)
68
+ # because only agy has an artifact store; unstripped it reads as divergence.
69
+ "**File write mode:**",
66
70
  "**Model:**",
67
71
  "**Pane role:**",
68
72
  )
@@ -37,6 +37,24 @@ READ_SCOPE_HEADER = (
37
37
  "or Assumptions* instead of reading it."
38
38
  )
39
39
 
40
+ # `agy`'s write tool validates the target against the Gemini artifact store
41
+ # whenever the model attaches ArtifactMetadata, and rejects every path outside
42
+ # `~/.gemini/antigravity-cli/brain/<uuid>/`. All okstra worker outputs live under
43
+ # `<PROJECT_ROOT>/.okstra/`, so an attached ArtifactMetadata fails the write —
44
+ # usually on the audit sidecar, after which the CLI exits 0 having persisted no
45
+ # result file. Only antigravity carries this: codex and claude have no artifact
46
+ # store, and a shared header would break the cross-worker prompt-body equality
47
+ # that `worker_prompt_contract` enforces.
48
+ PLAIN_FILE_WRITE_HEADER = (
49
+ "**File write mode:** Write every okstra output (result file, audit "
50
+ "sidecar, errors sidecar) as a plain project file at the absolute path this "
51
+ "prompt gives. Do NOT attach ArtifactMetadata and do NOT route these writes "
52
+ "through the Gemini artifact store — artifact paths are restricted to the "
53
+ "brain folder, so an artifact write under `<PROJECT_ROOT>/.okstra/` is "
54
+ "rejected and the run ends with no result file."
55
+ )
56
+ ANTIGRAVITY_WORKER_ID = "antigravity"
57
+
40
58
 
41
59
  def worker_prompt_headers(
42
60
  *,
@@ -81,6 +99,8 @@ def worker_prompt_headers(
81
99
  f"**Errors sidecar path:** {errors_sidecar_path}",
82
100
  READ_SCOPE_HEADER,
83
101
  ])
102
+ if worker_id == ANTIGRAVITY_WORKER_ID:
103
+ headers.append(PLAIN_FILE_WRITE_HEADER)
84
104
  if _string_value(manifest.get("taskType")) == "improvement-discovery":
85
105
  headers.append(
86
106
  f"{GRILLING_LOG_HEADER} "
@@ -275,10 +275,11 @@ def release_status(
275
275
  ) -> Optional[WorktreeEntry]:
276
276
  """Mark the entry as `released` WITHOUT freeing its branch index slot.
277
277
 
278
- Use when the physical branch still exists and its name must stay
279
- reserved e.g. teardown defers the slot-free until `git branch -d`
280
- actually succeeds, so the registry never advertises a free branch
281
- name that is still alive on disk. Returns the prior entry, or None.
278
+ Use when the caller must not touch the branch index either the slot is
279
+ still needed, or another caller owns its lifetime. Stage teardown is the
280
+ latter case: `consumers._release_stage_reservation` already ran `release()`
281
+ at stage-done time, so teardown only flips the status. Returns the prior
282
+ entry, or None.
282
283
  """
283
284
  key = task_key(project_id, task_group, task_id, stage_number, group_id)
284
285
  with _registry_lock():
@@ -323,11 +324,12 @@ def release(
323
324
  group_id: Optional[str] = None,
324
325
  ) -> Optional[WorktreeEntry]:
325
326
  """Mark the entry as `released` and free its branch slot (worktree dir
326
- intact — preservation is the project's policy). Used by callers that
327
- release at a point where the branch is gone or intentionally orphaned
328
- (done-time reservation release, group cleanup). Teardown of a physical
329
- branch must instead pair `release_status` + `free_branch_slot` so the
330
- slot is freed only after `git branch -d` succeeds.
327
+ intact — preservation is the project's policy). Used at the points that
328
+ end a reservation's life: done-time stage release
329
+ (`consumers._release_stage_reservation`) and group cleanup. Both free the
330
+ slot even when the physical branch survives stage stack branches are
331
+ deliberately kept, and their names are task-scoped, so a freed slot cannot
332
+ collide with a live branch from another task.
331
333
  Returns the prior entry, or None when not found.
332
334
  """
333
335
  entry = release_status(project_id, task_group, task_id, stage_number, group_id)
@@ -36,16 +36,45 @@ class SubstituteRefusedError(RuntimeError):
36
36
  """
37
37
 
38
38
 
39
- def _role_match(state_role: str, data_role: str) -> bool:
40
- """Loose match between team-state worker role and data.json
41
- executionStatus row role. team-state uses tags like ``claude-worker``
42
- or ``report-writer``; data.json uses human strings like ``Claude
43
- worker``. We normalise both to ``alnum-only-lowercase`` so the two
44
- line up across rephrasings.
39
+ def _norm(s: str) -> str:
40
+ """alnum-only lowercase, so role / agent / model line up across
41
+ rephrasings (``Claude worker`` vs ``claude``; ``gpt-5.6-sol`` vs
42
+ ``gpt56sol``)."""
43
+ return "".join(ch.lower() for ch in s if ch.isalnum())
44
+
45
+
46
+ def _match_worker_index(row: dict, workers: list, used: set[int]) -> int | None:
47
+ """Pick the team-state worker that owns this executionStatus row, never
48
+ reusing a worker an earlier row already claimed.
49
+
50
+ A contract-following report names each row after its team-state worker
51
+ (``Claude worker``), so role equality / containment matches. A report
52
+ that instead used function roles (``Analysis verifier``, ``Acceptance
53
+ critic``) falls back to a *unique* provider(agent)+model match. When two
54
+ rows share one provider aggregate (codex verifier + critic → one ``Codex
55
+ worker``), only the first is attributed; the second stays null because
56
+ team-state holds no separate figure for it.
45
57
  """
46
- def normalise(s: str) -> str:
47
- return "".join(ch.lower() for ch in s if ch.isalnum())
48
- return normalise(state_role) == normalise(data_role)
58
+ role = _norm(row.get("role", ""))
59
+ agent = _norm(row.get("agent", ""))
60
+ model = _norm(row.get("model", ""))
61
+ for i, worker in enumerate(workers):
62
+ if i in used:
63
+ continue
64
+ worker_role = _norm(worker.get("role", ""))
65
+ if worker_role and role and (
66
+ worker_role == role or worker_role in role or role in worker_role
67
+ ):
68
+ return i
69
+ candidates = [
70
+ i
71
+ for i, worker in enumerate(workers)
72
+ if i not in used
73
+ and _norm(worker.get("agent", ""))
74
+ and _norm(worker.get("agent", "")) in agent
75
+ and _norm(worker.get("model", "")) == model
76
+ ]
77
+ return candidates[0] if len(candidates) == 1 else None
49
78
 
50
79
 
51
80
  def _populate_execution_row(row: dict, source: dict) -> None:
@@ -179,17 +208,18 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
179
208
  lead_source = {"usage": team_state.get("leadUsage") or {}}
180
209
  workers = team_state.get("workers") or []
181
210
  rows = data.get("executionStatus") or []
211
+ used_workers: set[int] = set()
182
212
  for row in rows:
183
213
  role = row.get("role", "")
184
214
  if "lead" in role.lower():
185
215
  _populate_execution_row(row, lead_source)
186
216
  changes += 1
187
217
  continue
188
- for worker in workers:
189
- if _role_match(worker.get("role", ""), role):
190
- _populate_execution_row(row, worker)
191
- changes += 1
192
- break
218
+ idx = _match_worker_index(row, workers, used_workers)
219
+ if idx is not None:
220
+ used_workers.add(idx)
221
+ _populate_execution_row(row, workers[idx])
222
+ changes += 1
193
223
 
194
224
  data_path.write_text(
195
225
  json.dumps(data, indent=2, ensure_ascii=False) + "\n",
@@ -524,6 +524,7 @@
524
524
  "additionalProperties": false,
525
525
  "properties": {
526
526
  "h1": { "enum": ["local checkout", "push + PR", "skip"] },
527
+ "h1c": { "type": "string" },
527
528
  "h2": { "type": "string" },
528
529
  "h2b": {
529
530
  "enum": ["not-run", "clean", "proceed anyway", "change base branch", "cancel"]
@@ -37,6 +37,24 @@ Present a 3-option picker (`AskUserQuestion`):
37
37
 
38
38
  ## Mode A — Generate PR
39
39
 
40
+ ### A0. Branch naming (allowlist)
41
+
42
+ Whenever this skill creates or proposes a head branch name, the prefix comes
43
+ from this list and nothing else:
44
+
45
+ | work kind | prefix |
46
+ | --- | --- |
47
+ | feature, improvement | `feature/` |
48
+ | bug fix | `fix/` |
49
+ | refactor | `refactor/` |
50
+ | ops, chore | `ops/` |
51
+ | unclassified | `task/` |
52
+
53
+ `feat/`, `bugfix/`, `chore/`, `hotfix/` are not in the allowlist — do not emit
54
+ them. This mirrors okstra's worktree branch namespace, whose SSOT is
55
+ `_WORK_CATEGORY_NAMESPACE` in `scripts/okstra_ctl/worktree.py`. A branch name
56
+ the user states explicitly wins verbatim; never rewrite it.
57
+
40
58
  ### A1. Pick a template
41
59
 
42
60
  ```bash
@@ -483,6 +483,9 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
483
483
  | {{ t("releaseHandoff.questionsTableHeader.questionId") }} | {{ t("releaseHandoff.questionsTableHeader.questionBody") }} | {{ t("releaseHandoff.questionsTableHeader.userResponse") }} | {{ t("releaseHandoff.questionsTableHeader.allowedOptions") }} |
484
484
  |---------|-----------|--------------------|--------------------|
485
485
  | H1 | {{ t("releaseHandoff.h1Body") }} | `{{ releaseHandoff.userSelections.h1 | mdcell }}` | `local checkout` / `push + PR` / `skip` |
486
+ {% if releaseHandoff.userSelections.h1c %}
487
+ | H1c | Local checkout target | `{{ releaseHandoff.userSelections.h1c | mdcell }}` | `whole-task branch` / `stage <N> stack branch` |
488
+ {% endif %}
486
489
  | H2 | {{ t("releaseHandoff.h2Body") }} | `{{ (releaseHandoff.userSelections.h2 or t("releaseHandoff.h2DefaultLabel")) | mdcell }}` | {{ t("releaseHandoff.h2OptionsLabel") }} |
487
490
  | H2b | Merge conflict probe | `{{ (releaseHandoff.userSelections.h2b or releaseHandoff.mergeConflictProbe.kind) | mdcell }}` | `not-run` / `clean` / `proceed anyway` / `change base branch` / `cancel` |
488
491
  | H3 | {{ t("releaseHandoff.h3Body") }} | `{{ releaseHandoff.userSelections.h3 | mdcell }}` | `use as-is` / `edit then proceed` / `cancel` |
@@ -14,7 +14,7 @@ Usage:
14
14
  okstra handoff record-pr --plan-run-root <dir> --stages 2,3 \\
15
15
  --branch <b> --url <u>
16
16
  okstra handoff local-checkout --project-root <dir> --project-id <id> \\
17
- --task-group <g> --task-id <t>
17
+ --task-group <g> --task-id <t> [--stage <N>]
18
18
 
19
19
  Exit codes: 0 ok / 1 자격·전제 위반 / 2 stage 간 merge 충돌(conflicts 동봉)
20
20
  `;