okstra 0.132.0 → 0.134.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.
Files changed (32) hide show
  1. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/agents/workers/report-writer-worker.md +3 -1
  5. package/runtime/prompts/lead/report-writer.md +4 -2
  6. package/runtime/prompts/profiles/final-verification.md +1 -1
  7. package/runtime/prompts/profiles/implementation.md +1 -1
  8. package/runtime/prompts/profiles/release-handoff.md +8 -5
  9. package/runtime/python/okstra_ctl/clarification_items.py +34 -12
  10. package/runtime/python/okstra_ctl/handoff.py +72 -32
  11. package/runtime/python/okstra_ctl/model_discovery.py +16 -9
  12. package/runtime/python/okstra_ctl/models.py +6 -5
  13. package/runtime/python/okstra_ctl/render_final_report.py +73 -26
  14. package/runtime/python/okstra_ctl/run.py +19 -11
  15. package/runtime/python/okstra_ctl/session.py +36 -7
  16. package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
  17. package/runtime/python/okstra_ctl/user_response.py +14 -5
  18. package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
  19. package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
  20. package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
  21. package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
  22. package/runtime/python/okstra_ctl/worktree.py +39 -9
  23. package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
  24. package/runtime/python/okstra_token_usage/collect.py +57 -1
  25. package/runtime/python/okstra_token_usage/report.py +44 -14
  26. package/runtime/schemas/final-report-v1.0.schema.json +1 -0
  27. package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
  28. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  29. package/runtime/templates/reports/final-report.template.md +3 -0
  30. package/runtime/validators/validate-run.py +15 -6
  31. package/runtime/validators/validate_session_conformance.py +16 -6
  32. package/src/commands/execute/handoff.mjs +1 -1
@@ -34,7 +34,7 @@ okstra preflight --runtime claude-code --json
34
34
 
35
35
  ## Flow
36
36
 
37
- 1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`. If the array is empty, stop with "no open clarification". A 3-option picker (top recommendations + the final option always "Enter directly" for pasting a `reportPath`/`task-key` directly). `unreadable:true` is a §1 format drift — flag it with `⚠` and do not proceed (do not fabricate rows).
37
+ 1. **list**: `okstra user-response list --home <home> --project <projectId> --limit 3` → an array of `{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}` (`openBlockerCount` = open rows with `Blocks` in `{approval, next-phase}`; `openApprovalCount` = the `approval`-only subset). If the array is empty, stop with "no open clarification". A 3-option picker (top recommendations + the final option always "Enter directly" for pasting a `reportPath`/`task-key` directly). `unreadable:true` is a §1 format drift — flag it with `⚠` and do not proceed (do not fabricate rows).
38
38
  2. **show**: `okstra user-response show --report <reportPath>` → `rows[]` with `resolvedRefs` (the `definition` of internal tokens such as `RB-002`/`§4.7`). **Do not paste the raw `statement` as the question.** For each open row present: (a) a **self-contained question** with internal tokens expanded inline first, (b) `recommended` (only a recommendation) in plain language, (c) `alternatives[]`, (d) the raw `id`+`statement` as a secondary traceability aid. If `definition` is `null`, resolve the report `§`/`path:line` yourself **using Read**.
39
39
  3. **decision (4 branches)**: the user picks one per item — Answer (accept the recommendation / own answer, verbatim), Ask-to-explain (explain only and hand the decision back to the user), Free-form (narrative verbatim), Hold+reframe (`disposition:"reframe"`, does not satisfy the approval gate). Each item's JSON: `{id, kind, value, rationale?, disposition}`.
40
40
  4. **echo → confirmed gate**: before `write`, echo the whole collection (each `id`·`disposition`·`value`·`rationale`·approval) as-is and get explicit confirmation. Never `write` before `confirmed`. On any change, re-echo and re-confirm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.132.0",
3
+ "version": "0.134.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.132.0",
3
- "builtAt": "2026-07-22T06:44:24.538Z",
2
+ "package": "0.134.0",
3
+ "builtAt": "2026-07-23T05:31:24.504Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -41,7 +41,9 @@ Do NOT duplicate the data.json contents here — the data.json is the canonical
41
41
 
42
42
  ## Heartbeat (BLOCKING)
43
43
 
44
- Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
44
+ Write the audit sidecar at `**Audit sidecar path:**` before required reading, then append `- PROGRESS: <stage> <ISO-8601-UTC>` at a cadence no longer than five minutes. Use only: `started`, `required-reading-complete`, `synthesis-start`, `data-json-write-start`, `render-start`, and `write-result-start`. When a stage runs longer than that, append `- PROGRESS: in-stage:<stage> <ISO-8601-UTC>` with the same stage names. The Phase 7 validator enforces the first stage, timestamps, cadence, and this allowlist.
45
+
46
+ `data-json-write-start`, `render-start` and `write-result-start` open a stretch whose work is one uninterruptible tool call, so no `in-stage:` line can be appended while it runs. Those three carry a 20-minute budget instead of five; every other stage keeps the five-minute cadence.
45
47
 
46
48
  ## Execution Rules
47
49
 
@@ -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):
@@ -258,23 +258,44 @@ def parse_clarification_rows(report_text: str) -> list[dict]:
258
258
 
259
259
  UNRESOLVED_STATUSES = {"open", "answered"}
260
260
 
261
+ # `Blocks` values that gate the user's `approved: true` flip.
262
+ APPROVAL_BLOCKS = frozenset({"approval"})
263
+ # `Blocks` values that owe the user an answer at all. `next-phase` rows never
264
+ # gate approval, but the next phase cannot start with them open either, so the
265
+ # user-response skill must list them — counting only `approval` made a report
266
+ # whose open items were all `next-phase` look like it had none.
267
+ USER_INPUT_BLOCKS = frozenset({"approval", "next-phase"})
268
+
261
269
 
262
270
  @dataclass(frozen=True)
263
- class ApprovalGateScan:
264
- """Fail-closed read of the §1 approval gate.
271
+ class ClarificationScan:
272
+ """Fail-closed read of §1 for rows blocking on one `Blocks` value set.
265
273
 
266
274
  ``unreadable_reason`` is ``None`` only when the scan is confident: §1
267
275
  parsed cleanly (or is the legitimate table-less placeholder) and
268
276
  ``blockers`` is therefore authoritative. A non-None reason means the
269
- gate must refuse approval — never soft-pass.
277
+ caller must refuse to act — never soft-pass.
270
278
  """
271
279
  blockers: list[ClarificationItem]
272
280
  unreadable_reason: Optional[str]
273
281
 
274
282
 
275
- def scan_approval_gate(report_text: str) -> ApprovalGateScan:
283
+ def scan_approval_gate(report_text: str) -> ClarificationScan:
276
284
  """Scan §1 for unresolved ``Blocks=approval`` rows (``Status`` in
277
285
  ``{open, answered}``), refusing to guess whenever the schema drifted."""
286
+ return scan_clarification_blockers(report_text, APPROVAL_BLOCKS)
287
+
288
+
289
+ def scan_open_user_input(report_text: str) -> ClarificationScan:
290
+ """Scan §1 for every unresolved row that still owes the user an answer
291
+ (``Blocks`` in ``{approval, next-phase}``)."""
292
+ return scan_clarification_blockers(report_text, USER_INPUT_BLOCKS)
293
+
294
+
295
+ def scan_clarification_blockers(
296
+ report_text: str, blocking_values: frozenset[str]
297
+ ) -> ClarificationScan:
298
+ """Shared fail-closed §1 walk for both gates above."""
278
299
  section = _section_1_slice(report_text)
279
300
  if section is None:
280
301
  if _LOOSE_SECTION_1_RE.search(report_text):
@@ -283,32 +304,33 @@ def scan_approval_gate(report_text: str) -> ApprovalGateScan:
283
304
  "the schema heading format (anchor/format drift)"
284
305
  )
285
306
  else:
307
+ expected = "/".join(sorted(blocking_values))
286
308
  reason = (
287
309
  "report has no `## 1. Clarification Items` section — the gate "
