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.
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- 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/clarification_items.py +34 -12
- 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 +19 -11
- package/runtime/python/okstra_ctl/session.py +36 -7
- package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
- package/runtime/python/okstra_ctl/user_response.py +14 -5
- package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
- package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
- 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.py +39 -9
- package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +57 -1
- 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/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report.template.md +3 -0
- package/runtime/validators/validate-run.py +15 -6
- package/runtime/validators/validate_session_conformance.py +16 -6
- 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}
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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):
|
|
@@ -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
|
|
264
|
-
"""Fail-closed read of
|
|
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
|
-
|
|
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) ->
|
|
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=
|
|
310
|
+
f"cannot confirm there are no unresolved `Blocks={expected}` rows"
|
|
289
311
|
)
|
|
290
|
-
return
|
|
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
|
|
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
|
|
300
|
-
return
|
|
321
|
+
# was emitted — confidently "no blocking items".
|
|
322
|
+
return ClarificationScan([], None)
|
|
301
323
|
if table.unparsed_row_count:
|
|
302
|
-
return
|
|
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
|
|
331
|
+
if it.blocks in blocking_values and it.status in UNRESOLVED_STATUSES
|
|
310
332
|
]
|
|
311
|
-
return
|
|
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
|
-
"""
|
|
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
|
|