okstra 0.68.0 → 0.69.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 (30) hide show
  1. package/bin/okstra +18 -0
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/agents/SKILL.md +3 -3
  5. package/runtime/agents/workers/claude-worker.md +1 -1
  6. package/runtime/agents/workers/codex-worker.md +3 -3
  7. package/runtime/agents/workers/gemini-worker.md +3 -3
  8. package/runtime/agents/workers/report-writer-worker.md +2 -2
  9. package/runtime/prompts/launch.template.md +2 -2
  10. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  11. package/runtime/prompts/profiles/_implementation-executor.md +3 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +1 -0
  13. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  14. package/runtime/prompts/wizard/prompts.ko.json +4 -4
  15. package/runtime/python/okstra_ctl/conformance.py +17 -0
  16. package/runtime/python/okstra_ctl/run.py +87 -17
  17. package/runtime/python/okstra_ctl/wizard.py +64 -18
  18. package/runtime/python/okstra_ctl/worktree.py +18 -0
  19. package/runtime/python/okstra_token_usage/collect.py +27 -0
  20. package/runtime/skills/okstra-convergence/SKILL.md +2 -2
  21. package/runtime/skills/okstra-report-writer/SKILL.md +6 -6
  22. package/runtime/skills/okstra-team-contract/SKILL.md +5 -5
  23. package/runtime/validators/validate-run.py +2 -2
  24. package/src/_python-helper.mjs +52 -0
  25. package/src/error-log.mjs +19 -0
  26. package/src/inject-report-index.mjs +22 -0
  27. package/src/render-final-report.mjs +22 -0
  28. package/src/render-views.mjs +9 -48
  29. package/src/spawn-followups.mjs +23 -0
  30. package/src/token-usage.mjs +3 -34