288
- "cannot confirm there are no unresolved `Blocks=approval` rows"
310
+ f"cannot confirm there are no unresolved `Blocks={expected}` rows"
289
311
  )
290
- return ApprovalGateScan([], reason)
312
+ return ClarificationScan([], reason)
291
313
  table = _walk_section_1_table(section)
292
314
  if table.items is None:
293
315
  if table.has_pipe_lines:
294
- return ApprovalGateScan([], (
316
+ return ClarificationScan([], (
295
317
  "§1 contains a table but its header row is not the schema "
296
318
  "header (`| ... | Statement | Expected form | User input |`)"
297
319
  ))
298
320
  # Renderer's emptyState placeholder: heading is intact and no table
299
- # was emitted — confidently "no approval-blocking items".
300
- return ApprovalGateScan([], None)
321
+ # was emitted — confidently "no blocking items".
322
+ return ClarificationScan([], None)
301
323
  if table.unparsed_row_count:
302
- return ApprovalGateScan([], (
324
+ return ClarificationScan([], (
303
325
  f"§1 table has {table.unparsed_row_count} row(s) whose metadata "
304
326
  "cell could not be parsed (Blocks/Status markers missing or "
305
327
  "malformed)"
306
328
  ))
307
329
  blockers = [
308
330
  it for it in table.items
309
- if it.blocks == "approval" and it.status in UNRESOLVED_STATUSES
331
+ if it.blocks in blocking_values and it.status in UNRESOLVED_STATUSES
310
332
  ]
311
- return ApprovalGateScan(blockers, None)
333
+ return ClarificationScan(blockers, None)
312
334
 
313
335
 
314
336
  # 느슨한 §1 헤딩 탐지: 엄격한 SECTION_HEADING_PATTERN 이 실패해도 이게 매칭되면
@@ -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"),
@@ -325,46 +325,93 @@ _ABBREV = {
325
325
  }
326
326
 
327
327
 
328
- def _ventilate_segment(seg: str) -> str:
329
- def replace_boundary(match: re.Match) -> str:
330
- mark = match.start()
331
- prev = seg[mark - 1] if mark > 0 else ""
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
- return match.group(0)
335
- tail = re.search(r"[A-Za-z.]+$", seg[: mark + 1])
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
- return match.group(0)
338
- return seg[mark] + "\n"
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
- return _SENT_BOUNDARY_RE.sub(replace_boundary, seg)
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 _split_prose_line(line: str) -> str:
344
- parts = line.split("`")
345
- for even in range(0, len(parts), 2): # segments outside backtick code spans
346
- parts[even] = _ventilate_segment(parts[even])
347
- return "`".join(parts)
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 paragraphs into one sentence per line. Idempotent: a line
352
- already holding a single sentence has no internal boundary to split."""
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
- lines[i] = _split_prose_line(line)
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