okstra 0.132.0 → 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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/report-writer.md +4 -2
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +8 -5
- package/runtime/python/okstra_ctl/handoff.py +72 -32
- package/runtime/python/okstra_ctl/model_discovery.py +16 -9
- package/runtime/python/okstra_ctl/models.py +6 -5
- package/runtime/python/okstra_ctl/render_final_report.py +73 -26
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
- package/runtime/python/okstra_token_usage/report.py +44 -14
- package/runtime/schemas/final-report-v1.0.schema.json +1 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
- package/runtime/templates/reports/final-report.template.md +3 -0
- package/src/commands/execute/handoff.mjs +1 -1
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
162
|
-
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 {
|
|
294
|
-
f"
|
|
295
|
-
return {"ok": True, "branch":
|
|
296
|
-
"removedWorktree":
|
|
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,
|
|
74
|
+
*, provider: str, execution: str, role: str,
|
|
75
75
|
) -> str:
|
|
76
|
-
"""Return the CLI-accepted spelling of `execution
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 {
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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"),
|
|
@@ -325,46 +325,93 @@ _ABBREV = {
|
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
|
|
328
|
-
def
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 ""
|
|
332
363
|
# A number, a bare-digit, or an ellipsis dot is not a sentence end.
|
|
333
364
|
if not prev or prev.isspace() or prev.isdigit() or prev == ".":
|
|
334
|
-
|
|
335
|
-
tail = re.search(r"[A-Za-z.]+$",
|
|
365
|
+
continue
|
|
366
|
+
tail = re.search(r"[A-Za-z.]+$", text[: mark + 1])
|
|
336
367
|
if tail and tail.group(0).rstrip(".").lower() in _ABBREV:
|
|
337
|
-
|
|
338
|
-
|
|
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
|
+
|
|
339
375
|
|
|
340
|
-
|
|
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)
|
|
341
387
|
|
|
342
388
|
|
|
343
|
-
def
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return "
|
|
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>")
|
|
348
394
|
|
|
349
395
|
|
|
350
396
|
def _ventilate_prose(markdown: str) -> str:
|
|
351
|
-
"""Break prose
|
|
352
|
-
|
|
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."""
|
|
353
402
|
lines = markdown.split("\n")
|
|
354
403
|
mask = _code_line_mask(lines)
|
|
355
404
|
for i, line in enumerate(lines):
|
|
356
|
-
if mask[i]: # frontmatter / fenced code
|
|
357
|
-
continue
|
|
358
|
-
if not line or line != line.lstrip(): # blank or indented continuation
|
|
405
|
+
if mask[i] or not line: # frontmatter / fenced code / blank
|
|
359
406
|
continue
|
|
360
|
-
if (
|
|
361
|
-
_HEADING_RE.match(line)
|
|
362
|
-
or line.startswith("|")
|
|
363
|
-
or line.startswith(">")
|
|
364
|
-
or _LIST_LINE_RE.match(line)
|
|
365
|
-
):
|
|
407
|
+
if _HEADING_RE.match(line) or line.startswith(">"):
|
|
366
408
|
continue
|
|
367
|
-
|
|
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")
|
|
368
415
|
return "\n".join(lines)
|
|
369
416
|
|
|
370
417
|
|
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
156
|
-
if
|
|
157
|
-
return ("warn",
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
(
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
`;
|