okstra 0.166.2 → 0.168.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 (25) hide show
  1. package/README.md +1 -1
  2. package/docs/pr-template-usage.md +10 -6
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +27 -1
  6. package/runtime/prompts/coding-preflight/clean-code.md +13 -0
  7. package/runtime/prompts/profiles/_implementation-diff-review.md +2 -0
  8. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  9. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  10. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  12. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  13. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  14. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  15. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
  16. package/runtime/python/okstra_ctl/consumers.py +26 -8
  17. package/runtime/python/okstra_ctl/dispatch_core.py +47 -0
  18. package/runtime/python/okstra_ctl/dispatch_state.py +6 -0
  19. package/runtime/python/okstra_ctl/doctor.py +60 -0
  20. package/runtime/python/okstra_ctl/pr_template.py +3 -6
  21. package/runtime/python/okstra_ctl/team.py +2 -1
  22. package/runtime/python/okstra_ctl/worker_audit_ledger.py +6 -0
  23. package/runtime/skills/okstra-setup/references/project-config.md +1 -1
  24. package/runtime/validators/validate-run.py +56 -0
  25. /package/runtime/templates/{prd → pr}/pr-body.template.md +0 -0
package/README.md CHANGED
@@ -225,7 +225,7 @@ Major workflow changes added to `main` after 0.8.0:
225
225
 