package/bin/okstra CHANGED
@@ -41,6 +41,19 @@ const COMMANDS = new Map([
41
41
  () => import("../src/render-bundle.mjs").then((m) => m.run),
42
42
  ],
43
43
  ["render-views", () => import("../src/render-views.mjs").then((m) => m.run)],
44
+ [
45
+ "render-final-report",
46
+ () => import("../src/render-final-report.mjs").then((m) => m.run),
47
+ ],
48
+ [
49
+ "inject-report-index",
50
+ () => import("../src/inject-report-index.mjs").then((m) => m.run),
51
+ ],
52
+ [
53
+ "spawn-followups",
54
+ () => import("../src/spawn-followups.mjs").then((m) => m.run),
55
+ ],
56
+ ["error-log", () => import("../src/error-log.mjs").then((m) => m.run)],
44
57
  ["wizard", () => import("../src/wizard.mjs").then((m) => m.run)],
45
58
  ["token-usage", () => import("../src/token-usage.mjs").then((m) => m.run)],
46
59
  ["memory", () => import("../src/memory.mjs").then((m) => m.run)],
@@ -89,6 +102,11 @@ Introspection commands (JSON output, used by skills to avoid python heredocs):
89
102
  token-usage Collect token usage for a run (wraps the installed
90
103
  okstra-token-usage.py so skills avoid emitting
91
104
  python3 "$HOME/..." invocations).
105
+ render-views Render slim AI + self-contained HTML views of a final report
106
+ render-final-report Render the markdown sibling of a final-report data.json
107
+ inject-report-index Add the top-of-report Index + scroll anchors to a report
108
+ spawn-followups Create follow-up task bundles from a final report
109
+ error-log Append run error events to the run error log
92
110
  memory Store and find user-home conversation memory under
93
111
  ~/.okstra/memory-book.
94
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.68.0",
3
+ "version": "0.69.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.68.0",
3
- "builtAt": "2026-06-10T14:01:48.019Z",
2
+ "package": "0.69.0",
3
+ "builtAt": "2026-06-10T15:26:59.219Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -246,13 +246,13 @@ After each worker terminates, BEFORE classifying its terminal status, verify the
246
246
  After each worker terminates (any terminal status), if its errors sidecar exists, dump it to the run error log using the same resolved paths from the launch prompt:
247
247
 
248
248
  ```bash
249
- python3 scripts/okstra-error-log.py append-from-worker \
249
+ okstra error-log append-from-worker \
250
250
  --sidecar <absolute-sidecar-path-from-launch-prompt> \
251
251
  --out <absolute-errors-log-path-from-launch-prompt> \
252
252
  --task-key <taskKey> --agent <agent> --agent-role <role> --model <model>
253
253
  ```
254
254
 
255
- For Codex/Gemini wrappers: if the CLI returns non-zero, times out, or hits a rate limit, immediately call `okstra-error-log.py append-observed --error-type cli-failure ...` with the captured exit code, duration, message, and stderr excerpt. The wrapper subagent does this from inside its own Bash tool — Lead does NOT need to re-record. Token usage is NOT available from Agent tool results in real time; it is collected post-hoc at the start of Phase 7.
255
+ For Codex/Gemini wrappers: if the CLI returns non-zero, times out, or hits a rate limit, immediately call `okstra error-log append-observed --error-type cli-failure ...` with the captured exit code, duration, message, and stderr excerpt. The wrapper subagent does this from inside its own Bash tool — Lead does NOT need to re-record. Token usage is NOT available from Agent tool results in real time; it is collected post-hoc at the start of Phase 7.
256
256
 
257
257
  ## Phase 5.5: Convergence loop
258
258
 
@@ -271,7 +271,7 @@ When `task-manifest.json` does not set `convergence.maxRounds`, lead MUST resolv
271
271
 
272
272
  **Confirmed findings are pruned from the queue.** Findings classified as `full-consensus`, `partial-consensus`, or `worker-unique` MUST NOT appear in any subsequent round's reverify prompt for any worker. `contested` is a final classification assigned only when the last executed round completes and the queue is still non-empty — it is NEVER an intermediate queue label.
273
273
 
274
- If any re-verification batch yields a `verification-error` terminal status, or a worker result fails the contract, Lead MUST record one event per violation via `python3 scripts/okstra-error-log.py append-observed --error-type contract-violation --agent <offending-agent> ...`. Use `agent: "claude-lead"` only when the violation is detected internally without a specific worker.
274
+ If any re-verification batch yields a `verification-error` terminal status, or a worker result fails the contract, Lead MUST record one event per violation via `okstra error-log append-observed --error-type contract-violation --agent <offending-agent> ...`. Use `agent: "claude-lead"` only when the violation is detected internally without a specific worker.
275
275
 
276
276
  If convergence is disabled, proceed directly to Phase 6 with the raw worker results.
277
277
 
@@ -104,7 +104,7 @@ If you find yourself thinking "let me double-check section 3" or "I should read
104
104
 
105
105
  ## Error reporting
106
106
 
107
- This agent is responsible for recording its own tool failures via `scripts/okstra-error-log.py`:
107
+ This agent is responsible for recording its own tool failures via `okstra error-log`:
108
108
 
109
109
  **Path extraction (BLOCKING).** Before recording anything, extract the absolute sidecar path from the lead's dispatch prompt body:
110
110
 
@@ -103,7 +103,7 @@ The wrapper exists because Claude Code's Bash permission matcher rejects simple-
103
103
 
104
104
  c. **Result-file existence check (exit 0 only).** If `exit_code == 0` BUT no file exists at the extracted Result Path, the Codex CLI returned 0 without producing the analysis artifact. Observed failure mode: the CLI streams analysis prose on stdout, hits its token budget or a sandbox EPERM mid-`Write`, and exits 0 with the artifact never persisted. Forwarding the partial stdout silently degrades lead synthesis (the case that motivated this rule), so this path is required.
105
105
  1. Capture the final ~10 lines of the wrapper's live log for diagnostics — single Bash call: `tail -n 10 "${prompt_path%.md}.log"` (substitute the literal absolute prompt-history path; the wrapper writes the log next to it per the §"trace pane" comment in `okstra-codex-exec.sh`). Write the captured lines to a temp file (e.g. `<errors-sidecar-dir>/codex-result-missing-tail.txt`) so `--stderr-excerpt-file` can reference it.
106
- 2. Record a `cli-failure` event directly to the run-level error log via the exact `okstra-error-log.py append-observed` template in §"Error reporting" — substitute `--exit-code 0`, `--duration-ms <observed-ms>`, `--message "okstra-codex-exec.sh exited 0 but no result file at <abs-path>"`, and `--stderr-excerpt-file <temp-tail-path>`.
106
+ 2. Record a `cli-failure` event directly to the run-level error log via the exact `okstra error-log append-observed` template in §"Error reporting" — substitute `--exit-code 0`, `--duration-ms <observed-ms>`, `--message "okstra-codex-exec.sh exited 0 but no result file at <abs-path>"`, and `--stderr-excerpt-file <temp-tail-path>`.
107
107
  3. Return `CODEX_RESULT_MISSING: codex exited 0 but result file absent at <abs-path>` instead of the raw stdout. The lead is responsible for deciding redispatch per `okstra-team-contract` "Lead Redispatch Policy on Result-Missing".
108
108
 
109
109
  d. **Normal return.** Otherwise (`exit_code == 0` AND result file exists), return the wrapper's accumulated stdout from `BashOutput`, prefixed by exactly one model-identity line copied verbatim from the `**Model:** Codex worker, <execution-value>` line in the lead prompt (per Worker Preamble → "Return message to the lead"):
@@ -175,7 +175,7 @@ This contract mirrors the `okstra-team-contract` skill's Worker Output Contract
175
175
  ## Error reporting
176
176
 
177
177
  The wrapper agent (this Codex worker subagent) is responsible for recording
178
- two kinds of errors via `scripts/okstra-error-log.py`:
178
+ two kinds of errors via `okstra error-log`:
179
179
 
180
180
  **Path extraction (BLOCKING).** Before recording anything, extract the
181
181
  following two absolute paths verbatim from the lead's dispatch prompt body:
@@ -207,7 +207,7 @@ and the run-level error log staying empty.
207
207
  the dispatched `bash_id`:
208
208
 
209
209
  ```bash
210
- python3 scripts/okstra-error-log.py append-observed \
210
+ okstra error-log append-observed \
211
211
  --out "<absolute-errors-log-path-from-lead-prompt>" \
212
212
  --task-key "<task-key>" \
213
213
  --phase "<phase>" \
@@ -103,7 +103,7 @@ The wrapper exists because Claude Code's Bash permission matcher rejects simple-
103
103
 
104
104
  c. **Result-file existence check (exit 0 only).** If `exit_code == 0` BUT no file exists at the extracted Result Path, the Gemini CLI returned 0 without producing the analysis artifact. Observed failure mode: the CLI streams analysis prose on stdout, hits its token budget or a sandbox EPERM mid-`Write`, and exits 0 with the artifact never persisted. Forwarding the partial stdout silently degrades lead synthesis (the case that motivated this rule), so this path is required.
105
105
  1. Capture the final ~10 lines of the wrapper's live log for diagnostics — single Bash call: `tail -n 10 "${prompt_path%.md}.log"` (substitute the literal absolute prompt-history path; the wrapper writes the log next to it per the §"trace pane" comment in `okstra-gemini-exec.sh`). Write the captured lines to a temp file (e.g. `<errors-sidecar-dir>/gemini-result-missing-tail.txt`) so `--stderr-excerpt-file` can reference it.
106
- 2. Record a `cli-failure` event directly to the run-level error log via the exact `okstra-error-log.py append-observed` template in §"Error reporting" — substitute `--exit-code 0`, `--duration-ms <observed-ms>`, `--message "okstra-gemini-exec.sh exited 0 but no result file at <abs-path>"`, and `--stderr-excerpt-file <temp-tail-path>`.
106
+ 2. Record a `cli-failure` event directly to the run-level error log via the exact `okstra error-log append-observed` template in §"Error reporting" — substitute `--exit-code 0`, `--duration-ms <observed-ms>`, `--message "okstra-gemini-exec.sh exited 0 but no result file at <abs-path>"`, and `--stderr-excerpt-file <temp-tail-path>`.
107
107
  3. Return `GEMINI_RESULT_MISSING: gemini exited 0 but result file absent at <abs-path>` instead of the raw stdout. The lead is responsible for deciding redispatch per `okstra-team-contract` "Lead Redispatch Policy on Result-Missing".
108
108
 
109
109
  d. **Normal return.** Otherwise (`exit_code == 0` AND result file exists), return the wrapper's accumulated stdout from `BashOutput`, prefixed by exactly one model-identity line copied verbatim from the `**Model:** Gemini worker, <execution-value>` line in the lead prompt (per Worker Preamble → "Return message to the lead"):
@@ -175,7 +175,7 @@ This contract mirrors the `okstra-team-contract` skill's Worker Output Contract
175
175
  ## Error reporting
176
176
 
177
177
  The wrapper agent (this Gemini worker subagent) is responsible for recording
178
- two kinds of errors via `scripts/okstra-error-log.py`:
178
+ two kinds of errors via `okstra error-log`:
179
179
 
180
180
  **Path extraction (BLOCKING).** Before recording anything, extract the
181
181
  following two absolute paths verbatim from the lead's dispatch prompt body:
@@ -207,7 +207,7 @@ and the run-level error log staying empty.
207
207
  the dispatched `bash_id`:
208
208
 
209
209
  ```bash
210
- python3 scripts/okstra-error-log.py append-observed \
210
+ okstra error-log append-observed \
211
211
  --out "<absolute-errors-log-path-from-lead-prompt>" \
212
212
  --task-key "<task-key>" \
213
213
  --phase "<phase>" \
@@ -101,9 +101,9 @@ Rules (the schema enforces most of these — they are listed here so you know *w
101
101
  - Cite file paths and line numbers in every `evidence.primary[].source` / `consensus[].evidence` cell.
102
102
  - Preserve every analysis worker's ticket tagging — every row's `ticketId` field carries the ticket key or the task-fallback. For single-ticket runs, set `ticketCoverage` to `{"singleTicket": "<ticket>"}`. For runs that do not require ticket tagging (`release-handoff`, `final-verification`), set `ticketCoverage` to `{"omit": true}`.
103
103
  - For `implementation-planning`, populate `implementationPlanning.requirementCoverage` with one row per concrete requirement from the brief / packet, using IDs `R-001`, `R-002`, ... in source order. `coveredBy` MUST name the specific Option Candidate plus Stage/Step that satisfies the requirement. Use `status: "covered"` only when the report's plan actually covers it; otherwise use `gap` or `blocked C-NNN` and ensure the corresponding `Clarification Items` row blocks approval. Do not collapse this into `ticketCoverage`; ticket coverage is not requirement coverage.
104
- - When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate-improvement-report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes. `improvement-discovery` is NOT in the data.json schema enum, so author its markdown directly (not via `okstra-render-final-report.py`). Immediately after writing the markdown, run (`Bash`): `python3 scripts/okstra-inject-report-index.py <markdown path> --report-language <en|ko>`. That adds the top-of-report Index plus `I-NNN` / `C-NNN` scroll anchors; the run validator fails the report when the Index anchor is absent.
104
+ - When the `Task Type` is `improvement-discovery`, populate `## 5.9 Improvement Candidates` with the 10-column schema enforced by `validators/validate-improvement-report.py`. Source the row IDs (`I-NNN`), lens whitelist, and Source workers patterns from `scripts/okstra_ctl/improvement_lenses.py` — do NOT introduce new lens names or worker prefixes. `improvement-discovery` is NOT in the data.json schema enum, so author its markdown directly (not via `okstra-render-final-report.py`). Immediately after writing the markdown, run (`Bash`): `okstra inject-report-index <markdown path> --report-language <en|ko>`. That adds the top-of-report Index plus `I-NNN` / `C-NNN` scroll anchors; the run validator fails the report when the Index anchor is absent.
105
105
 
106
- Write the data.json with your `Write` tool against the absolute `Result Path`. Then invoke the renderer (`Bash`): `python3 scripts/okstra-render-final-report.py <data.json path>`. Confirm both files exist and respond with a short status line prefixed by your model identity, copied verbatim from the `**Model:** Report writer worker, <modelExecutionValue>` line in your dispatch prompt (per Worker Preamble → "Return message to the lead"):
106
+ Write the data.json (and the audit sidecar `.md`) with your `Write` tool that is the canonical authoring path, and okstra ships no hook that blocks `.md` writes (its only settings hook is the `SessionEnd` trace-cleanup; the coding-preflight hook emits reminders but never blocks). A Bash heredoc is acceptable ONLY when a specific `Write` call is genuinely rejected by the host environment, and it MUST produce byte-identical content — do not reach for it pre-emptively. Then invoke the renderer (`Bash`): `okstra render-final-report <data.json path>`. Confirm both files exist and respond with a short status line prefixed by your model identity, copied verbatim from the `**Model:** Report writer worker, <modelExecutionValue>` line in your dispatch prompt (per Worker Preamble → "Return message to the lead"):
107
107
 
108
108
  ```
109
109
  **Model:** Report writer worker, <modelExecutionValue>
@@ -67,8 +67,8 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
67
67
  - When dispatching any worker you MUST inject **two header lines** into the dispatch prompt body so the worker subagent can record errors without guessing paths:
68
68
  - `**Errors log path:** <absolute run-level errors log path>`
69
69
  - `**Errors sidecar path:** <absolute per-worker sidecar path matching the dispatched worker>`
70
- - These lines are the canonical contract — worker subagents extract them verbatim and pass them to `okstra-error-log.py append-observed --out ...` (run-level cli-failure / contract-violation events) and to their internal sidecar writes (worker-reported tool-failure events) respectively.
71
- - After each worker terminates, dump its sidecar into the run-level errors log via `python3 scripts/okstra-error-log.py append-from-worker --sidecar <sidecar-path> --out <run-errors-log-path> --task-key {{TASK_KEY}} --agent <worker-id> --agent-role worker --model <assigned-model-execution-value>` (per `okstra-team-contract` Worker Output Contract).
70
+ - These lines are the canonical contract — worker subagents extract them verbatim and pass them to `okstra error-log append-observed --out ...` (run-level cli-failure / contract-violation events) and to their internal sidecar writes (worker-reported tool-failure events) respectively.
71
+ - After each worker terminates, dump its sidecar into the run-level errors log via `okstra error-log append-from-worker --sidecar <sidecar-path> --out <run-errors-log-path> --task-key {{TASK_KEY}} --agent <worker-id> --agent-role worker --model <assigned-model-execution-value>` (per `okstra-team-contract` Worker Output Contract).
72
72
 
73
73
  ## Executor Worktree
74
74
 
@@ -44,6 +44,7 @@ are collected and convergence finished. Phase 1-5 do not need it.
44
44
  git diff <base>..HEAD | grep -E '^\+[^+].*\b(TBD|TODO|FIXME|XXX|implement later|handle edge cases|similar to|placeholder)\b' || echo 'clean'
45
45
  ```
46
46
  Only newly-added lines (those starting with `+` and not part of the `+++` header) are inspected. If output is anything other than `clean`, the run MUST either remove the placeholders before finalising or record an explicit justification per occurrence in the final report.
47
+ 7. **Stage-foreign literal scrub** — when the report-writer modelled this stage's `data.json` on another stage's report (a common shortcut for structural consistency), stage-specific literals get copied verbatim and silently misattribute this run. Confirm every branch name, commit SHA, stageKey, and stage number in the report resolves to **this** run's stage `<N>` — its worktree branch is `<prefix>-<task-id>-s<N>`, its stageKey `<task-id>-stage-<N>`. Sweep the report for any `-s<M>` / `stage-<M>` / `Stage <M>` where `M ≠ N` and for SHAs not in this run's `Commit list`; each hit is a copy-from-other-stage defect to correct before finalising.
47
48
 
48
49
  ## Lead post-stage persistence (BLOCKING — runs after the Executor emits `### Stage Carry Evidence`)
49
50
 
@@ -24,12 +24,13 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
24
24
  - **Language-agnostic principles that ALWAYS bind (the TDD loop below MUST satisfy them):** (1) no self-mocking of the SUT — stub/spy only injected collaborators, never the subject's own methods; (2) behavioral assertions on outcomes (return value, state, persisted rows, events, boundary calls) — never `toHaveBeenCalled*` on an internal helper as the only/primary assertion; (3) truthful names — a `get*` / `find*` that writes/inserts, or a name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*`), is a defect; (4) single-purpose functions ≤50 effective lines, plain-English readability.
25
25
  - **Graceful degradation (codex / gemini executor runtimes, or any runtime where the `~/.claude/skills/okstra-coding-preflight/` files are absent or unreadable):** do NOT skip the gate — apply the agnostic principles above plus the project's own `CLAUDE.md` / `CONTRIBUTING` / formatter+lint config, and record `coding-conventions: skill-unavailable → applied <project rules + agnostic principles>` in the final report. Never claim a skill read that did not happen.
26
26
  - **CLI executor transcription (BLOCKING when the executor provider is `codex` or `gemini`):** the executor CLI process does NOT share the lead's context — a gate that stays in lead memory never reaches it. The lead MUST copy this entire "Coding-conventions preflight" bullet tree (file-read instructions, project review rule packs, agnostic principles, graceful degradation) verbatim into the dispatched executor prompt body. Enforcement: the CLI wrapper agents refuse an implementation-Executor dispatch whose persisted prompt lacks the literal heading `Coding-conventions preflight`, returning `<SENTINEL_PREFIX>_PREFLIGHT_MISSING` (see `agents/workers/_cli-wrapper-template.md` → Prompt Composition).
27
+ - **Non-interactive auto-execution (BLOCKING when the executor provider is `codex` or `gemini`).** A CLI executor runs head-less (`codex exec` / gemini equivalent) — there is no human at the keyboard. Skills loaded during the run (tdd, coding-preflight, and others) contain "get user approval", "state your plan to the user and wait", or "ask before proceeding" gates written for interactive sessions; in this run those gates are **already satisfied** by the upstream `implementation-planning` approval (the plan this stage executes was human-approved). The executor MUST NOT stop to request approval, MUST NOT end its turn after only producing a plan, and MUST carry the stage through end-to-end — RED → GREEN → refactor → per-step commits → `### Stage Carry Evidence`. The ONLY skill step to skip is the interactive user-approval prompt itself; every other skill rule (TDD discipline, conventions, real-IO isolation) still binds. The lead MUST transcribe this bullet verbatim into the dispatched CLI executor prompt (same reason as the preflight transcription rule above — the CLI process does not share lead context). Stopping early for approval in a head-less run is the observed empty-exit failure (exit 0, no diff): treat it as `contract-violated`.
27
28
  - **Mandatory TDD loop**: BEFORE the first `Edit` or `Write` call, the executor MUST apply a red-green-refactor loop for every code change in this run. This is required; skipping it is a `contract-violated` outcome. This governs HOW each step is executed (failing test first → minimal implementation → refactor); it does not override the approved plan's WHAT/file scope.
28
29
  - Order of operations per plan step: (1) write/extend the test that captures the step's acceptance criterion and confirm it fails for the right reason, (2) commit the failing test (`test(<scope>): ...`), (3) implement the minimum change to make it pass, (4) commit the implementation (`feat|fix(<scope>): ...`), (5) refactor without changing behaviour and commit separately if any cleanup is made (`refactor(<scope>): ...`). The failing-then-passing transition between steps (2) and (4) is the `TDD evidence` required by the final report.
29
30
  - Doc-only / config-only / pure-rename steps that have no observable runtime behaviour are exempt from the failing-test requirement, but the executor MUST cite the exemption per step in the final report (`TDD exemption: <reason>`).
30
31
  - When the touched area has no existing test harness, the executor MUST stand up the minimum harness needed to host one regression test for this run rather than skipping TDD entirely. Record the harness-bootstrap step as an `Out-of-plan edit` if it is not in the plan.
31
32
  - **DB / IO / SQL changes require real execution — mock-only is NOT validation evidence:** when this run's diff touches DB/IO/SQL (ORM / query-builder code — sequelize / typeorm / prisma / knex / raw SQL — `*.repository.*`, model/entity files, `migrations/**`, `*.sql`, or any changed query string), a mocked unit test cannot observe the SQL the query builder actually emits — a mocked suite once passed while `count({ col: 'FontFamily.fontFamily' })` threw `Unknown column` on the real DB. The executor MUST run the change against a real (or faithful-replica) datastore — the `db-test` validation step (plan `validation` db step, else `project.json.qaCommands.db-test`), targeting a **local / replica** DB — and cite its exact command + exit code in the final report's `Validation evidence`. If no real DB / `db-test` command is reachable, do NOT claim the change verified: label the DB portion `정적 분석상 …, 미검증(실행 안 함)` in the report, surface it in the routing recommendation, and never downplay the real run as "too heavy". `git push` stays forbidden (universal list); the unverified DB state is carried forward so `final-verification` cannot accept it and `release-handoff` cannot push.
32
- - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa directory `<task_root>/qa/` (the `TASK_QA_PATH` token; same directory that holds the Tier 3 conformance manifest). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite.
33
+ - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa directory `<task_root>/qa/` (the `TASK_QA_PATH` token; same directory that holds the Tier 3 conformance manifest). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
33
34
  - re-read the approved plan end-to-end and parse the `## 5.5 Stage Map`. Read the **Stage** injected in the launch prompt (`Stage for this implementation run`): the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`.
34
35
  - load every `runs/<plan-key>/carry/stage-<i>.json` for `i ∈ depends-on(this stage)` and inject them into the executor's working context as "runtime carry-in". For a `depends-on (none)` stage, no sidecar load — task-brief only.
35
36
  - this stage's `depends-on` are all already `status:done`. Its file list, step order, Stage Validation commands, Stage Exit Contract, and rollback path are the authoritative scope.
@@ -58,6 +59,7 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
58
59
  - read-only inspection commands: `git status`, `git diff`, `git log`, `grep`, `rg`, `find`, `cat`, `ls`, file Read tools
59
60
  - build, lint, type-check, and test commands (`npm test`, `pytest`, `go build`, `cargo test`, `bash -n`, etc.)
60
61
  - **local git operations only**: `git add`, `git commit`. Prefer small commits keyed to plan steps.
62
+ - **No okstra artifacts in commits (BLOCKING).** Never use `git add -f`. Before every `git commit`, run `git diff --cached --name-only` and confirm it contains zero `.okstra/` paths (and zero `.project-docs/` paths when the legacy symlink is present). `.okstra/**` is gitignored; force-staging it onto the stage branch is the one way these verification artifacts reach the upstream PR. Conformance/qa evidence belongs in the carry sidecar and verifier result — committing it is never correct, even when a step's instructions seem to ask for it.
61
63
  - **Commit message format (mandatory)**: every commit message MUST follow Conventional Commits — `<type>(<scope>): <subject>` for the first line, optional body separated by a blank line, optional footer. Constraints:
62
64
  - `<type>` MUST be one of: `feat` / `fix` / `perf` / `revert` / `deps` / `docs` / `refactor` / `build` / `ci` / `chore` / `test`. When the repo is `release-please`-managed, this aligns the commit with a configured changelog section.
63
65
  - `<scope>` SHOULD be the plan step identifier or the primary module touched (e.g. `feat(report-writer): ...`). Omit the parentheses only when no meaningful scope applies.
@@ -96,6 +96,7 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
96
96
  - **Tautological delegation assertion:** a test asserts the SUT result equals a direct call to the same pure helper/collaborator that the SUT delegates to, instead of asserting an independent literal value or observable state.
97
97
  - **Untruthful name:** a read-named function (`get*` / `find*` / `load*`) that writes/inserts/mutates; an adapter or repository name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*` / `findActive*`).
98
98
  - **Hexagonal (only when the overlay is loaded):** business logic inside a port body; an adapter method that is not pure I/O (post-fetch JS filtering on domain state, domain-rule evaluation); a domain object declared outside the `domain/` boundary.
99
+ - **okstra artifact committed to the branch:** any path in the `git diff --name-only <base>...HEAD` enumeration that lives under `.okstra/` (or `.project-docs/` when the legacy symlink is present). `.okstra/**` is gitignored, so a committed okstra file means the executor force-staged it (`git add -f`) — leaking verification artifacts (qa scripts, conformance results) into the eventual PR. Cite the path; recommend `git rm --cached <path>` to untrack it while keeping the file on disk. Conformance/qa evidence belongs in the carry sidecar / verifier result, never in git history.
99
100
  - **Real-IO test in source tree:** a changed/added test under the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*` — that opens a **real** DB connection / DSN, makes a real `fetch` / `axios` / `http` request, or otherwise hits real external IO without mocking the injected collaborator (a live handle, not a stub/spy). Real-IO tests MUST live under `<task_root>/qa/` per the executor's *Real-IO test isolation* rule — a live-IO test in source silently breaks the project's CI suite and violates the artifact-home rule. Cite the test file + the real-IO line; recommend moving it to `<task_root>/qa/` (or declaring it as a Tier 3 conformance script). Mock-only unit tests in source are NOT a hit.
100
101
  - **Advisory findings (recorded as recommendations; verdict MAY still PASS):** function >50 effective lines, a single body mixing read+write stages, weak readability, a missing-but-non-critical outcome assertion, newly orphaned private/public code that is safe to remove but not on a critical path, or weak-but-not-misleading names. These land in the verifier result as `should-fix` / `nit` recommendations, not as a `FAIL`.
101
102
  - **Output.** Every finding — blocking or advisory — is a structured item in the verifier's worker result (`path:line`, rule, severity, suggested fix) so it carries into Phase 5.5 convergence and the final report. A blocking hit sets the verifier verdict to `FAIL` with the rule cited, using the same verdict machinery as the Discrepancy rule above. `Claude lead` MUST NOT silently downgrade a cited blocking finding to advisory during synthesis; an override requires a concrete cited reason, exactly as for the Discrepancy rule.
@@ -33,7 +33,7 @@
33
33
  - `Consensus` cells in `## 5.9 Improvement Candidates` use the table enum exactly: `full`, `partial`, `contested`, `worker-unique`. Map convergence's `full-consensus` / `partial-consensus` labels to `full` / `partial` before writing the table.
34
34
  - `## 7. Final Verdict` Verdict Token ∈ {`candidates-ready`, `no-candidates`, `blocked`}; Direction `routing`; Next Step "사용자에게 후보 K개 선택 의뢰 (## 5.9 표 참조)"
35
35
  - `## 3. Recommended Next Steps` first entry summarises per-candidate routing and proposes new task-key names of the form `<task-group>/imp-<Cand-ID>`
36
- - this report is authored free-form (improvement-discovery is not in the data.json schema enum); after the markdown is written, the report-writer runs `scripts/okstra-inject-report-index.py <report.md> --report-language <en|ko>` to add the top-of-report Index + `I-NNN`/`C-NNN` scroll anchors. The run validator fails the report when the Index anchor is missing.
36
+ - this report is authored free-form (improvement-discovery is not in the data.json schema enum); after the markdown is written, the report-writer runs `okstra inject-report-index <report.md> --report-language <en|ko>` to add the top-of-report Index + `I-NNN`/`C-NNN` scroll anchors. The run validator fails the report when the Index anchor is missing.
37
37
  - Clarification request policy (phase-specific addenda — shared policy is in `_common-contract.md`):
38
38
  - if scan-scope or priority-lenses cannot be made concrete during Phase 1.5, end the run with Verdict Token `blocked`, populate `## 1. Clarification Items` with `Blocks=next-phase` rows, and do not run worker dispatch
39
39
  - every clarification row carries a recommended answer + one-line rationale inside the `Expected form` cell
@@ -168,7 +168,7 @@
168
168
  "echo_template": "approved-plan: {value}"
169
169
  },
170
170
  "approve_plan_confirm": {
171
- "label": "이 플랜으로 implementation 을 진행할까요?\n {path}\n· 예 — 진행합니다. 플랜이 아직 승인 전이면 지금 data.json(정본) + 리포트를 함께 approved 로 처리한 뒤 진행합니다. (markdown 만 손으로 고치면 일관성 검증에서 거부되므로 이 경로로 승인하세요.)\n· 아니오 — 진행하지 않습니다.",
171
+ "label": "이 플랜으로 진행할까요?\n {path}\n· 예 — 진행합니다. 플랜이 아직 승인 전이면 지금 data.json(정본) + 리포트를 함께 approved 로 처리한 뒤 진행합니다. (markdown 만 손으로 고치면 일관성 검증에서 거부되므로 이 경로로 승인하세요.)\n· 아니오 — 진행하지 않습니다.",
172
172
  "echo_template": "approve-plan: {value}",
173
173
  "options": {
174
174
  "yes": "예 — 승인하고 진행",
@@ -179,16 +179,16 @@
179
179
  "approved": "approved-plan: {path} (승인·진행 확인됨)"
180
180
  },
181
181
  "errors": {
182
- "declined": "진행을 선택하지 않으면 implementation 시작할 수 없습니다. 진행(예)하거나 위저드를 종료하세요.",
182
+ "declined": "진행을 선택하지 않으면 다음 단계로 넘어갈 수 없습니다. 진행(예)하거나 위저드를 종료하세요.",
183
183
  "still_unapproved": "approve-plan: 승인 처리 후에도 승인 상태가 아닙니다 (data.json/markdown 불일치): {path}"
184
184
  }
185
185
  },
186
186
  "stage_pick": {
187
187
  "label": "stage 범위를 선택하세요. auto 는 전체 task(모든 stage)를, 특정 번호는 해당 stage 만 대상으로 합니다.",
188
+ "label_final_verification": "검증할 implementation stage 를 선택하세요.",
188
189
  "echo_template": "stage: {value}",
189
190
  "options": {
190
- "auto": "auto (다음 미완료 stage)",
191
- "auto_final_verification": "auto (전체 task — 모든 stage 머지 후 한 번)"
191
+ "auto": "auto (다음 미완료 stage)"
192
192
  }
193
193
  },
194
194
  "directive_pick": {
@@ -258,6 +258,23 @@ def apply_qa_waiver(manifest: object, stage_key: str, reason: str, *, at: str,
258
258
  return False
259
259
 
260
260
 
261
+ def clear_qa_waiver(manifest: object, stage_key: str) -> bool:
262
+ """stage_key entry 의 `waiver` 를 제거한다(in place). 제거했으면 True.
263
+
264
+ 한 stage 의 새 run 이 시작될 때, 그 stage entry 에 남아 있던 이전 run 의
265
+ waiver(예: all-gate run 이 미래 stage 를 미리 waive 한 것)는 stale 다 —
266
+ 그대로 두면 verifier 가 conformance 를 skip 해 마스킹된다. 이 run 이 실제로
267
+ 검증하도록 제거한다. 사용자가 이번 run 에 같은 stage 를 명시 waive 한
268
+ 경우(--qa-waiver)는 호출 측에서 걸러 보존한다."""
269
+ entries = manifest.get("entries") if isinstance(manifest, dict) else None
270
+ if not isinstance(entries, list):
271
+ return False
272
+ for entry in entries:
273
+ if isinstance(entry, dict) and entry.get("stageKey") == stage_key:
274
+ return entry.pop("waiver", None) is not None
275
+ return False
276
+
277
+
261
278
  def manifest_required_surfaces(manifest: object) -> set[str]:
262
279
  """매니페스트 전 entry 의 `requires` 합집합 — 선언된 surface 집합."""
263
280
  entries = manifest.get("entries") if isinstance(manifest, dict) else None
@@ -81,7 +81,11 @@ from .workers import (
81
81
  )
82
82
  from .workflow import compute_workflow_state
83
83
  from .locks import worktree_provision_mutex
84
- from .worktree import provision_task_worktree
84
+ from .worktree import (
85
+ WorktreeProvision,
86
+ okstra_clean_gate_excludes,
87
+ provision_task_worktree,
88
+ )
85
89
 
86
90
  # Frontmatter approval-flag matcher.
87
91
  #
@@ -1151,6 +1155,46 @@ def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> N
1151
1155
  manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
1152
1156
 
1153
1157
 
1158
+ def _clear_stale_stage_waiver(inp: "PrepareInputs", project_root: Path, stage: int) -> None:
1159
+ """A fresh `implementation` run of stage N must not inherit a waiver left on
1160
+ its conformance entry by an earlier run (e.g. an all-gate run that pre-waived
1161
+ future stages, or an abandoned attempt). A stale waiver makes the verifier
1162
+ skip Tier 3 conformance and silently mask this stage, so clear it — UNLESS
1163
+ the user re-waived this exact stage for this run via `--qa-waiver` (already
1164
+ applied upstream in `_apply_qa_waiver_if_requested`)."""
1165
+ from .conformance import clear_qa_waiver, parse_qa_waiver_arg
1166
+ from .paths import task_dir
1167
+ manifest_path = (
1168
+ task_dir(project_root, inp.task_group, inp.task_id)
1169
+ / "qa" / "conformance-manifest.json"
1170
+ )
1171
+ if not manifest_path.is_file():
1172
+ return
1173
+ manifest = json.loads(manifest_path.read_text())
1174
+ entries = manifest.get("entries") if isinstance(manifest, dict) else None
1175
+ if not isinstance(entries, list):
1176
+ return
1177
+ # The manifest stageKey is `<task-id>-stage-<N>` authored by planning; match
1178
+ # on the `-stage-<N>` suffix so we do not assume the task-id's exact form.
1179
+ suffix = f"-stage-{stage}"
1180
+ stage_key = next(
1181
+ (e["stageKey"] for e in entries
1182
+ if isinstance(e, dict) and isinstance(e.get("stageKey"), str)
1183
+ and e["stageKey"].endswith(suffix)),
1184
+ None,
1185
+ )
1186
+ if stage_key is None:
1187
+ return
1188
+ if inp.qa_waiver:
1189
+ parsed = parse_qa_waiver_arg(inp.qa_waiver)
1190
+ if parsed is not None and parsed[0] == stage_key:
1191
+ return # user intentionally waived this stage for this run
1192
+ if clear_qa_waiver(manifest, stage_key):
1193
+ manifest_path.write_text(
1194
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
1195
+ )
1196
+
1197
+
1154
1198
  def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
1155
1199
  """project.json self-registration + (implementation 한정) qaCommands gate 검증."""
1156
1200
  from okstra_project import ResolverError
@@ -1493,10 +1537,22 @@ def _is_ancestor(cwd, commit, head) -> bool:
1493
1537
 
1494
1538
 
1495
1539
  def _is_dirty_excluding_okstra(cwd) -> bool:
1496
- out = _git_out(cwd, "status", "--short", "--", ".", ":(exclude).okstra")
1540
+ excludes = [f":(exclude){p}" for p in okstra_clean_gate_excludes(Path(cwd))]
1541
+ out = _git_out(cwd, "status", "--short", "--", ".", *excludes)
1497
1542
  return bool(out.strip())
1498
1543
 
1499
1544
 
1545
+ def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
1546
+ """Placeholder until the selected stage registry row is resolved."""
1547
+ return WorktreeProvision(
1548
+ status="deferred-final-verification",
1549
+ note=(
1550
+ "final-verification single-stage uses the selected implementation "
1551
+ "stage worktree from the registry"
1552
+ ),
1553
+ )
1554
+
1555
+
1500
1556
  def _reserve_final_verification_target(
1501
1557
  inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
1502
1558
  ) -> None:
@@ -1519,6 +1575,7 @@ def _reserve_final_verification_target(
1519
1575
  row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
1520
1576
  wt_path = (row or {}).get("worktree_path", "")
1521
1577
  stage_base = (row or {}).get("base_ref", "")
1578
+ stage_branch = (row or {}).get("branch", "")
1522
1579
  head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
1523
1580
  target = _resolve_single_stage_target(
1524
1581
  requested_stage=inp.stage, done_rows=done_rows,
@@ -1527,6 +1584,12 @@ def _reserve_final_verification_target(
1527
1584
  stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
1528
1585
  )
1529
1586
  ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
1587
+ ctx["EXECUTOR_WORKTREE_BRANCH"] = stage_branch
1588
+ ctx["EXECUTOR_WORKTREE_BASE_REF"] = stage_base
1589
+ ctx["EXECUTOR_WORKTREE_STATUS"] = "reused-stage"
1590
+ ctx["EXECUTOR_WORKTREE_NOTE"] = (
1591
+ f"final-verification uses implementation stage {n} worktree"
1592
+ )
1530
1593
  else:
1531
1594
  wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1532
1595
  anchor = _reg.get_implementation_base(
@@ -1803,21 +1866,24 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1803
1866
  with worktree_provision_mutex(
1804
1867
  okstra_home(), inp.project_id, task_group_segment, task_id_segment,
1805
1868
  ):
1806
- try:
1807
- worktree = provision_task_worktree(
1808
- task_type=inp.task_type,
1809
- project_root=project_root,
1810
- project_id=inp.project_id,
1811
- task_group_segment=task_group_segment,
1812
- task_id_segment=task_id_segment,
1813
- work_category=inp.work_category,
1814
- base_ref=inp.base_ref,
1815
- require_base_ref=True,
1816
- )
1817
- except RuntimeError as exc:
1818
- raise PrepareError(
1819
- f"task worktree provisioning failed: {exc}"
1820
- ) from exc
1869
+ if inp.task_type == "final-verification" and inp.stage and inp.stage != "auto":
1870
+ worktree = _single_stage_final_verification_worktree(inp)
1871
+ else:
1872
+ try:
1873
+ worktree = provision_task_worktree(
1874
+ task_type=inp.task_type,
1875
+ project_root=project_root,
1876
+ project_id=inp.project_id,
1877
+ task_group_segment=task_group_segment,
1878
+ task_id_segment=task_id_segment,
1879
+ work_category=inp.work_category,
1880
+ base_ref=inp.base_ref,
1881
+ require_base_ref=True,
1882
+ )
1883
+ except RuntimeError as exc:
1884
+ raise PrepareError(
1885
+ f"task worktree provisioning failed: {exc}"
1886
+ ) from exc
1821
1887
 
1822
1888
  # ---- implementation stage selection (path-independent) ----
1823
1889
  # Resolve + provision the stage BEFORE run-path compute so RUN_DIR
@@ -1832,6 +1898,10 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
1832
1898
  task_key, worktree.status,
1833
1899
  )
1834
1900
  stage_arg = impl_stage_selection.stage
1901
+ # Drop any stale waiver on this stage so the run actually verifies
1902
+ # conformance (kept inside the per-task-key mutex so concurrent
1903
+ # same-task runs don't race the manifest write).
1904
+ _clear_stale_stage_waiver(inp, project_root, impl_stage_selection.stage)
1835
1905
  else:
1836
1906
  impl_stage_selection = None
1837
1907
  stage_arg = None
@@ -763,6 +763,22 @@ def _resolve_reuse_worktree(state: WizardState) -> bool:
763
763
  return bool(entry and entry.status == "active")
764
764
 
765
765
 
766
+ def _base_ref_required(state: WizardState) -> bool:
767
+ return state.task_type != "final-verification" and state.reuse_worktree is False
768
+
769
+
770
+ def _base_ref_ready(state: WizardState) -> bool:
771
+ return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
772
+
773
+
774
+ def _branch_confirm_required(state: WizardState) -> bool:
775
+ return state.task_type != "final-verification"
776
+
777
+
778
+ def _stage_auto_allowed(state: WizardState) -> bool:
779
+ return state.task_type == "implementation"
780
+
781
+
766
782
  def _existing_task_brief(project_root: Path, task_key: str) -> str:
767
783
  """Read taskBriefPath from manifest for an existing task. Empty if none."""
768
784
  root = find_task_root(project_root, task_key)
@@ -1495,12 +1511,14 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1495
1511
  stages, _errs = mod._parse_stage_map(plan_text)
1496
1512
  finally:
1497
1513
  _sys.modules.pop("_ip_stage_v_wizard", None)
1498
- auto_label = (
1499
- t["options"].get("auto_final_verification", t["options"]["auto"])
1514
+ label = (
1515
+ t.get("label_final_verification", t["label"])
1500
1516
  if state.task_type == "final-verification"
1501
- else t["options"]["auto"]
1517
+ else t["label"]
1502
1518
  )
1503
- options = [_opt("auto", auto_label)]
1519
+ options = []
1520
+ if _stage_auto_allowed(state):
1521
+ options.append(_opt("auto", t["options"]["auto"]))
1504
1522
  for s in stages:
1505
1523
  depends = ",".join(map(str, s.depends_on)) or "(none)"
1506
1524
  options.append(_opt(
@@ -1509,7 +1527,7 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1509
1527
  ))
1510
1528
  return Prompt(
1511
1529
  step=S_STAGE_PICK, kind="pick",
1512
- label=t["label"],
1530
+ label=label,
1513
1531
  options=options,
1514
1532
  echo_template=t["echo_template"],
1515
1533
  )
@@ -1518,7 +1536,12 @@ def _build_stage_pick(state: WizardState) -> Prompt:
1518
1536
  def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
1519
1537
  if not answer:
1520
1538
  raise WizardError("value required")
1521
- if answer != "auto":
1539
+ if answer == "auto":
1540
+ if not _stage_auto_allowed(state):
1541
+ raise WizardError(
1542
+ "final-verification requires an explicit stage number"
1543
+ )
1544
+ else:
1522
1545
  try:
1523
1546
  int(answer)
1524
1547
  except ValueError:
@@ -2352,7 +2375,7 @@ STEPS: list[Step] = [
2352
2375
  owns=("keep_existing_brief",)),
2353
2376
  Step(S_BASE_REF_PICK,
2354
2377
  applies=lambda s: (S_TASK_TYPE in s.answered
2355
- and s.reuse_worktree is False
2378
+ and _base_ref_required(s)
2356
2379
  and S_BASE_REF_PICK not in s.answered
2357
2380
  and bool(s.brief_path)),
2358
2381
  build=_build_base_ref_pick, submit=_submit_base_ref_pick,
@@ -2367,8 +2390,7 @@ STEPS: list[Step] = [
2367
2390
  and not s.approved_plan_pending_text
2368
2391
  and S_APPROVED_PLAN_PICK not in s.answered
2369
2392
  and bool(s.brief_path)
2370
- and (s.reuse_worktree is True
2371
- or S_BASE_REF_PICK in s.answered)
2393
+ and _base_ref_ready(s)
2372
2394
  and not s.base_ref_pending_text
2373
2395
  and _latest_implementation_planning_report(s) is not None),
2374
2396
  build=_build_approved_plan_pick, submit=_submit_approved_plan_pick,
@@ -2377,8 +2399,7 @@ STEPS: list[Step] = [
2377
2399
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
2378
2400
  and not s.approved_plan_path
2379
2401
  and bool(s.brief_path)
2380
- and (s.reuse_worktree is True
2381
- or S_BASE_REF_PICK in s.answered)
2402
+ and _base_ref_ready(s)
2382
2403
  and not s.base_ref_pending_text
2383
2404
  and (s.approved_plan_pending_text
2384
2405
  or _latest_implementation_planning_report(s) is None)),
@@ -2548,11 +2569,16 @@ STEPS: list[Step] = [
2548
2569
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
2549
2570
  owns=("pr_template_scope",)),
2550
2571
  Step(S_BRANCH_CONFIRM,
2551
- applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is None,
2572
+ applies=lambda s: (_ready_for_confirm(s)
2573
+ and _branch_confirm_required(s)
2574
+ and s.branch_confirmed is None),
2552
2575
  build=_build_branch_confirm, submit=_submit_branch_confirm,
2553
2576
  owns=("branch_confirmed",)),
2554
2577
  Step(S_CONFIRM,
2555
- applies=lambda s: _ready_for_confirm(s) and s.branch_confirmed is True and s.confirmed is None,
2578
+ applies=lambda s: (_ready_for_confirm(s)
2579
+ and (not _branch_confirm_required(s)
2580
+ or s.branch_confirmed is True)
2581
+ and s.confirmed is None),
2556
2582
  build=_build_confirm, submit=_submit_confirm,
2557
2583
  owns=("confirmed", "edit_target")),
2558
2584
  Step(S_EDIT_TARGET,
@@ -2570,7 +2596,7 @@ def _identity_ready(s: WizardState) -> bool:
2570
2596
  return False
2571
2597
  if not s.brief_path:
2572
2598
  return False
2573
- if s.reuse_worktree is False and S_BASE_REF_PICK not in s.answered:
2599
+ if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
2574
2600
  return False
2575
2601
  if s.base_ref_pending_text:
2576
2602
  return False
@@ -2763,7 +2789,21 @@ def render_args(state: WizardState) -> dict[str, str]:
2763
2789
  workers = state.workers_override.strip()
2764
2790
  if state.task_type == "implementation":
2765
2791
  workers = "" # profile-default roster is mandatory for impl
2766
- base_ref = "" if state.reuse_worktree else state.base_ref
2792
+ base_ref = (
2793
+ ""
2794
+ if state.reuse_worktree or state.task_type == "final-verification"
2795
+ else state.base_ref
2796
+ )
2797
+ if state.task_type == "implementation":
2798
+ stage = state.selected_stage or "auto"
2799
+ elif state.task_type == "final-verification":
2800
+ if not state.selected_stage or state.selected_stage == "auto":
2801
+ raise WizardError(
2802
+ "final-verification requires an explicit stage number"
2803
+ )
2804
+ stage = state.selected_stage
2805
+ else:
2806
+ stage = ""
2767
2807
  pr_template = (
2768
2808
  state.pr_template_path
2769
2809
  if state.task_type == "release-handoff"
@@ -2779,7 +2819,7 @@ def render_args(state: WizardState) -> dict[str, str]:
2779
2819
  "executor": state.executor,
2780
2820
  "critic": state.critic,
2781
2821
  "approved-plan": state.approved_plan_path,
2782
- "stage": (state.selected_stage or "auto") if state.task_type in _STAGE_SCOPED_TASK_TYPES else "",
2822
+ "stage": stage,
2783
2823
  "base-ref": base_ref,
2784
2824
  "workers": workers,
2785
2825
  "directive": state.directive,
@@ -2801,7 +2841,9 @@ def confirmation_block(state: WizardState) -> str:
2801
2841
  lines.append(f" task-type : {state.task_type}")
2802
2842
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
2803
2843
  lines.append(f" brief : {state.brief_path or '(none)'}")
2804
- if state.reuse_worktree:
2844
+ if state.task_type == "final-verification":
2845
+ lines.append(" base-ref : (selected stage worktree)")
2846
+ elif state.reuse_worktree:
2805
2847
  lines.append(" base-ref : (reusing existing worktree)")
2806
2848
  else:
2807
2849
  lines.append(f" base-ref : {state.base_ref}")
@@ -2829,7 +2871,11 @@ def confirmation_block(state: WizardState) -> str:
2829
2871
  lines.append(f" critic : {state.critic or '(off)'}")
2830
2872
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
2831
2873
  lines.append(f" approved-plan : {state.approved_plan_path}")
2832
- lines.append(f" stage : {state.selected_stage or 'auto'}")
2874
+ stage = (
2875
+ state.selected_stage
2876
+ or ("auto" if state.task_type == "implementation" else "(not selected)")
2877
+ )
2878
+ lines.append(f" stage : {stage}")
2833
2879
  if state.clarification_response_path:
2834
2880
  lines.append(f" clarification : {state.clarification_response_path}")
2835
2881
  if state.task_type == "release-handoff" and state.pr_template_path:
@@ -373,6 +373,24 @@ def _resolve_snapshot_files(project_root: Optional[Path] = None) -> tuple[str, .
373
373
  )
374
374
 
375
375
 
376
+ def okstra_clean_gate_excludes(project_root: Optional[Path] = None) -> tuple[str, ...]:
377
+ """Project-relative paths okstra owns and source clean gates should ignore."""
378
+ out: list[str] = []
379
+ seen: set[str] = set()
380
+ for rel in (
381
+ ".okstra",
382
+ *_resolve_sync_dirs(project_root),
383
+ *_resolve_sync_files(project_root),
384
+ *_resolve_snapshot_files(project_root),
385
+ ):
386
+ cleaned = rel.strip().removeprefix("./").rstrip("/")
387
+ if not cleaned or cleaned == "." or cleaned in seen:
388
+ continue
389
+ seen.add(cleaned)
390
+ out.append(cleaned)
391
+ return tuple(out)
392
+
393
+
376
394
  def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
377
395
  """Symlink each configured dir from `source_root` (the MAIN
378
396
  worktree) into the new worktree.
@@ -183,6 +183,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
183
183
  # silently (observed in dev-9692 error-analysis: claude/codex workers
184
184
  # dispatched without `name` → both unavailable, report-writer named → fine).
185
185
  unattributed_sessions: list[str] = []
186
+ unattributed_totals: list[dict] = []
186
187
  for sid, path in claude_sessions.items():
187
188
  if sid == lead_sid:
188
189
  lead_path = path
@@ -194,6 +195,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
194
195
  by_agent.setdefault(agent, []).append((sid, path, totals))
195
196
  else:
196
197
  unattributed_sessions.append(sid)
198
+ unattributed_totals.append(totals)
197
199
 
198
200
  # Lead.
199
201
  if lead_path is not None:
@@ -273,6 +275,26 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
273
275
  block["cliNote"] = f"{agent} CLI session found but no usage recorded (likely errored before completion)"
274
276
  worker["usage"] = block
275
277
 
278
+ # Fold team-tagged worker sessions that carry no agentName into the worker
279
+ # pool. They cannot be mapped to a specific workerId (so each named worker
280
+ # row above stays `unavailable`), but the tokens are real team-worker spend —
281
+ # most often an in-process teammate whose work is commingled in a team-tagged
282
+ # session the harness never tagged with `name`. Without this, the run-level
283
+ # Worker total reads 0 and the report validator hard-fails a legitimate run.
284
+ # Attribution is aggregate, not per-worker; usageSummary records it openly.
285
+ unattributed_usage = None
286
+ if unattributed_totals:
287
+ unattributed_usage = usage_block(
288
+ _aggregate_totals(unattributed_totals), source="claude-jsonl"
289
+ )
290
+ unattributed_usage["sessionIds"] = unattributed_sessions
291
+ unattributed_usage["note"] = (
292
+ "Team-tagged worker session(s) with no agentName (dispatched without "
293
+ "the Agent `name` arg, or an in-process teammate commingled with the "
294
+ "lead). Folded into the worker pool as an aggregate because they "
295
+ "cannot be mapped to a specific workerId."
296
+ )
297
+
276
298
  # Aggregate summary.
277
299
  lead = state.get("leadUsage") or {}
278
300
  workers = state.get("workers", [])
@@ -283,6 +305,10 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
283
305
  worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
284
306
  worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
285
307
  cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
308
+ if unattributed_usage is not None:
309
+ worker_total += unattributed_usage.get("totalTokens", 0) or 0
310
+ worker_billable += unattributed_usage.get("billableEquivalentTokens", 0) or 0
311
+ worker_cost += unattributed_usage.get("estimatedCostUsd", 0) or 0
286
312
 
287
313
  # Surface models whose pricing lookup failed so the silent-zero case is visible.
288
314
  unmatched_models: list[str] = []
@@ -312,6 +338,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
312
338
  "sessionsFound": len(claude_sessions),
313
339
  "unmatchedModels": sorted(set(unmatched_models)),
314
340
  "unattributedTeamSessions": unattributed_sessions,
341
+ "unattributedWorkerUsage": unattributed_usage,
315
342
  "definitions": {
316
343
  "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
317
344
  "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
@@ -169,7 +169,7 @@ A reverify dispatch that returns a **terminal non-result** (`timeout`, `error`,
169
169
  Rules:
170
170
 
171
171
  1. For each affected finding, append a `votes[W].verdict = "verification-error"` entry instead of `disagree`, plus the wrapper's captured exit reason in `votes[W].explanation`.
172
- 2. Record one event per failed dispatch via `python3 scripts/okstra-error-log.py append-observed --error-type cli-failure --agent <worker> ...` (the worker wrapper does this for Codex/Gemini; for Claude worker timeouts the lead does it).
172
+ 2. Record one event per failed dispatch via `okstra error-log append-observed --error-type cli-failure --agent <worker> ...` (the worker wrapper does this for Codex/Gemini; for Claude worker timeouts the lead does it).
173
173
  3. Add an entry to the round's `skippedWorkers[]` with `{worker: <W>, reason: "dispatch-non-result", terminalStatus: <timeout|error|not-run>}`.
174
174
  4. If at least one dispatch was issued AND all reverify dispatches in a round terminate as non-result (mirroring the pseudocode's `len(dispatches) > 0` guard), the round is treated as gate-closed: write `round2SkippedReason: "all-reverify-non-result"` (even if the round in question is round 1 — i.e. round 2 never runs because round 1 produced no usable votes), record one `contract-violation` event per non-result dispatch, and exit the WHILE loop.
175
175
  5. Section 6 (Specialization Lens) of a worker output is OUT of convergence scope per "Convergence scope" above — its absence is NEVER a `verification-error`.
@@ -293,7 +293,7 @@ Assigned worker prompt history path: <Project Root>/<Prompt History Path>
293
293
  2. `team-state-<task-type>-<seq>.json` → `workers[].usage.cliModel` for that role (initial run's actual execution value)
294
294
  3. The `**Model:**` line of the initial Phase 4 prompt for that role (read from its persisted prompt-history file)
295
295
 
296
- If none of the three is available, **abort the reverify dispatch for that role** and record a `contract-violation` event via `okstra-error-log.py append-observed`. Do NOT guess, do NOT fall back to training-data defaults — for codex this would silently produce `o4-mini` instead of the assigned `gpt-5.5`-class model, which is a real bug class observed in production.
296
+ If none of the three is available, **abort the reverify dispatch for that role** and record a `contract-violation` event via `okstra error-log append-observed`. Do NOT guess, do NOT fall back to training-data defaults — for codex this would silently produce `o4-mini` instead of the assigned `gpt-5.5`-class model, which is a real bug class observed in production.
297
297
 
298
298
  For Codex/Gemini wrapper subagents, the `**Model:** <role>, <modelExecutionValue>` line is what their wrapper extracts to pass into the underlying CLI's `--model` flag. Omitting it forces the wrapper to fall back to its own training-data knowledge of the CLI's historical default.
299
299
 
@@ -60,7 +60,7 @@ The prompt MUST include, in this order at the top:
60
60
  before the dispatch is constructed. The worker copies this verbatim
61
61
  into `data.json.meta.reportLanguage`.
62
62
  11. For implementation-planning runs: a literal block listing the 8 required English section headings the validator scans for (`Option Candidates`, `Trade-off`, `Recommended Option`, `Stepwise Execution Order`, `Dependency`, `Validation Checklist`, `Rollback`, `User Approval Request`). The writer must use these exact substrings as section headings (Korean translation in parentheses is allowed).
63
- 12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "python3 scripts/okstra-render-final-report.py <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
63
+ 12. An explicit instruction: `You are the author of TWO files: (a) the final-report data.json at <Result Path>, (b) the worker-results audit file at <Worker Result Path>. After writing the data.json, invoke "okstra render-final-report <Result Path>" via Bash so the markdown sibling is rendered before you return. Do not return the report inline. The validator fails the run when (a)'s schema validation fails, when the rendered markdown is absent, or when (b) is missing.`
64
64
 
65
65
  **Completion detection after dispatch (BLOCKING).** The `Agent(... team_name ...)` call returns `Spawned successfully` immediately; that ack is NOT completion. After dispatching the report-writer (async), Lead MUST detect its completion via the self-scheduled polling protocol in [okstra-team-contract](../okstra-team-contract/SKILL.md) "Worker-completion detection (self-scheduled polling)", polling for the appearance of the data.json (Result Path) and the worker-results file (Worker Result Path) — do NOT restate the algorithm here. Report-writer is a single worker, so the pending set has one entry; the SSOT protocol handles that naturally. Lead MUST NOT treat the `Spawned successfully` ack as completion and MUST NOT end its turn with a prose "waiting for the report" statement; that path stalls the run until the user manually nudges it.
66
66
 
@@ -78,7 +78,7 @@ Except for `release-handoff` (which is single-lead by design and never dispatche
78
78
 
79
79
  1. A Report writer worker dispatch was actually attempted (Agent call was issued).
80
80
  2. The attempt recorded a terminal status of `error`, `timeout`, or `not-run` with a concrete reason (tool error message, timeout duration, or external blocker).
81
- 3. The reason is logged via `okstra-error-log.py append-observed --error-type cli-failure ...` (or `tool-failure` if the failure was internal).
81
+ 3. The reason is logged via `okstra error-log append-observed --error-type cli-failure ...` (or `tool-failure` if the failure was internal).
82
82
 
83
83
  Speculative reasons such as "session resume constraint", "team object no longer exists", or "lead can do it faster" are NOT valid.
84
84
 
@@ -90,7 +90,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
90
90
  2. **Phase 7 step 1 — Token-usage collector with `--substitute-data`** (BLOCKING). One invocation aggregates `leadUsage` / `workers[].usage` / `usageSummary` into team-state AND populates `tokenUsage` + `executionStatus[].totalTokens` etc. in the data.json AND re-invokes the renderer so the sibling markdown carries the real numbers. Skipping the flag ships a markdown full of `--` cells.
91
91
 
92
92
  ```bash
93
- python3 scripts/okstra-token-usage.py \
93
+ okstra token-usage \
94
94
  <runDirectoryPath>/state/team-state-<task-type>-<seq>.json \
95
95
  --write --summary \
96
96
  --substitute-data <runDirectoryPath>/reports/final-report-<task-type>-<seq>.data.json
@@ -100,7 +100,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
100
100
  3. **Phase 7 step 1.5 — Render report views** (BLOCKING, conditional output). Always invoke the renderer; it decides whether an html sibling is warranted:
101
101
 
102
102
  ```bash
103
- python3 scripts/okstra-render-report-views.py \
103
+ okstra render-views \
104
104
  <runDirectoryPath>/reports/final-report-<task-type>-<seq>.md
105
105
  ```
106
106
 
@@ -112,7 +112,7 @@ The four steps below MUST execute in this exact order. Reordering them is the re
112
112
  4. **Phase 7 step 2 — Follow-up task spawner** (BLOCKING when Section 4 is non-empty). Turns the report's `## 4. Follow-up Tasks (후속 작업)` rows into `tasks/<task-group>/<new-task-id>/` stubs.
113
113
 
114
114
  ```bash
115
- python3 scripts/okstra-spawn-followups.py \
115
+ okstra spawn-followups \
116
116
  <runDirectoryPath>/reports/final-report-<task-type>-<seq>.data.json \
117
117
  --project-root <project_root> \
118
118
  --task-group <task-group> \
@@ -323,7 +323,7 @@ Persistence steps that must be performed in Phase 7:
323
323
  - [ ] 5. **Update task-index.md**: Refresh human-readable summary
324
324
  - [ ] 6. **Generate final status file**: `runs/<task-type>/status/final-<task-type>-<seq>.status` (if necessary)
325
325
  - [ ] 7. **Save convergence state**: `runs/<task-type>/state/convergence-<task-type>-<seq>.json` (when convergence is enabled)
326
- - [ ] 8. **Spawn follow-up task stubs**: run `scripts/okstra-spawn-followups.py` against the final-report per the canonical spawn rule defined in "Phase 7 follow-up task spawner" above. Do not restate the trigger condition here — that section is the single source of truth. The script is idempotent across reruns.
326
+ - [ ] 8. **Spawn follow-up task stubs**: run `okstra spawn-followups` against the final-report per the canonical spawn rule defined in "Phase 7 follow-up task spawner" above. Do not restate the trigger condition here — that section is the single source of truth. The script is idempotent across reruns.
327
327
  - [ ] 9. **Human HTML report** (conditional): `runs/<task-type>/reports/final-report-<task-type>-<seq>.html` — produced by Phase 7 step 1.5 **only when the report has ≥1 §5 `C-*` clarification row** (self-contained, embeds `Export user response` button). Clarification-free reports legitimately have no html sibling; do not treat its absence as a missing artifact.
328
328
 
329
329
  ### Response after Persistence
@@ -303,7 +303,7 @@ Schema:
303
303
  Workers MUST omit `source` / `recordedAt` / `agent` / `agentRole` / `model` /
304
304
  `taskKey`. Claude lead fills those in when dumping the sidecar to the
305
305
  run-level errors log (`runs/<task-type>/logs/errors-<task-type>-<seq>.jsonl`)
306
- via `scripts/okstra-error-log.py append-from-worker`.
306
+ via `okstra error-log append-from-worker`.
307
307
 
308
308
  Workers MUST use only `errorType: "tool-failure"` in the **sidecar file**.
309
309
 
@@ -312,7 +312,7 @@ run-level errors log path or their sidecar path from the
312
312
  `runs/<task-type>/...` template syntax. Both absolute paths are delivered
313
313
  by Lead via two dispatch-prompt header lines:
314
314
 
315
- - `**Errors log path:** <absolute path>` — run-level JSONL (`okstra-error-log.py append-observed --out ...`)
315
+ - `**Errors log path:** <absolute path>` — run-level JSONL (`okstra error-log append-observed --out ...`)
316
316
  - `**Errors sidecar path:** <absolute path>` — per-worker JSON (`{ "schemaVersion": 1, "errors": [...] }`)
317
317
 
318
318
  Lead obtains both paths from the launch prompt's `## Run Logs (error-log
@@ -322,7 +322,7 @@ without proceeding — this is the contractual replacement for the previous
322
322
  "derive from template placeholders" behavior, which silently produced
323
323
  empty run-level error logs in production.
324
324
 
325
- - `cli-failure` events are recorded by the wrapper subagent itself (Codex / Gemini), but **directly to the run-level error log** via `okstra-error-log.py append-observed --error-type cli-failure ...` — NOT via the sidecar. The sidecar is an in-process tool-failure channel only.
325
+ - `cli-failure` events are recorded by the wrapper subagent itself (Codex / Gemini), but **directly to the run-level error log** via `okstra error-log append-observed --error-type cli-failure ...` — NOT via the sidecar. The sidecar is an in-process tool-failure channel only.
326
326
  - **Wrapper invocation arity.** Both `okstra-codex-exec.sh` and `okstra-gemini-exec.sh` accept four required positional arguments plus an optional fifth `<role>`: `<project-root> <model> <prompt-path> <worktree-path> [<role>]`. The fourth (worktree) argument is **mandatory for implementation phase** and optional otherwise. For codex it becomes `--add-dir <worktree>` (sandbox write access); for gemini it is appended to `--include-directories`. Omitting it during implementation causes the codex sandbox to reject every Edit/Write targeting the worktree with EPERM. Workers extract the path from the `**Worktree:**` / `EXECUTOR_WORKTREE_PATH` / `cwd for every mutating command:` line in the lead prompt. The optional fifth `<role>` is folded into both the caller (worker) pane title `<cli>-<role>-<pid>` and the sibling trace-pane title `<cli>-<role>-<pid>-trace[from=<caller-pane-id>]` (e.g. `codex-worker-93421` ↔ `codex-worker-93421-trace[from=%5]`). `<pid>` is the wrapper's own PID and disambiguates concurrent dispatches of the same role; the embedded caller pane id keeps the trace ↔ worker correlation visible even when the worker pane's title is overwritten by the parent process (Claude Code's TUI emits OSC 2 title escape sequences on its own pane). Always pass the literal string `worker` so the dispatch is self-describing (the wrapper defaults to `worker` if omitted).
327
327
  - **Background dispatch + polling contract (Codex / Gemini wrappers).** Both wrapper subagents MUST dispatch `okstra-codex-exec.sh` / `okstra-gemini-exec.sh` via `Bash(run_in_background: true)` and poll with `BashOutput(bash_id)` until the shell reports `status == "completed"`, capped at 30 minutes (1800s) of wall-clock elapsed time. `BashOutput` itself is the wait primitive — call it back-to-back; do NOT insert a standalone `sleep` between polls. The Claude Code harness blocks `sleep` calls of 5 seconds or longer as a circumvention vector and explicitly forbids chaining shorter sleeps inside until-loops to work around the block. Workers that hit the contract bug must NOT self-recover with `until ...; do sleep 2; done` wrappers — that path violates the harness anti-circumvention rule, even though it superficially "works". The legacy "single foreground `Bash` with 120000ms timeout" rule, and the subsequent "60-second cadence with `sleep 60` between polls" rule, are both retired. The current rule applies in **every phase** (analysis runs typically complete in 1–2 `BashOutput` calls, so there is no regression for short jobs). Recording responsibilities:
328
328
  - Successful completion: return the wrapper's accumulated stdout from the final `BashOutput`. No log entry.
@@ -330,8 +330,8 @@ empty run-level error logs in production.
330
330
  - Polling cap reached: before `KillShell`, perform a one-shot **mtime-grace check** on the wrapper's live log (`<prompt>.log`). If the log was written within the last 90 seconds AND grace has not yet been applied this loop, extend the cap from 1800s → 2100s (one-shot +5min) and continue polling. Otherwise (log stale, OR grace already applied), call `KillShell(shell_id)`, record `cli-failure` with `--exit-code 124 --duration-ms <observed_ms> --message "<wrapper> exceeded polling cap (grace=<applied|not-applied>, last_mtime_age=<n>s)"`, then return the language-specific `*_CLI_TIMEOUT` sentinel. The grace exists to absorb token-budget spikes where the CLI is genuinely still producing output past the 30-minute mark; it is a one-shot soft extension, NOT a loop.
331
331
  - Token-usage matching is unaffected: the wrapper subagent stays alive throughout polling, so the wrapper's jsonl timestamp window continues to cover the underlying CLI rollout's full duration (see §"Token-usage accounting" below).
332
332
  - **No external timeout on wrapper subagents.** The codex/gemini wrapper subagent's polling loop (with optional mtime grace) is the SINGLE timeout authority for its dispatch. Lead MUST NOT impose a separate `Agent()` call timeout, an outer `Bash` wall-clock deadline, or any other mechanism that terminates the subagent before its own polling cap is reached. Doing so reproduces the historical failure mode that motivated this rule: Lead aborts the subagent at e.g. 18 minutes, the subagent returns nothing, and Lead classifies the role as "no response" while the underlying CLI was actively working. The wrapper's polling cap (30min + optional 5min grace) is calibrated so that, combined with Lead's redispatch policy (see "Lead Redispatch Policy on Result-Missing"), a recoverable single-run failure costs at most ~70 minutes of wall-clock — predictable enough to plan around. If a specific run requires a tighter cap, lower it in the wrapper subagent's polling contract (single source of truth), NOT by layering Lead-side timeouts.
333
- - `contract-violation` events (C) are recorded by Lead via `okstra-error-log.py append-observed --error-type contract-violation ...` after inspecting worker outputs.
334
- - Lead's responsibility regarding the sidecar is to dump it to the run-level error log via `okstra-error-log.py append-from-worker` after each worker terminates; Lead does not write into the sidecar.
333
+ - `contract-violation` events (C) are recorded by Lead via `okstra error-log append-observed --error-type contract-violation ...` after inspecting worker outputs.
334
+ - Lead's responsibility regarding the sidecar is to dump it to the run-level error log via `okstra error-log append-from-worker` after each worker terminates; Lead does not write into the sidecar.
335
335
 
336
336
  ## Convergence Phase Rules
337
337
 
@@ -621,7 +621,7 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
621
621
  failures.append(
622
622
  f"Token Usage Summary row `{label_cell or '<unlabeled>'}` has "
623
623
  f"a zero value `{stripped}` — no okstra run consumes zero "
624
- "tokens. Re-run `python3 scripts/okstra-token-usage.py "
624
+ "tokens. Re-run `okstra token-usage "
625
625
  "<team-state> --write --summary --substitute-data "
626
626
  "<report-path>` to repopulate from session jsonls. The "
627
627
  "Codex/Gemini CLI row is the only place `$0.00` is "
@@ -1024,7 +1024,7 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
1024
1024
  if not summary or not summary.get("collectedAt"):
1025
1025
  failures.append(
1026
1026
  "team-state.usageSummary is empty — Phase 7 token-usage collection was skipped. "
1027
- "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
1027
+ "Run `okstra token-usage <team-state> --write --summary "
1028
1028
  "--substitute-data <final-report>`."
1029
1029
  )
1030
1030
  return
@@ -1,6 +1,58 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve as resolvePath } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { buildPythonpath, resolvePaths } from "./paths.mjs";
3
6
 
7
+ function resolveInstalledScript(paths, scriptName) {
8
+ // Prefer the installed copy under ~/.okstra/bin (what production users run);
9
+ // fall back to the in-repo source when invoked from a checkout that has not
10
+ // been installed (dev / CI).
11
+ const installed = join(paths.bin, scriptName);
12
+ if (existsSync(installed)) return installed;
13
+ const repoRoot = fileURLToPath(new URL("..", import.meta.url));
14
+ const dev = resolvePath(repoRoot, "scripts", scriptName);
15
+ return existsSync(dev) ? dev : null;
16
+ }
17
+
18
+ // Thin spawn shim shared by every `okstra <cmd>` subcommand that fronts a
19
+ // `scripts/okstra-*.py` entry point. Centralizing it keeps PYTHONPATH wiring
20
+ // and installed/dev resolution in one place so skills call `okstra <cmd>`
21
+ // instead of emitting `python3 "$HOME/..."` (which breaks `Bash(okstra:*)`
22
+ // permission matching and prompts on every call).
23
+ export async function runInstalledScript({ scriptName, args, usage, emptyArgsCode = 2 }) {
24
+ if (args.length === 0) {
25
+ process.stdout.write(usage);
26
+ return emptyArgsCode;
27
+ }
28
+ // Only a bare `--help` / `-h` prints the wrapper usage. A `--help` that
29
+ // follows a subcommand (e.g. `error-log append-observed --help`) must reach
30
+ // the python helper so its own per-subcommand help shows through.
31
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
32
+ process.stdout.write(usage);
33
+ return 0;
34
+ }
35
+ const paths = await resolvePaths();
36
+ const entry = resolveInstalledScript(paths, scriptName);
37
+ if (!entry) {
38
+ process.stderr.write(
39
+ `error: ${scriptName} not found — run 'okstra install' (or 'okstra ensure-installed') first\n`,
40
+ );
41
+ return 1;
42
+ }
43
+ return await new Promise((resolve) => {
44
+ const child = spawn("python3", [entry, ...args], {
45
+ stdio: "inherit",
46
+ env: { ...process.env, PYTHONPATH: buildPythonpath(paths) },
47
+ });
48
+ child.on("error", (err) => {
49
+ process.stderr.write(`error: failed to spawn python3: ${err.message}\n`);
50
+ resolve(1);
51
+ });
52
+ child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
53
+ });
54
+ }
55
+
4
56
  export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
5
57
  const paths = await resolvePaths();
6
58
  return new Promise((resolve) => {
@@ -0,0 +1,19 @@
1
+ import { runInstalledScript } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra error-log — append okstra run error events to the run error log
4
+
5
+ Wraps the python helper (\`okstra-error-log.py\`) installed under
6
+ \`~/.okstra/bin/\` so skills and worker wrappers call \`okstra error-log\`
7
+ instead of emitting a \`python3 "$HOME/..."\` invocation (which breaks
8
+ \`Bash(okstra:*)\` permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra error-log <subcommand> [...] # e.g. append-observed / append-from-worker
12
+
13
+ All arguments are forwarded verbatim to the python helper. See
14
+ \`okstra error-log append-observed --help\` for the full option list.
15
+ `;
16
+
17
+ export async function run(args) {
18
+ return runInstalledScript({ scriptName: "okstra-error-log.py", args, usage: USAGE });
19
+ }
@@ -0,0 +1,22 @@
1
+ import { runInstalledScript } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra inject-report-index — add the top-of-report Index + scroll anchors to a report
4
+
5
+ Wraps the python helper (\`okstra-inject-report-index.py\`) installed under
6
+ \`~/.okstra/bin/\` so skills call \`okstra inject-report-index\` instead of
7
+ emitting a \`python3 "$HOME/..."\` invocation (which breaks \`Bash(okstra:*)\`
8
+ permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra inject-report-index <markdown-path> [--report-language <en|ko>]
12
+
13
+ All arguments are forwarded verbatim to the python helper.
14
+ `;
15
+
16
+ export async function run(args) {
17
+ return runInstalledScript({
18
+ scriptName: "okstra-inject-report-index.py",
19
+ args,
20
+ usage: USAGE,
21
+ });
22
+ }
@@ -0,0 +1,22 @@
1
+ import { runInstalledScript } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra render-final-report — render the markdown sibling of a final-report data.json
4
+
5
+ Wraps the python helper (\`okstra-render-final-report.py\`) installed under
6
+ \`~/.okstra/bin/\` so skills call \`okstra render-final-report\` instead of
7
+ emitting a \`python3 "$HOME/..."\` invocation (which breaks \`Bash(okstra:*)\`
8
+ permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra render-final-report <path-to-final-report.data.json>
12
+
13
+ The argument is forwarded verbatim to the python helper.
14
+ `;
15
+
16
+ export async function run(args) {
17
+ return runInstalledScript({
18
+ scriptName: "okstra-render-final-report.py",
19
+ args,
20
+ usage: USAGE,
21
+ });
22
+ }
@@ -1,18 +1,14 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { resolve as resolvePath } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { resolvePaths } from "./paths.mjs";
1
+ import { runInstalledScript } from "./_python-helper.mjs";
6
2
 
7
- const USAGE = `okstra render-views — render slim AI + self-contained HTML views of a final-report
3
+ const USAGE = `okstra render-views — render the self-contained HTML view of a final-report
8
4
 
9
5
  A thin spawn shim over \`scripts/okstra-render-report-views.py\` (installed
10
6
  at \`$HOME/.okstra/bin/okstra-render-report-views.py\`). Reads the final-
11
- report MD and writes two siblings:
7
+ report MD and writes a single sibling:
12
8
 
13
- <stem>.slim.md — token-saving AI consumption copy
14
9
  <stem>.html — single-file self-contained human view with form
15
- controls on §5 clarification rows
10
+ controls on §5 clarification rows (skipped when the
11
+ report has no §5 C-* clarification rows)
16
12
 
17
13
  Usage:
18
14
  okstra render-views <path-to-final-report.md>
@@ -23,45 +19,10 @@ When the optional flags are omitted the script infers from the report
23
19
  path and its '- Task Type:' / '- Task Key:' lines.
24
20
  `;
25
21
 
26
- function resolveEntrypoint(paths) {
27
- // Prefer the installed copy under ~/.okstra/bin (what production users
28
- // see); fall back to the in-repo dev source when running from a
29
- // checkout that hasn't been installed.
30
- const installed = resolvePath(paths.home, "bin", "okstra-render-report-views.py");
31
- if (existsSync(installed)) return installed;
32
- const here = fileURLToPath(new URL("..", import.meta.url));
33
- const dev = resolvePath(here, "scripts", "okstra-render-report-views.py");
34
- if (existsSync(dev)) return dev;
35
- return null;
36
- }
37
-
38
22
  export async function run(args) {
39
- if (args.includes("--help") || args.includes("-h")) {
40
- process.stdout.write(USAGE);
41
- return 0;
42
- }
43
- if (args.length === 0) {
44
- process.stderr.write("error: missing <path-to-final-report.md>\n");
45
- process.stderr.write(USAGE);
46
- return 2;
47
- }
48
- const paths = await resolvePaths();
49
- const entry = resolveEntrypoint(paths);
50
- if (!entry) {
51
- process.stderr.write(
52
- "error: okstra-render-report-views.py not found. " +
53
- "Run `okstra install` to install the runtime.\n",
54
- );
55
- return 1;
56
- }
57
- return await new Promise((res) => {
58
- const child = spawn("python3", [entry, ...args], {
59
- stdio: ["ignore", "inherit", "inherit"],
60
- });
61
- child.on("error", (err) => {
62
- process.stderr.write(`error: ${err.message}\n`);
63
- res(1);
64
- });
65
- child.on("close", (code) => res(code ?? 0));
23
+ return runInstalledScript({
24
+ scriptName: "okstra-render-report-views.py",
25
+ args,
26
+ usage: USAGE,
66
27
  });
67
28
  }
@@ -0,0 +1,23 @@
1
+ import { runInstalledScript } from "./_python-helper.mjs";
2
+
3
+ const USAGE = `okstra spawn-followups — create follow-up task bundles from a final report
4
+
5
+ Wraps the python helper (\`okstra-spawn-followups.py\`) installed under
6
+ \`~/.okstra/bin/\` so skills call \`okstra spawn-followups\` instead of
7
+ emitting a \`python3 "$HOME/..."\` invocation (which breaks \`Bash(okstra:*)\`
8
+ permission matching and prompts on every call).
9
+
10
+ Usage:
11
+ okstra spawn-followups <args...>
12
+
13
+ All arguments are forwarded verbatim to the python helper. See
14
+ \`okstra spawn-followups --help\` for the full option list.
15
+ `;
16
+
17
+ export async function run(args) {
18
+ return runInstalledScript({
19
+ scriptName: "okstra-spawn-followups.py",
20
+ args,
21
+ usage: USAGE,
22
+ });
23
+ }
@@ -1,7 +1,4 @@
1
- import { spawn } from "node:child_process";
2
- import { join } from "node:path";
3
- import { promises as fs } from "node:fs";
4
- import { resolvePaths } from "./paths.mjs";
1
+ import { runInstalledScript } from "./_python-helper.mjs";
5
2
 
6
3
  const USAGE = `okstra token-usage — collect token usage for a run
7
4
 
@@ -15,37 +12,9 @@ Usage:
15
12
  okstra token-usage <state-file> [--write] [--summary] [...]
16
13
 
17
14
  Arguments and flags after the state-file path are forwarded verbatim to
18
- the python helper. See \`python3 ~/.okstra/bin/okstra-token-usage.py --help\`
19
- for the full option list.
15
+ the python helper. See \`okstra token-usage --help\` for the full option list.
20
16
  `;
21
17
 
22
18
  export async function run(args) {
23
- if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
24
- process.stdout.write(USAGE);
25
- return args.length === 0 ? 2 : 0;
26
- }
27
-
28
- const paths = await resolvePaths();
29
- const script = join(paths.bin, "okstra-token-usage.py");
30
-
31
- try {
32
- await fs.access(script);
33
- } catch {
34
- process.stderr.write(
35
- `error: ${script} not found — run 'okstra install' (or 'okstra ensure-installed') first\n`,
36
- );
37
- return 1;
38
- }
39
-
40
- return await new Promise((resolve) => {
41
- const child = spawn("python3", [script, ...args], {
42
- stdio: "inherit",
43
- env: { ...process.env, PYTHONPATH: paths.pythonpath },
44
- });
45
- child.on("error", (err) => {
46
- process.stderr.write(`error: failed to spawn python3: ${err.message}\n`);
47
- resolve(1);
48
- });
49
- child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
50
- });
19
+ return runInstalledScript({ scriptName: "okstra-token-usage.py", args, usage: USAGE });
51
20
  }