226
226
  - **Automatic isolated worktrees for every task type** — During preparation, `okstra-ctl` runs `git worktree add ~/.okstra/worktrees/<project-id>/<task-group-segment>/<task-id-segment>` once per task key to create an isolated working tree and a `<work-category-namespace>/<task-id-segment>` branch (for example, `feature/dev-9436` or `fix/dev-7311`). The user chooses the base ref with `--base-ref`, using the same choices as the release-handoff PR base picker: `main`, `dev`, `staging`, `preprod`, `prod`, or a custom value. It is required in the first phase; the okstra-run skill collects it through `AskUserQuestion`, while non-interactive callers must pass `--base-ref` explicitly. Later **non-`implementation`** phases for the same task key (`requirements-discovery` → `error-analysis` → `implementation-planning` → `final-verification` → `release-handoff`) reuse the same path and branch. `implementation` runs are **stage-isolated**: each run executes one stage in its own `.../<task>/stage-<N>/` worktree on a `<work-category-namespace>/<task>-s<N>` branch, so independent stages with `depends-on (none)` can run concurrently without sharing a tree. The registry reserves both task keys and **stage keys** with flock. Provisioning is skipped when the caller is already in another worktree or project_root is not a Git repository; stage isolation degrades to a flat path in those cases. Manual cleanup: `git worktree remove <path>` → `git branch -D <branch>` plus release/removal of the registry entry. Details: [`docs/architecture.md`](docs/architecture.md), in the *Task type* section, and [`docs/cli.md#--executor`](docs/cli.md#--executor).
227
227
  - **`release-handoff` lifecycle phase** — runs immediately after `final-verification` returns `verdict=accepted`. The current Okstra lead drafts the candidate messages and PR body inline, then uses the selected host adapter's user-prompt operation for the delivery choices. Only the Git/GitHub CLI commands selected through those menus are run. Force pushes, direct pushes to the base branch, hook bypasses (`--no-verify`), and release publication (`gh release`, `npm publish`, and similar commands) are prohibited. This phase does not edit source code. Profile: [`prompts/profiles/release-handoff.md`](prompts/profiles/release-handoff.md).
228
- - **Configurable PR body template** (release-handoff) — PR bodies are populated from a Markdown template selected in this order: one-time override (`--pr-template-path` or the okstra-run Step 6 prompt) → `prTemplatePath` in `<project_root>/.okstra/project.json` → `prTemplatePath` in `~/.okstra/config.json` → the skill default at `~/.claude/skills/templates/prd/pr-body.template.md`. Register a template with `okstra config set pr-template-path <path> [--scope project|global]`; project scope accepts a path relative to the project root, while global scope requires an absolute path or a path beginning with `~/`. `okstra config get pr-template-path --scope all` prints every scoped value and the effective winner. The default template contains `## Summary`, `## Changes`, `## Test plan`, and `## Linked issues`, plus HTML comment guidance that the lead removes immediately before PR creation.
228
+ - **Configurable PR body template** (release-handoff) — PR bodies are populated from a Markdown template selected in this order: one-time override (`--pr-template-path` or the okstra-run Step 6 prompt) → `prTemplatePath` in `<project_root>/.okstra/project.json` → `prTemplatePath` in `~/.okstra/config.json` → the installed default at `~/.okstra/templates/pr/pr-body.template.md`. Register a template with `okstra config set pr-template-path <path> [--scope project|global]`; project scope accepts a path relative to the project root, while global scope requires an absolute path or a path beginning with `~/`. `okstra config get pr-template-path --scope all` prints every scoped value and the effective winner. The default template contains `## Summary`, `## Changes`, `## Test plan`, and `## Linked issues`, plus HTML comment guidance that the lead removes immediately before PR creation.
229
229
  - **Profile worker-roster validation** — `--workers <csv>` and the okstra-run Step 6 worker prompt accept only the worker IDs declared in the selected profile's `Required workers:` block. Requesting a worker absent from the profile—for example, `codex` or `antigravity` for `release-handoff`—fails with a clear error, and the interactive prompt shows only workers accepted by that profile.
230
230
  - **Host-aware lead adapters** — `okstra-run` resolves the current harness through the same dynamic host registry used by the terminal front door. Claude Code, Codex, Antigravity, Grok, and Kimi keep their matching provider assignment native; `external` remains the explicit all-CLI host. Host and provider are separate axes, and every non-native worker assignment uses its provider's registered CLI wrapper. `leadAssignment` and every `workerAssignments[]` row record provider, model, and `runner`. `okstra codex-run` and `okstra codex-dispatch` remain low-level artifact/dispatch commands.
231
231
  - **Multi-stage `implementation-planning` / `implementation`** — `implementation-planning` always produces a Stage Map and N stage sections. Each stage has no more than six steps, and stages with `depends-on (none)` can be implemented concurrently in separate `implementation` runs. Each `implementation` invocation runs a single stage, selected with `--stage <auto|N>`, and creates an evidence sidecar at `carry/stage-<N>.json` for automatic carry-in to the next stage. The `implementation-planning` run directory accumulates `consumers.jsonl` reverse links that record which run consumed each stage.
@@ -13,22 +13,26 @@ Higher priority matches first. Once an upper step matches, the lower steps are n
13
13
  | 1 | **per-run override** | `okstra render-bundle --pr-template-path <path>` or the wizard's one-time input | A relative path is resolved against the caller cwd (override) or against `project_root` (using the same function as project scope). |
14
14
  | 2 | **project scope** | the `prTemplatePath` field in `<project_root>/.okstra/project.json` | A relative path is resolved against `project_root`. |
15
15
  | 3 | **global scope** | the `prTemplatePath` field in `~/.okstra/config.json` | **Only an absolute path or a `~/`-prefixed path** is allowed. A relative path is ambiguous and rejected. |
16
- | 4 | **default (skill bundle)** | the first existing file among the candidate paths (§2 below) | The fallback path right after `npx okstra install`. |
16
+ | 4 | **default (installed runtime)** | the first existing file among the candidate paths (§2 below) | The fallback path right after `npx okstra install`. |
17
17
 
18
18
  If the file named in any of the 4 steps does not exist, it fails immediately with `PrTemplateError` (no silent fallback).
19
19
 
20
20
  ## 2. Default candidate paths
21
21
 
22
- 1. `$OKSTRA_SKILLS_DIR/templates/prd/pr-body.template.md` only when the `OKSTRA_SKILLS_DIR` environment variable is set.
23
- 2. `~/.claude/skills/templates/prd/pr-body.template.md` — the standard location `npx okstra install` installs to.
22
+ | # | Path | When it exists |
23
+ |---|------|----------------|
24
+ | 1 | `$OKSTRA_SKILLS_DIR/okstra-run/templates/pr-body.template.md` | Only when the `OKSTRA_SKILLS_DIR` environment variable is set. Nothing in okstra sets it for you — it is an escape hatch for a non-standard skill home. |
25
+ | 2 | `$OKSTRA_HOME/templates/pr/pr-body.template.md` — `~/.okstra/templates/pr/pr-body.template.md` unless `OKSTRA_HOME` overrides the home | The location `npx okstra install` writes to. This is the candidate that normally matches. |
24
26
 
25
- The candidates are tried in priority order, and if all are absent it ends with an explicit error as follows.
27
+ The candidates are tried in priority order, and if all are absent it ends with an explicit error that names every path it searched:
26
28
 
27
- > `no PR template available: default skill template not found. Reinstall okstra (npx okstra install) or set prTemplatePath in project.json / ~/.okstra/config.json.`
29
+ ```text
30
+ no PR template available: default template not found. Searched: <candidate paths>. Reinstall okstra (`npx okstra install`) or set prTemplatePath in project.json / ~/.okstra/config.json.
31
+ ```
28
32
 
29
33
  ## 3. The original inside the source repository
30
34
 
31
- - [`templates/prd/pr-body.template.md`](../templates/prd/pr-body.template.md) — the original that `npx okstra install` copies to the §2 default location. To change the copy, edit this file and install again.
35
+ - [`templates/pr/pr-body.template.md`](../templates/pr/pr-body.template.md) — the original that `npx okstra install` copies to `~/.okstra/templates/pr/pr-body.template.md` (§2 candidate 2). To change the copy, edit this file and install again.
32
36
 
33
37
  ## 4. Configuration commands — persistence
34
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.166.2",
3
+ "version": "0.168.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.166.2",
3
- "builtAt": "2026-08-11T13:40:27.396Z",
2
+ "package": "0.168.0",
3
+ "builtAt": "2026-08-12T10:32:35.419Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -133,7 +133,33 @@ When a change adds or modifies a service's dependency on a concrete adapter —
133
133
 
134
134
  The verdict is mechanical: this change adds or modifies such a dependency → finding; the injection sits entirely on lines this change did not touch → clean.
135
135
 
136
- **An existing convention does not clear this one.** A codebase that injects concrete `*Repository` classes everywhere is precisely the debt this rule pays down, one touched injection at a time — matching the surrounding style is the condition being flagged, not a defence against it. Record it, say so in the note (*"matches existing convention — advisory"*), and propose the port: its name and the two or three method signatures it would declare — unless the project declares `architecture.style = hexagonal` in `.okstra/project.json`, where this item is blocking and that same port sketch is what you write rather than what you record: fix the injection before the write, never record-and-pass. This is the single rule in this overlay where a project-local convention does not override the pack; every other conflict still resolves in the project's favour.
136
+ **An existing convention does not clear this one.** A codebase that injects concrete `*Repository` classes everywhere is precisely the debt this rule pays down, one touched injection at a time — matching the surrounding style is the condition being flagged, not a defence against it. Record it, say so in the note (*"matches existing convention — advisory"*), and propose the port: its name and the two or three method signatures it would declare — unless the project declares `architecture.style = hexagonal` in `.okstra/project.json`, where this item is blocking and that same port sketch is what you write rather than what you record: fix the injection before the write, never record-and-pass.
137
+
138
+ ---
139
+
140
+ ## Rule H6 — Business rules live in the domain, not in application services
141
+
142
+ A service orchestrates: fetch, delegate the decision to a domain function, persist, publish. Flag changed service code that *decides* a business outcome inline instead of calling into the domain.
143
+
144
+ Violations:
145
+
146
+ - An `if` / `else` chain over domain fields that decides an outcome — eligibility, validity, which branch of a business process runs.
147
+ - Arithmetic expressing a business formula: money, ranking, quota, date-based entitlement.
148
+ - A private service method whose name is a domain concept (`isEligible…`, `computeDowngrade…`, `resolve…Status`). The name is telling you where it belongs.
149
+
150
+ Not a violation:
151
+
152
+ - Orchestration control flow — early return on not-found, `try` / `finally` around a transaction, threading a transaction through repository calls.
153
+ - DTO → domain mapping.
154
+ - Calling a domain predicate and acting on its result. That IS the pattern.
155
+
156
+ The test: **if this decision changed, would the change be described as a business rule change or as a plumbing change?** A business rule change that lands in a service is the violation — the rule is now invisible to the domain's own tests, and the next caller that needs it writes its own copy.
157
+
158
+ Severity: `should-fix`, and blocking when the embedded rule is substantial — money, permissions, or a state machine. Under a declared `architecture.style = hexagonal` the substantial case is fixed before the write, never record-and-pass.
159
+
160
+ ---
161
+
162
+ **How far a project convention reaches.** H5 above is the one rule in this overlay a project-local convention cannot clear. Elsewhere here, a documented convention does resolve the conflict in the project's favour — but that latitude stops at okstra's own gates. It never clears a check the implementation verifier's blocking list declares **mechanical**: a changed file under the domain folder importing an ORM / DB layer, a framework or its DI decorators, or anything under adapters / infrastructure / services is decided by reading the import list, and no convention argument reaches that verdict (`prompts/profiles/_implementation-verifier.md` §"Static design & test-quality review" → Hexagonal). Resolving a declared-mechanical finding as "project convention" is the failure this paragraph exists to prevent — a real run did exactly that, returned `clean`, and the team's own PR review then flagged the same import.
137
163
 
138
164
  Severity: advisory in a project that has not declared `architecture.style = hexagonal` — a direction-of-travel rule there, not a correctness gate, so it never blocks on its own. Under that declaration it is blocking: the injection is fixed before the write.
139
165
 
@@ -314,6 +314,19 @@ A new mock, state setter, or repository branch that no test calls is unfinished
314
314
 
315
315
  A change to memory use, concurrency, batching, or stream handling is not covered by a functional test that passes on a small input. Add a test that pins the bound the change claims to hold — peak size, concurrent count, chunk count.
316
316
 
317
+ ## Trace what this change can do wrong
318
+
319
+ The rules above name defect shapes. A defect with no name on this list is still a defect, and the ones that reach production usually have no name — they are ordinary code that produces a wrong result for one input nobody walked.
320
+
321
+ For every source file this change touches, follow the paths the change creates or alters to their end, and state where a wrong result comes out:
322
+
323
+ - **error** — what the caller sees when each new call fails, and whether that is distinguishable from the other failures it must be told apart from.
324
+ - **partial** — the change succeeded halfway; what is left written, and what the next run sees.
325
+ - **concurrent** — something else is still writing, or the deadline fired and the work did not stop.
326
+ - **selection** — when several candidates fail, which one's evidence survives.
327
+
328
+ **A finding names the input or state that produces the wrong result.** *"If the archive yields no entries, line 42 reports success and stores an empty result"* is a finding. Code that works as written is `clean`, however you would have written it differently: alternative structures, guards for states no caller can reach, extra tests for covered paths, and "consider extracting / renaming / memoizing" are improvements, not findings. A real failure that is small and cheap to fix is still a finding.
329
+
317
330
  ## No magic numbers
318
331
 
319
332
  Replace hardcoded values with named constants.
@@ -34,6 +34,8 @@ Do not scan holistically and stop when it "looks fine". Work the matrix exhausti
34
34
  - a file that decides, mutates, or persists state → `clean-code.md` "Mutation and state boundaries": decide on the direct identifier rather than a status/flag proxy, capture before-state in one snapshot ahead of the mutating boundary, update only this work's owned fields on an existing row, re-read state before calling a zero-affected-rows write success or failure, put priority-between-inputs in a named domain function, and keep error messages to what was actually observed. Also check that no state union/enum was re-declared beside an authoritative one the domain or a dependency exports.
35
35
  - test file (`*.spec.*` / `*.test.*` / `test_*.py` / `*_test.go` …) → `clean-code.md` "Testing discipline": no self-mocking of the SUT, behavioral (outcome) assertions not interaction-only, no tautological delegation assertion, no effect claimed under its own mock, shared-fixture defaults left on the ordinary path, setup values that actually separate the scenarios, every new test helper/mock used by a test in this same diff, no positional mock-argument access (`rg 'mock\.calls'`), every branch this diff adds covered by a test that fails when the branch body is deleted, assertions on the last write to a record rather than an intermediate one, and test titles naming their unit plus the single condition each case isolates.
36
36
  - port / adapter / domain file, when the hexagonal overlay is loaded → `architectures/hexagonal.md`: no business logic in a port body, adapter methods are I/O only (no post-fetch filtering on domain state, no `findValid*`/`findActive*` names hiding a rule), domain objects declared under the domain boundary, no changed domain file importing an ORM / framework / adapter / service (read the import list — mechanical), and a service dependency you add or modify goes through a port rather than a concrete adapter (advisory only while the project has not declared `architecture.style = hexagonal` in `.okstra/project.json` — record it with the port sketch; blocking under that declaration, so fix it in place before the commit rather than record-and-pass, exactly as `_implementation-verifier.md` re-grades this same diff. Either way, the codebase already injecting concrete classes is the debt this pays down, not a reason to skip it).
37
+ - a service file, when the hexagonal overlay is loaded → `architectures/hexagonal.md` Rule H6: a decision this diff puts in a service — an `if`/`else` chain over domain fields, a business formula, a private method named for a domain concept — belongs in a domain function. Orchestration control flow, DTO mapping and calling a domain predicate are not violations.
38
+ - every changed source file → `clean-code.md` "Trace what this change can do wrong": walk the error, partial, concurrent and selection paths this diff creates to their end and fix wherever a wrong result comes out. Name the input that produces it — if you cannot name one, there is nothing to fix here and you move on rather than restructuring code that works.
37
39
  A file can hold several roles — apply every rule set that fits it.
38
40
  3. **Decide clean-or-finding for each cell.** Read the full file when a rule needs context (never judge a port/adapter/domain or a naming rule from the hunk alone).
39
41
  4. **Fix each finding in place** before the commit. When a readability finding is real, the fix is a named helper or named intermediate value — sketch the cleaner shape (a few lines) in your audit note, then apply it. When the fix is genuinely out of this stage's scope, record it as an `Out-of-plan` note instead of silently leaving it.
@@ -63,7 +63,7 @@ persisted prompt lacks the heading `Coding-conventions preflight`
63
63
 
64
64
  ## Stage execution contract (this run owns one stage)
65
65
 
66
- - **Sidecar evidence writer (BLOCKING).** When this stage's Stage Validation `post` commands all succeed, the Executor MUST emit a JSON object with **exactly** these fields (spec `docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md` §3.2), as a fenced ```json``` block in the worker result under the heading `### Stage Carry Evidence`, and the lead MUST persist it to `runs/<impl-task-key>/carry/stage-<N>.json`. The schema is inlined here because a CLI-wrapper executor runs in its own process and never receives a native worker definition — a carry emitted with an ad-hoc shape (`stage`/`files`/`validation`) is a `contract-violated` gap the lead must rewrite by hand:
66
+ - **Sidecar evidence writer (BLOCKING).** You emit the evidence; you do NOT write the file. `carry/stage-<N>.json` sits inside your task scope, so nothing stops your tools from creating it — but the lead's post-stage persistence is what writes it, and only on a non-`FAIL` verdict. Your own gate passes before the verifier's, so a file you write exists exactly when the verdict may still withhold it: the lead then refuses to overwrite, the report says the sidecar was withheld, and the disk disagrees with the record. Emit the block and stop. When this stage's Stage Validation `post` commands all succeed, the Executor MUST emit a JSON object with **exactly** these fields (spec `docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md` §3.2), as a fenced ```json``` block in the worker result under the heading `### Stage Carry Evidence`, and the lead MUST persist it to `runs/<impl-task-key>/carry/stage-<N>.json`. The schema is inlined here because a CLI-wrapper executor runs in its own process and never receives a native worker definition — a carry emitted with an ad-hoc shape (`stage`/`files`/`validation`) is a `contract-violated` gap the lead must rewrite by hand:
67
67
 
68
68
  ```json
69
69
  {
@@ -164,7 +164,9 @@ Re-running commands proves the diff *builds and passes*; it does NOT prove the d
164
164
  - **Interaction-only assertion:** a test whose only/primary assertion is `toHaveBeenCalled*` / `toHaveBeenCalledTimes` on an internal helper or a non-side-effecting collaborator, with no assertion on the returned value / resulting state / persisted row / emitted event.
165
165
  - **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.
166
166
  - **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*`).
167
- - **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; a changed file under the domain folder importing an ORM / DB layer, a framework or its DI decorators, or anything under adapters / infrastructure / services (read the import list this verdict is mechanical, not a judgment).
167
+ - **Wrong-result trace (every changed source file):** follow the paths this diff creates or alters to their end error, partial, concurrent, selection — and name the input or state that produces a wrong result (`clean-code.md` §"Trace what this change can do wrong"). This one is an obligation, not a pattern: the defects that reach production are usually ordinary code with no name on this list. The bar is also the noise filter a finding names the failing input; an alternative structure, a guard for a state no caller reaches, or a "consider extracting" is an improvement and verdicts `clean`. **The unit is the changed source file**: record `clean` or findings for each one, list every file you excluded with its reason (lockfiles, generated code, pure config), and close the section with `general: <N> files — all verdicted, <M> excluded`.
168
+ - **Business rules in an application service (only when the hexagonal overlay is loaded):** changed service code that decides a business outcome inline — an `if`/`else` chain over domain fields, a business formula in arithmetic, a private method named for a domain concept — instead of calling a domain function (`architectures/hexagonal.md` Rule H6). Orchestration control flow, DTO mapping, and calling a domain predicate are not violations. Blocking when the embedded rule is substantial: money, permissions, a state machine.
169
+ - **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; a changed file under the domain folder importing an ORM / DB layer, a framework or its DI decorators, or anything under adapters / infrastructure / services (read the import list — this verdict is mechanical, not a judgment). **The routed pack's project-convention latitude does not reach this cell.** `architectures/hexagonal.md` lets a documented convention resolve conflicts in the project's favour elsewhere in that overlay; here the import list decides, and "matches existing convention" is not an answer to it (see that file's §"How far a project convention reaches"). A run that resolved exactly this finding as project convention returned `clean` and the team's PR review flagged the same import.
168
170
  - **gitignored file committed to the branch:** any path in the `git diff --name-only <base>...HEAD` enumeration that `.gitignore` excludes — enumerate them by piping that list through `git check-ignore --stdin --no-index`. A committed ignored file means the executor bulk-added (`git add .`/`-A`) or force-staged (`git add -f`) it, leaking build output, scratch files, or verification artifacts into the eventual PR. This explicitly includes `.okstra/` paths (and `.project-docs/` when the legacy symlink is present): `.okstra/**` is gitignored, so a committed okstra file (qa scripts, conformance results) is always this defect. Cite each 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.
169
171
  - **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/scripts/` 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/scripts/` (or declaring it as a Tier 3 conformance script). Mock-only unit tests in source are NOT a hit.
170
172
  - **Proxy-based identity decision:** a move / ownership / re-parenting decision taken from a status field or flag while the source and destination identifiers were available and never compared. Cite the condition and the identifiers it should have compared, and show the opposite case the condition also reads true for.
@@ -77,7 +77,7 @@ Render every numbered item as its option label followed by its description verba
77
77
  | `read_artifacts` | Read the manifest-provided paths through the current Antigravity host file interface. |
78
78
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
79
79
  | `prompt_user` | Ask through the current host text/question interface and stop at approval gates until an explicit answer arrives. |
80
- | `dispatch_worker` | Dispatch every `runner=native-session` Antigravity assignment through the current host. Dispatch every `runner=cli-wrapper` assignment through its registered provider wrapper. |
80
+ | `dispatch_worker` | Dispatch every `runner=native-session` Antigravity assignment through the current host. Dispatch every `runner=cli-wrapper` assignment through its registered provider wrapper. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
81
81
  | `await_workers` | Await native host workers through the host primitive and CLI workers through their status sidecars, then verify terminal state and Result Paths. |
82
82
  | `redispatch_worker` | Start a fresh native worker or CLI wrapper attempt according to the persisted assignment and record the supplied dispatch kind. |
83
83
  | `shutdown_workers` | Perform host or process cleanup only for resources owned by this run. |
@@ -136,7 +136,7 @@ For a `host-text` mapping, render each numbered item as its option label followe
136
136
  | `read_artifacts` | Use the host file-read primitive and preserve the core contract's read order. |
137
137
  | `write_artifact` | Use the host file-write primitive only for paths authorized by the active lifecycle phase. |
138
138
  | `prompt_user` | Use the native question tool for approvals and clarifications; do not infer an answer from silence. |
139
- | `dispatch_worker` | Dispatch each assignment through `Agent(name: "<role>", run_in_background: true)` without `team_name`; use an in-process worker for `runner=native-session` and the assigned provider's wrapper worker for `runner=cli-wrapper`. |
139
+ | `dispatch_worker` | Dispatch each assignment through `Agent(name: "<role>", run_in_background: true)` without `team_name`; use an in-process worker for `runner=native-session` and the assigned provider's wrapper worker for `runner=cli-wrapper`. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
140
140
  | `await_workers` | Arm one background shell poll for the pending Result Paths; the spawn acknowledgement is not completion. |
141
141
  | `redispatch_worker` | Dispatch a fresh `Agent(...)` session with the same prompt plus the core reverify/retry reason. |
142
142
  | `shutdown_workers` | For each confirmed-complete worker selected for cleanup, send `SendMessage(to: <name>, message: { type: "shutdown_request" })` to idle the roster member **and** call `TaskStop(task_id: "<name>")` to stop its background task. Both are required; neither subsumes the other. |
@@ -77,7 +77,7 @@ Render every numbered item as its option label followed by its description verba
77
77
  | `read_artifacts` | Read the manifest-provided paths through the current host's file interface. |
78
78
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
79
79
  | `prompt_user` | Ask through the host text/question interface and stop at approval gates until an explicit answer arrives. |
80
- | `dispatch_worker` | Dispatch every `runner=native-session` assignment with the current Codex host's native worker/session primitive. Pass only `runner=cli-wrapper` assignments to `okstra codex-dispatch --project-root <root> --run-manifest <path> --workers <ids>`; use `--dry-run` first when the core requires a dispatch preview. |
80
+ | `dispatch_worker` | Dispatch every `runner=native-session` assignment with the current Codex host's native worker/session primitive. Pass only `runner=cli-wrapper` assignments to `okstra codex-dispatch --project-root <root> --run-manifest <path> --workers <ids>`; use `--dry-run` first when the core requires a dispatch preview. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
81
81
  | `await_workers` | Await native host workers through the host primitive and CLI workers through synchronous dispatch, then verify team-state terminal records and Result Paths for both. |
82
82
  | `redispatch_worker` | Start a fresh native worker or `okstra codex-dispatch` attempt according to the persisted assignment's `runner`, and record the retry/reverify dispatch kind. |
83
83
  | `shutdown_workers` | Perform process cleanup when a wrapper remains live; otherwise this operation is a no-op recorded in state. |
@@ -77,7 +77,7 @@ Render every numbered item as its option label followed by its description verba
77
77
  | `read_artifacts` | Read the manifest-provided paths through the current host's file or shell interface. |
78
78
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
79
79
  | `prompt_user` | Ask through the host text/question interface and require an explicit approval or clarification response. |
80
- | `dispatch_worker` | Run `okstra team dispatch --project-root <root> --run-manifest <path>`; use `--dry-run` first when the core requires a dispatch preview. |
80
+ | `dispatch_worker` | Run `okstra team dispatch --project-root <root> --run-manifest <path>`; use `--dry-run` first when the core requires a dispatch preview. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
81
81
  | `await_workers` | Run `okstra team await --project-root <root> --run-manifest <path>` through the host's asynchronous shell facility. |
82
82
  | `redispatch_worker` | Create the core-specified fresh jobs file and dispatch it with a new `dispatchKind`; never reuse a live worker conversation. |
83
83
  | `shutdown_workers` | Run `okstra team teardown --project-root <root> --run-manifest <path>` only after the user-approved cleanup gate. |
@@ -76,7 +76,7 @@ Render every numbered item as its option label followed by its description verba
76
76
  | `read_artifacts` | Read the manifest-provided paths through the current Grok host file interface. |
77
77
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
78
78
  | `prompt_user` | Ask through the current host text interface and wait for an explicit answer. |
79
- | `dispatch_worker` | Follow each persisted assignment's `runner` and use the common host dispatch boundary. |
79
+ | `dispatch_worker` | Follow each persisted assignment's `runner` and use the common host dispatch boundary. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
80
80
  | `await_workers` | Await through the selected common dispatch backend, then verify terminal state and Result Paths. |
81
81
  | `redispatch_worker` | Start a fresh attempt from the persisted assignment and record the supplied dispatch kind. |
82
82
  | `shutdown_workers` | Clean up only host or process resources owned by this run. |
@@ -76,7 +76,7 @@ Render every numbered item as its option label followed by its description verba
76
76
  | `read_artifacts` | Read the manifest-provided paths through the current Kimi host file interface. |
77
77
  | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
78
78
  | `prompt_user` | Ask through the current host text interface and wait for an explicit answer. |
79
- | `dispatch_worker` | Follow each persisted assignment's `runner` and use the common host dispatch boundary. |
79
+ | `dispatch_worker` | Follow each persisted assignment's `runner` and use the common host dispatch boundary. **Not in a cmux run:** when the run manifest's `terminalBackend` is `cmux-pane`, `prompts/lead/adapters/cmux.md` overrides this row — start every worker with `okstra team dispatch`, this host's native path included, so okstra owns the panes and the user can watch the work. |
80
80
  | `await_workers` | Await through the selected common dispatch backend, then verify terminal state and Result Paths. |
81
81
  | `redispatch_worker` | Start a fresh attempt from the persisted assignment and record the supplied dispatch kind. |
82
82
  | `shutdown_workers` | Clean up only host or process resources owned by this run. |
@@ -27,6 +27,11 @@ CONSUMERS_FILENAME = "consumers.jsonl"
27
27
  # carrying a confirmed regression as complete.
28
28
  STAGE_LIFECYCLE_STATUSES = ("started", "done", "failed")
29
29
 
30
+ # The lead has already ruled on a stage whose last row is one of these — `done`
31
+ # closed it, `failed` deliberately did not. Neither may be re-derived from disk:
32
+ # closing a stage is the lead's verdict gate, not a file's existence.
33
+ _LEAD_SETTLED_STATUSES = ("done", "failed")
34
+
30
35
 
31
36
  @dataclass(frozen=True)
32
37
  class StageConsumerState:
@@ -308,16 +313,29 @@ def _carry_dir(plan_run_root: Path) -> Path:
308
313
  def backfill_done_from_carry(plan_run_root: Path) -> int:
309
314
  """Recover missing `done` rows from carry sidecars (carry is SSOT).
310
315
 
311
- For every `runs/implementation/carry/stage-<N>.json` that is complete and
312
- not already recorded as `done` in consumers.jsonl, append a `done` row with
313
- the head commit read from the carry. Returns the number of rows recovered.
314
- Stages with no carry or an unfinished carry are skipped, so the dependency
315
- gate still legitimately blocks genuinely-unstarted stages."""
316
+ Recovers the crash window between the lead writing the carry file and
317
+ appending the `done` row: for every `runs/implementation/carry/stage-<N>.json`
318
+ that is complete and whose stage has no terminal row yet, append a `done` row
319
+ with the head commit read from the carry. Returns the number of rows
320
+ recovered. Stages with no carry or an unfinished carry are skipped, so the
321
+ dependency gate still legitimately blocks genuinely-unstarted stages.
322
+
323
+ A stage the lead has already ruled on is never re-derived from disk. That
324
+ includes `failed`: the executor emits its carry evidence when its own Tier 1/2
325
+ validation passes, which happens BEFORE the verifier's gate, so a carry file
326
+ exists for exactly the runs where the verifier then returned `FAIL`. Reading
327
+ the file as completion would promote a stage carrying a confirmed regression
328
+ to `done` and let its dependents proceed — and it would contradict this
329
+ module's own rule that a stage's position is its last lifecycle row."""
316
330
  carry_dir = _carry_dir(plan_run_root)
317
331
  if not carry_dir.is_dir():
318
332
  return 0
319
333
  existing = read_consumers(plan_run_root)
320
- done_stages = {r.get("stage") for r in existing if r.get("status") == "done"}
334
+ settled_stages = {
335
+ stage
336
+ for stage, status in last_lifecycle_status_by_stage(existing).items()
337
+ if status in _LEAD_SETTLED_STATUSES
338
+ }
321
339
  key_by_stage: Dict[Any, str] = {}
322
340
  fallback_key = ""
323
341
  for r in existing:
@@ -335,7 +353,7 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
335
353
  if not isinstance(carry, dict):
336
354
  continue
337
355
  stage = _carry_stage_number(carry, cf.name)
338
- if stage is None or stage in done_stages:
356
+ if stage is None or stage in settled_stages:
339
357
  continue
340
358
  if not _carry_is_complete(carry):
341
359
  continue
@@ -358,6 +376,6 @@ def backfill_done_from_carry(plan_run_root: Path) -> int:
358
376
  carry_path=carry_path,
359
377
  source="carry-backfill",
360
378
  )
361
- done_stages.add(stage)
379
+ settled_stages.add(stage)
362
380
  recovered += 1
363
381
  return recovered
@@ -31,6 +31,7 @@ from .dispatch_state import (
31
31
  transition_worker_status as _transition_worker_status,
32
32
  string_list as _string_list,
33
33
  string_value as _string_value,
34
+ TEARDOWN_BEFORE_TERMINAL_REASON,
34
35
  utc_now as _utc_now,
35
36
  validate_dispatch_prompts as _validate_dispatch_prompts,
36
37
  worker_jobs_from_file as _worker_jobs_from_file,
@@ -231,6 +232,7 @@ def await_dispatches(
231
232
  timeout_seconds: int | None = None,
232
233
  heartbeat_seconds: int = 30,
233
234
  ) -> int:
235
+ _correct_teardown_marked_dispatches(plan)
234
236
  deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
235
237
  last_heartbeat = 0.0
236
238
  while True:
@@ -643,6 +645,35 @@ def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
643
645
  )
644
646
 
645
647
 
648
+ def _correct_teardown_marked_dispatches(plan: DispatchPlan) -> None:
649
+ """Let the wrapper's own exit settle a record teardown wrote off.
650
+
651
+ `okstra team teardown` marks every non-terminal dispatch `error` — right for a
652
+ worker it killed, wrong for one that had already exited while its record was
653
+ still `running`, which happens when the lead awaited by polling artifacts
654
+ instead of `team await` (the component that transitions the record). The run
655
+ then reports `error` for workers whose wrapper exited 0 with every output in
656
+ place, and nothing could undo it: teardown's `error` drops the record out of
657
+ the running set this function's caller scans, so a later await never saw it.
658
+
659
+ Only a success is corrected, and only from process-level evidence — the
660
+ wrapper's terminal status plus its exit code, judged by the same
661
+ `_outcome_from_status` path every other dispatch goes through. A non-zero exit
662
+ or a missing output stays an error, and no retry is spawned: teardown has
663
+ already taken the panes, so re-running here would start work nobody is
664
+ watching.
665
+ """
666
+ for record in _teardown_marked_dispatches(plan.team_state_path):
667
+ status_path = _optional_path(record.get("statusSidecarPath"))
668
+ status = read_wrapper_status(status_path) if status_path else None
669
+ if status is None or not status.is_terminal:
670
+ continue
671
+ outcome = _outcome_from_status(record, status)
672
+ if outcome.returncode != 0 or outcome.missing_completion_paths or outcome.timeout:
673
+ continue
674
+ _finish_record(plan, record, outcome)
675
+
676
+
646
677
  def _advance_running_dispatches(plan: DispatchPlan, records: Sequence[Mapping[str, Any]]) -> None:
647
678
  for record in records:
648
679
  status_path = _optional_path(record.get("statusSidecarPath"))
@@ -811,6 +842,22 @@ def _update_dispatch_status(
811
842
  _write_json(team_state_path, payload)
812
843
 
813
844
 
845
+ def _teardown_marked_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
846
+ """Records `okstra team teardown` wrote off, matched by its own reason string
847
+ so a genuine dispatch error is never mistaken for one."""
848
+ payload = _load_json_object(team_state_path, "team-state")
849
+ dispatches = payload.get("workerDispatches")
850
+ if not isinstance(dispatches, list):
851
+ return []
852
+ return [
853
+ record
854
+ for record in dispatches
855
+ if isinstance(record, dict)
856
+ and record.get("status") == "error"
857
+ and _string_value(record.get("reason")) == TEARDOWN_BEFORE_TERMINAL_REASON
858
+ ]
859
+
860
+
814
861
  def _running_dispatches(team_state_path: Path) -> list[Mapping[str, Any]]:
815
862
  payload = _load_json_object(team_state_path, "team-state")
816
863
  dispatches = payload.get("workerDispatches")
@@ -58,6 +58,12 @@ def detect_terminal_backend() -> str:
58
58
  LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
59
59
  LIVENESS_WRAPPER_STATUS = "wrapper-status"
60
60
 
61
+ # `okstra team teardown` stamps this on a dispatch it wrote off, and a later
62
+ # `okstra team await` matches it to tell those apart from a genuine dispatch
63
+ # error before letting the wrapper's own exit settle them. Two sides of one fact,
64
+ # so the string lives here rather than in whichever module wrote it first.
65
+ TEARDOWN_BEFORE_TERMINAL_REASON = "teardown before terminal status"
66
+
61
67
  # The worktree argument grants the wrapper `--add-dir` write access outside
62
68
  # project-root. Only the phases whose workers mutate a stage worktree get it;
63
69
  # analysis phases write their artifacts under project-root (see the contract in
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import os
5
6
  import subprocess
6
7
  from dataclasses import asdict, dataclass
7
8
  from pathlib import Path
@@ -89,9 +90,68 @@ def _project_checks(cwd: Path) -> tuple[Path | None, list[DoctorCheck]]:
89
90
  f"{project_json} not found — run okstra setup in this project first",
90
91
  )
91
92
  )
93
+ architecture = _architecture_declaration_check(project_root)
94
+ if architecture is not None:
95
+ checks.append(architecture)
92
96
  return project_root, checks
93
97
 
94
98
 
99
+ _LAYOUT_SCAN_SKIP = frozenset(
100
+ {".git", ".okstra", "node_modules", "dist", "build", "coverage", "__pycache__"}
101
+ )
102
+
103
+
104
+ def _ports_and_adapters_marker(project_root: Path) -> str:
105
+ """The first ports-and-adapters layout signal found, or "".
106
+
107
+ Only the two signals the pack states mechanically (`architectures/hexagonal.md`
108
+ Stage 3 row): a `ports/` directory beside a `domain/` one, and a `*.port.*`
109
+ file. The other two — NestJS hex split, abstract classes at a boundary — are
110
+ worker judgment and are deliberately not guessed at here.
111
+ """
112
+ for current, dirnames, filenames in os.walk(project_root):
113
+ dirnames[:] = [d for d in dirnames if d not in _LAYOUT_SCAN_SKIP]
114
+ here = set(dirnames)
115
+ if "ports" in here and "domain" in here:
116
+ return str(Path(current).relative_to(project_root) / "ports")
117
+ for name in filenames:
118
+ if ".port." in name:
119
+ return str(Path(current).relative_to(project_root) / name)
120
+ return ""
121
+
122
+
123
+ def _architecture_declaration_check(project_root: Path) -> DoctorCheck | None:
124
+ """Report placement rules running advisory because no style was declared.
125
+
126
+ The routed coding-preflight pack promotes its placement rules from advisory to
127
+ blocking only under `architecture.style` in `project.json`
128
+ (`architectures/hexagonal.md` Severity). With the key absent they stay advisory,
129
+ a verifier records `architecture-style: none`, and the run reads as fully gated
130
+ — the downgrade is invisible to both lead and user. A project that genuinely is
131
+ not ports-and-adapters silences this by declaring `none`, which is why the raw
132
+ value is read here: `resolve_architecture()` maps absent and `none` alike.
133
+ """
134
+ try:
135
+ payload = json.loads(project_json_path(project_root).read_text(encoding="utf-8"))
136
+ except (OSError, ValueError):
137
+ return None
138
+ architecture = payload.get("architecture") if isinstance(payload, dict) else None
139
+ declared = architecture.get("style") if isinstance(architecture, dict) else None
140
+ if isinstance(declared, str) and declared.strip():
141
+ return _ok("architecture style", f"declared: {declared.strip()}")
142
+ marker = _ports_and_adapters_marker(project_root)
143
+ if not marker:
144
+ return None
145
+ return _ok(
146
+ "architecture style",
147
+ f"not declared, but the layout shows a ports-and-adapters signal ({marker}) "
148
+ "— placement rules run advisory, so a misplaced domain object or a concrete "
149
+ "adapter injection is recorded rather than blocking. Declare "
150
+ '`architecture: {"style": "hexagonal"}` in .okstra/project.json to gate '
151
+ 'them, or `"none"` to say the layout is not ports-and-adapters.',
152
+ )
153
+
154
+
95
155
  def _profile_check(workspace: Path, phase: str) -> DoctorCheck:
96
156
  profile = _profile_path(workspace, phase)
97
157
  if profile.is_file():
@@ -6,8 +6,8 @@ release-handoff 단계에서 lead 가 PR 본문을 작성할 때 사용하는
6
6
  1. per-run override (okstra-run Step 6 에서 입력)
7
7
  2. project: <project_root>/.okstra/project.json 의 ``prTemplatePath``
8
8
  3. global: ~/.okstra/config.json 의 ``prTemplatePath``
9
- 4. default: ``$OKSTRA_HOME/templates/prd/pr-body.template.md`` (구버전
10
- ``~/.claude/skills/...`` 후보는 backward-compat 유지)
9
+ 4. default: ``$OKSTRA_SKILLS_DIR/okstra-run/templates/pr-body.template.md``
10
+ (env 설정된 경우에만) → ``$OKSTRA_HOME/templates/pr/pr-body.template.md``
11
11
 
12
12
  경로는 절대경로 또는 ``~`` 시작 경로를 권장한다. 상대경로일 경우 project
13
13
  스코프는 ``project_root`` 기준, override 는 호출자 cwd 기준으로 해석한다.
@@ -41,10 +41,7 @@ def _default_template_candidates() -> list[Path]:
41
41
  env_dir = os.environ.get("OKSTRA_SKILLS_DIR", "").strip()
42
42
  if env_dir:
43
43
  out.append(Path(env_dir) / "okstra-run" / "templates" / _DEFAULT_FILENAME)
44
- out.append(okstra_home() / "templates" / "prd" / _DEFAULT_FILENAME)
45
- out.append(
46
- Path.home() / ".claude" / "skills" / "okstra-run" / "templates" / _DEFAULT_FILENAME
47
- )
44
+ out.append(okstra_home() / "templates" / "pr" / _DEFAULT_FILENAME)
48
45
  return out
49
46
 
50
47
 
@@ -19,6 +19,7 @@ from . import tmux
19
19
  from .adapters.dispatch import provider_worker_wrappers
20
20
  from .adapters.dispatch.cmux import dispatch_port_for_terminal_backend
21
21
  from .application.dispatch_assignments import dispatch_assignments
22
+ from .dispatch_state import TEARDOWN_BEFORE_TERMINAL_REASON
22
23
  from .dispatch_core import (
23
24
  BACKEND_CMUX_PANE,
24
25
  BACKEND_TMUX_PANE,
@@ -256,7 +257,7 @@ def _mark_teardown_errors(team_state_path: Path) -> None:
256
257
  for record in payload.get("workerDispatches", []):
257
258
  if isinstance(record, dict) and record.get("status") not in _TERMINAL_STATUSES:
258
259
  record["status"] = "error"
259
- record["reason"] = "teardown before terminal status"
260
+ record["reason"] = TEARDOWN_BEFORE_TERMINAL_REASON
260
261
  _write_json(team_state_path, payload)
261
262
 
262
263
 
@@ -55,6 +55,12 @@ def _looks_like_file_path(path: str) -> bool:
55
55
  return False
56
56
  if re.fullmatch(r"[0-9a-fA-F]{7,64}", path):
57
57
  return False
58
+ # A dotted name is the usual filename signal, and an IPv4 literal is dots and
59
+ # digits too. `127.0.0.1:13306` in a Read-only command log is a host and port —
60
+ # the worker cannot record an Evidence read of it, so reading it as a citation
61
+ # fails the run over something it has no way to satisfy.
62
+ if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}", path):
63
+ return False
58
64
  return (
59
65
  "/" in path
60
66
  or "." in Path(path).name
@@ -155,7 +155,7 @@ overwriting silently.
155
155
  ## D. Project PR body template (release-handoff)
156
156
 
157
157
  `release-handoff` fills the PR body from a template — by default
158
- `~/.okstra/templates/prd/pr-body.template.md`. Most projects want their own
158
+ `~/.okstra/templates/pr/pr-body.template.md`. Most projects want their own
159
159
  (e.g. `.github/PULL_REQUEST_TEMPLATE.md`). Pre-registration during setup is
160
160
  opt-in; the same prompt is offered again on the first `release-handoff` run,
161
161
  so deferring (`Later`) is safe.
@@ -693,6 +693,58 @@ def _is_legal_concurrent_run_skip(
693
693
  )
694
694
 
695
695
 
696
+ def _validate_cmux_workers_were_dispatched_by_okstra(
697
+ team_state: dict,
698
+ workers: list,
699
+ dispatched_statuses: set[str],
700
+ failures: list[str],
701
+ ) -> None:
702
+ """Under cmux, okstra owns the worker panes — so it must have started them.
703
+
704
+ `prompts/lead/adapters/cmux.md` overrides the host relay's worker-dispatch
705
+ mapping: every lead goes through `okstra team dispatch`, a Claude Code lead
706
+ included. A lead that follows its own relay instead and starts the worker
707
+ in-process gets no pane, and the user cannot see the work — which is the whole
708
+ point of the adapter owning them.
709
+
710
+ The tell is the absent record, not the backend: `okstra team` appends a
711
+ `workerDispatches[]` row for every worker it starts, and an in-process worker
712
+ leaves none. Backend alone cannot separate the two, because a pane dispatch
713
+ that degrades legitimately records `backendType: cli-wrapper`
714
+ (`dispatch_core._dispatch_record`). A worker that was never attempted has no
715
+ row either, so only attempted statuses are checked.
716
+ """
717
+ adapter = team_state.get("leadAdapter")
718
+ if not isinstance(adapter, dict):
719
+ return
720
+ if str(adapter.get("name", "")).strip() != "cmux":
721
+ return
722
+ recorded = {
723
+ str(row.get("workerId", "")).strip()
724
+ for row in team_state.get("workerDispatches") or []
725
+ if isinstance(row, dict)
726
+ }
727
+ missing = []
728
+ for worker in workers:
729
+ if not isinstance(worker, dict):
730
+ continue
731
+ if str(worker.get("status", "")).strip() not in dispatched_statuses:
732
+ continue
733
+ worker_id = str(worker.get("workerId", "")).strip()
734
+ if worker_id and worker_id not in recorded:
735
+ missing.append(worker_id)
736
+ if missing:
737
+ failures.append(
738
+ "team-state.workerDispatches has no record for "
739
+ f"{', '.join(sorted(missing))} in a cmux run — those workers ran "
740
+ "without okstra starting them, so they had no pane and the user "
741
+ "could not watch the work. Under cmux every lead dispatches through "
742
+ "`okstra team dispatch`, a Claude Code lead included "
743
+ "(prompts/lead/adapters/cmux.md); the host relay's in-process "
744
+ "`Agent(...)` mapping does not apply to this run."
745
+ )
746
+
747
+
696
748
  def validate_team_state(
697
749
  team_state: dict,
698
750
  project_root: Path,
@@ -777,6 +829,10 @@ def validate_team_state(
777
829
  f"Found: `{tc_status}`."
778
830
  )
779
831
 
832
+ _validate_cmux_workers_were_dispatched_by_okstra(
833
+ team_state, workers, dispatched_statuses, failures
834
+ )
835
+
780
836
  by_role: dict[str, dict] = {}
781
837
  for worker in workers:
782
838
  if not isinstance(worker, dict):