okstra 0.163.0 → 0.163.2

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.
@@ -294,6 +294,7 @@ Important modules:
294
294
  | `manager_sync.py` | One-way child project `.okstra` snapshot reader; corrupt child state becomes row-level `error` so other children continue |
295
295
  | `manager_launch.py` | Child launch packet and manager child context renderer; records `prepared` launch metadata/events without changing project-local task state |
296
296
  | `dispatch_core.py` | Backend-neutral worker dispatch core — worker execution/collection logic shared by any lead runtime (Claude/Codex/Antigravity/external); gates selected initial prompts through the shared cross-task contract before launch |
297
+ | `cmux.py` | cmux-pane worker backend — mirrors the tmux backend's contract (a worker that gets a pane frees the lead process; anything that stops a pane opening degrades quietly to the blocking wrapper, recording a surface UUID rather than a tmux pane id). Detects a usable cmux session before selecting the backend (CLI resolves + ping answers PONG + the lead's workspace is resolvable), derives placement from the workspace geometry each dispatch, relays lead/worker events to the cmux sidebar, and records the run's terminal backend in the manifest so both phases of a run land on one backend. A sandbox that hides cmux (`PermissionError` on the socket) stops dispatch with the remedy instead of degrading into the same broken fallback; a quit app (`FileNotFoundError`) still degrades |
297
298
  | `codex_dispatch.py` | Codex lead CLI-worker dispatcher — the `okstra codex-dispatch` backend. Reads the run manifest to run the Codex-side supported worker subset, applies the same cross-task initial-prompt gate, and performs token-usage substitution, view render, follow-up, and validation |
298
299
  | `analysis_packet.py` | assembles the compact analysis-worker input packet for a task run from worker-owned profile sections; report/lead procedure stays outside the packet |
299
300
  | `analysis_inputs.py` | shared input boundary for `project-analysis`, `feature-analysis`, and `change-impact-analysis` — validates evidence-report identity and review status, enforces the type-to-type relation allowlist, computes `exact`/`stale` freshness, and resolves free-text or `PF-NNN` feature targets for both wizard and prepare paths |
@@ -431,7 +432,7 @@ Boilerplate shared by several skills (bash invocation rule, outdated-CLI preflig
431
432
  | `agents/workers/kimi-worker.params.json` | Kimi read-only analyser/critic wrapper params |
432
433
  | `agents/workers/report-writer-worker.md` | data.json SSOT author and audit sidecar writer |
433
434
 
434
- The neutral lead lifecycle contract lives at `prompts/lead/okstra-lead-contract.md`. Host mappings live under `prompts/lead/adapters/`: `claude-code.md`, `codex.md`, `antigravity.md`, and `external.md`. All are runtime resources installed under `~/.okstra/prompts/lead/`, not agent skills.
435
+ The neutral lead lifecycle contract lives at `prompts/lead/okstra-lead-contract.md`. Host mappings live under `prompts/lead/adapters/`: `claude-code.md`, `codex.md`, `antigravity.md`, `external.md`, and `cmux.md` (the environment-selected cmux worker backend, read by every task type regardless of lead runtime). All are runtime resources installed under `~/.okstra/prompts/lead/`, not agent skills.
435
436
 
436
437
  ### 4.12 `tests/` and `tests-e2e/`
437
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.163.0",
3
+ "version": "0.163.2",
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.163.0",
3
- "builtAt": "2026-08-09T07:22:48.148Z",
2
+ "package": "0.163.2",
3
+ "builtAt": "2026-08-09T12:57:56.931Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -200,4 +200,7 @@
200
200
  9. **Cross-project dependency check** — confirm you have not missed a dependency on another repo / another top-level deployable module / a published package. If `dependencyMigrationRisk` has a `kind: cross-project` row, confirm a matching `direction: upstream-precondition` `XP-NNN` row exists in `crossProjectDependencies`, and re-read as a reviewer whether its `requiredWork` is the concrete work the other side must actually build rather than an abstract phrase ("other side's work done") — validator S only checks existence, so concreteness is the self-review's responsibility. Confirm cross-repo work is split into a separate run + XP row instead of being crammed into one task's stages, and that the cross-project substance is not duplicated in `§3 Recommended Next Steps` but lives only in `§5.4 Cross-Project Dependencies`.
201
201
  10. **Decision-draft materialization check** — when `decisionDrafts` is non-empty, confirm as a reviewer which stage's stepwise order contains the matching materialization step (creating `.okstra/decisions/<NNNN>-<slug>.md`) and that the number of drafts corresponds 1:1 with the materialization steps. The validator only checks the *existence* of the step, so the `<NNNN>-<slug>` correctness and count correspondence are the self-review's responsibility.
202
202
  11. **Variation-point & seam check** — read `variationPointAnalysis` as a skeptic. Is `hasMultipleImplementations` honest against the brief and the sibling code you inspected during pre-planning, or was `false` chosen because it is the cheaper field to fill? For every point with `extract: true`, confirm the `extractionDecision` names a real interface (a `port` for a hexagonal project, not a shared helper) and a `coveredBy` stage that exists in the Stage Map — an interface no stage builds is a decision nobody executes. Then read the recommended option's `testSeams`: each `injectedAs` must name a construction or wiring point a test can actually substitute at, not a symbol the test would have to re-implement — a seam nothing can be injected into leaves the executor writing self-mocks. An empty `testSeams` array is only acceptable when you can defend it in one sentence; the validator accepts it either way, so this is the check that catches an unfilled field posing as a decision.
203
- 12. **Approval blast-radius check (BLOCKING).** Every `Blocks=approval` clarification row must be reachable *from* the plan, not only *into* it: at least one `planItems[]` entry carrying that id as `clarificationId`, or one `requirementCoverage` row blocked on it in `status` or `approvalDisposition`. Item 7 covers only rows this run promoted from a majority-disagree plan item; a blocker raised any other way can still withhold approval while recording nothing it affects. The cost lands on the re-run: `okstra incremental-scope` resolves impacted stages from exactly these two links and treats an id that traces to no stage as grounds to re-verify every stage, so one unlinked blocker turns an incremental re-run into a full one. **Enforced:** `validators/validate-run.py` `_validate_approval_clarification_backtrace`.
203
+ 12. **Approval blast-radius check (BLOCKING).** Every `Blocks=approval` clarification row must be reachable *from* the plan, not only *into* it: at least one `planItems[]` entry carrying that id as `clarificationId`, or one `requirementCoverage` row blocked on it in `status` or `approvalDisposition`. Item 7 covers only rows this run promoted from a majority-disagree plan item; a blocker raised any other way can still withhold approval while recording nothing it affects. The cost lands on the re-run: `okstra incremental-scope` resolves impacted stages from exactly these two links and treats an id that traces to no stage as grounds to re-verify every stage, so one unlinked blocker turns an incremental re-run into a full one.
204
+ - **The link must resolve to a stage, not merely exist.** `incremental-scope` reads the stage number out of a `P-Step-<stage>.<step>` / `P-Prep-S<stage>-<kind>` plan-item id, or out of a `Stage N` citation in the blocked coverage row's `coveredBy`. Every other plan-item prefix (`P-Req-*`, `P-Val-*`, `P-Opt-*`, `P-Dep-*`, `P-Rb-*`) is numbered by position in its own array and carries no stage, so a blocker linked only that way MUST also have its coverage row cite the stage in `coveredBy`. Writing the blocked row's `coveredBy` as prose with no `Stage N` in it — `No stage.`, `Partly covered — …` — satisfies nothing: the row passes the link check and the re-run still re-verifies everything.
205
+ - What to write when no stage covers the requirement yet: name the stage the answer will change, not the stage that satisfies the requirement today. A `Blocks=approval` row is admissible only when, absent an answer, `implementation` would produce wrong or unsafe code (see the admissibility rule above) — so some stage's code is at stake by construction. If you genuinely cannot name one, the row fails the admissibility test and belongs in `## 5. Missing Information and Risks` with `Blocks=none`, not in the approval gate.
206
+ **Enforced:** `validators/validate-run.py` `_validate_approval_clarification_backtrace` — one failure for a missing link, a separate one for a link that resolves to no stage.
@@ -589,9 +589,12 @@
589
589
  "worktree_impl_new": " worktree : stage {stage} 새 worktree `{path}` (브랜치 `{branch}`, base 는 run 준비 시 해소)",
590
590
  "worktree_impl_reuse": " worktree : 기존 stage {stage} worktree `{path}` (브랜치 `{branch}`)",
591
591
  "worktree_impl_auto": " worktree : stage 자동 선택 — `{path}` 아래 stage-<N>/ worktree 생성/재사용",
592
+ "clarification_sidecars_empty": " user-responses: 없음 — final-report 만 첨부됩니다",
593
+ "clarification_sidecars_attached": " user-responses: 사이드카 {files}개 · 답변 {count}개 함께 첨부 — {ids}",
594
+ "clarification_sidecars_none_parsed": "답변으로 셀 항목 없음 (reframe 등)",
592
595
  "reverify_scope_incremental": " reverify-scope: incremental 가능 — 답변된 항목이 모두 직전 리포트의 stage 에 연결됨 (최종 확정은 run 시점 base-ref 비교)",
593
- "reverify_scope_unlinked": " reverify-scope: full 예상 — {ids} 이(가) 직전 리포트의 어느 stage 에도 연결되지 않아 범위를 좁힐 없음",
594
- "reverify_scope_full": " reverify-scope: full 예상 — {reason}",
596
+ "reverify_scope_unlinked": " reverify-scope: full 예상 — {ids} 이(가) 직전 리포트의 어느 stage 에도 연결되지 않아 범위를 좁히지 못함\n (좁히지 못하는 것은 재검증 범위이지 답변이 아닙니다 — 답변은 모두 carry-in 되어 반영되고, 대신 stage 전체를 다시 검증합니다)",
597
+ "reverify_scope_full": " reverify-scope: full 예상 — {reason}\n (좁히지 못하는 것은 재검증 범위이지 답변이 아닙니다 — 답변은 모두 carry-in 되어 반영되고, 대신 stage 전체를 다시 검증합니다)",
595
598
  "reverify_scope_user_full": " reverify-scope: full (사용자 지정 — 전체 재검증)",
596
599
  "reverify_scope_user_stages": " reverify-scope: stage {stages} 재검증 지정 (사용자 지정 — 하위 stage 포함, 나머지는 직전 판정 이월)",
597
600
  "stage_whole_task": "전체 task",
@@ -14,7 +14,7 @@ import socket
14
14
  import subprocess
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
- from typing import Any, Sequence
17
+ from typing import Any, Collection, Sequence
18
18
 
19
19
  PING_OK = "PONG"
20
20
 
@@ -165,15 +165,31 @@ class Placement:
165
165
 
166
166
 
167
167
  def plan_worker_placement(
168
- panes: Sequence[PaneGeometry], *, lead_pane_id: str, min_columns: int
168
+ panes: Sequence[PaneGeometry],
169
+ *,
170
+ lead_pane_id: str,
171
+ owned_surface_ids: Collection[str],
172
+ min_columns: int,
169
173
  ) -> Placement:
170
174
  """Pick the next worker slot from the workspace's current geometry.
171
175
 
172
176
  Stateless by design: okstra records surface UUIDs, never a layout, so a
173
177
  resumed or crashed run cannot carry a layout model that no longer matches
174
178
  the screen. Every dispatch re-reads the panes and derives the next slot.
179
+
180
+ Those same UUIDs say which panes okstra may place into. A workspace also
181
+ holds panes okstra never opened — another agent session, a shell the user
182
+ keeps around — and "not the lead" does not make a pane a worker slot. Taken
183
+ as one, a stranger's pane is split or, when it is too narrow to split,
184
+ stacked into: the workers land as background tabs in someone else's window,
185
+ so nothing appears on screen and that window grows tabs it did not ask for.
175
186
  """
176
- workers = [pane for pane in panes if pane.pane_id != lead_pane_id]
187
+ workers = [
188
+ pane
189
+ for pane in panes
190
+ if pane.pane_id != lead_pane_id
191
+ and _holds_an_okstra_surface(pane, owned_surface_ids)
192
+ ]
177
193
  if not workers:
178
194
  return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
179
195
 
@@ -196,6 +212,17 @@ def lead_shrink_points(lead: PaneGeometry, *, target_columns: int) -> int:
196
212
  return surplus * lead.cell_width_points
197
213
 
198
214
 
215
+ def _holds_an_okstra_surface(
216
+ pane: PaneGeometry, owned_surface_ids: Collection[str]
217
+ ) -> bool:
218
+ """Whether okstra opened anything in this pane.
219
+
220
+ Any one recorded surface is enough: workers that stacked as tabs share a
221
+ pane, and only the tab that opened it carries the UUID okstra split on.
222
+ """
223
+ return any(surface_id in owned_surface_ids for surface_id in pane.surface_ids)
224
+
225
+
199
226
  def _widen_the_grid(
200
227
  workers: Sequence[PaneGeometry], *, min_columns: int
201
228
  ) -> Placement:
@@ -270,18 +297,30 @@ def list_panes(workspace: str) -> list[PaneGeometry]:
270
297
 
271
298
 
272
299
  def spawn_worker_surface(
273
- *, workspace: str, cwd: Path, command: Sequence[str], title: str
300
+ *,
301
+ workspace: str,
302
+ cwd: Path,
303
+ command: Sequence[str],
304
+ title: str,
305
+ owned_surface_ids: Collection[str],
274
306
  ) -> str:
275
307
  """Start one worker beside the lead and return its surface UUID.
276
308
 
277
309
  The UUID is what okstra records and later closes by. Positional refs cannot
278
310
  serve that purpose: cmux renumbers them as surfaces open and close, so a
279
311
  close by ref can land on a pane okstra never created.
312
+
313
+ `owned_surface_ids` are the UUIDs earlier dispatches returned. They are what
314
+ keeps this placement inside okstra's own panes; the workspace belongs to the
315
+ user and may hold anything.
280
316
  """
281
317
  panes = list_panes(workspace)
282
318
  lead = _lead_pane(panes)
283
319
  placement = plan_worker_placement(
284
- panes, lead_pane_id=lead.pane_id, min_columns=WORKER_MIN_COLUMNS
320
+ panes,
321
+ lead_pane_id=lead.pane_id,
322
+ owned_surface_ids=owned_surface_ids,
323
+ min_columns=WORKER_MIN_COLUMNS,
285
324
  )
286
325
  target = _pane_by_id(panes, placement.pane_id)
287
326
  surface_uuid = _open_worker_surface(workspace, placement, target)
@@ -452,6 +452,7 @@ def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
452
452
  cwd=plan.project_root,
453
453
  command=job.command,
454
454
  title=f"{job.worker_id}-worker",
455
+ owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
455
456
  )
456
457
  except (RuntimeError, OSError, subprocess.SubprocessError):
457
458
  return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
@@ -460,6 +461,28 @@ def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
460
461
  )
461
462
 
462
463
 
464
+ def _opened_cmux_surfaces(team_state_path: Path) -> tuple[str, ...]:
465
+ """The surfaces this run has already opened in the user's cmux workspace.
466
+
467
+ Placement needs them to tell okstra's own panes from the rest of the
468
+ workspace, which belongs to the user and may hold another agent session or
469
+ a shell okstra must not split or stack into. A degraded dispatch opened no
470
+ surface and records an empty id, which would name no pane at all.
471
+ """
472
+ payload = _load_json_object(team_state_path, "team-state")
473
+ dispatches = payload.get("workerDispatches")
474
+ if not isinstance(dispatches, list):
475
+ return ()
476
+ opened = []
477
+ for record in dispatches:
478
+ if not isinstance(record, dict):
479
+ continue
480
+ surface_id = str(record.get("paneId", ""))
481
+ if surface_id:
482
+ opened.append(surface_id)
483
+ return tuple(opened)
484
+
485
+
463
486
  def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
464
487
  lead_pane = tmux.resolve_caller_pane()
465
488
  if not lead_pane:
@@ -4809,6 +4809,33 @@ def render_args(state: WizardState) -> dict[str, str]:
4809
4809
  }
4810
4810
 
4811
4811
 
4812
+ def _clarification_sidecar_line(state: WizardState) -> Optional[str]:
4813
+ """확인 블록에 찍는 `user-responses/` 첨부 현황.
4814
+
4815
+ picker 단계의 옵션 라벨에도 같은 사실이 붙지만 그 화면을 지나면 사라지고,
4816
+ 확인 블록에는 final-report 경로만 남았다. 그 줄 바로 밑에 "범위를 좁히지
4817
+ 못함" 이 오니 두 줄이 겹쳐 "답변이 안 붙었다" 로 읽혔다 — 실제로는 첨부돼
4818
+ 반영되고 있었다. 실행 직전 화면에서 답변 id 를 직접 보여 그 오해를 없앤다.
4819
+ """
4820
+ if not state.clarification_response_path or not state.project_root:
4821
+ return None
4822
+ report = _resolve_path(
4823
+ state.clarification_response_path, Path(state.project_root)
4824
+ )
4825
+ files = user_response_sidecars(report)
4826
+ if not files:
4827
+ return _msg(state.workspace_root, "confirmation",
4828
+ "clarification_sidecars_empty")
4829
+ answers = sorted(sidecar_answers(report))
4830
+ return _msg(
4831
+ state.workspace_root, "confirmation", "clarification_sidecars_attached",
4832
+ files=str(len(files)), count=str(len(answers)),
4833
+ ids=", ".join(answers) or _msg(
4834
+ state.workspace_root, "confirmation",
4835
+ "clarification_sidecars_none_parsed"),
4836
+ )
4837
+
4838
+
4812
4839
  def _reverify_scope_line(state: WizardState) -> Optional[str]:
4813
4840
  """이번 clarification 재실행이 좁혀질지 — 확인 단계에서 보여주는 줄.
4814
4841
 
@@ -4923,6 +4950,9 @@ def confirmation_block(state: WizardState) -> str:
4923
4950
  lines.append(f" stage : {stage}")
4924
4951
  if state.clarification_response_path:
4925
4952
  lines.append(f" clarification : {state.clarification_response_path}")
4953
+ sidecar_line = _clarification_sidecar_line(state)
4954
+ if sidecar_line is not None:
4955
+ lines.append(sidecar_line)
4926
4956
  reverify_line = _reverify_scope_line(state)
4927
4957
  if reverify_line is not None:
4928
4958
  lines.append(reverify_line)
@@ -66,7 +66,10 @@ from okstra_ctl.report_translation import ( # noqa: E402
66
66
  hangul_share,
67
67
  )
68
68
  from okstra_ctl.stage_citations import cited_stage_numbers # noqa: E402
69
- from okstra_ctl.incremental_scope import coverage_row_blocked_on # noqa: E402
69
+ from okstra_ctl.incremental_scope import ( # noqa: E402
70
+ coverage_row_blocked_on,
71
+ stages_for_clarification,
72
+ )
70
73
  from okstra_ctl.workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE # noqa: E402
71
74
  from okstra_ctl.md_table import ( # noqa: E402
72
75
  is_separator_row as _is_markdown_separator,
@@ -4155,6 +4158,13 @@ def _validate_approval_clarification_backtrace(
4155
4158
  `incremental-scope` resolves impacted stages from these links and treats an
4156
4159
  id that traces to no stage as grounds to re-verify everything, so one
4157
4160
  unlinked blocker turns a narrow re-run into a full one.
4161
+
4162
+ The link must also *resolve to a stage*, which is the thing the re-run
4163
+ actually reads. Checking only that a link exists let a row satisfy this
4164
+ gate and still force full: `P-Req-*` and `P-Val-*` ids are numbered by
4165
+ position in their own array, so they carry no stage, and a blocked
4166
+ coverage row whose `coveredBy` is prose cites none either. Both shapes
4167
+ passed while the re-run they were meant to narrow re-verified everything.
4158
4168
  """
4159
4169
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
4160
4170
  return
@@ -4170,15 +4180,28 @@ def _validate_approval_clarification_backtrace(
4170
4180
  if not isinstance(row, dict) or row.get("blocks") != "approval":
4171
4181
  continue
4172
4182
  row_id = str(row.get("id") or "<unknown>")
4173
- if _has_clarification_backtrace(row_id, plan_items, coverage):
4183
+ if not _has_clarification_backtrace(row_id, plan_items, coverage):
4184
+ failures.append(
4185
+ f"final-report data.json: clarification `{row_id}` blocks approval "
4186
+ "but has no back-trace into the plan — no plan item carries it as "
4187
+ "`clarificationId`, and no requirement-coverage row is `blocked "
4188
+ f"{row_id}` in its `status` or `approvalDisposition`. An item that "
4189
+ "withholds approval without recording what it affects forces the "
4190
+ "next re-run to re-verify everything."
4191
+ )
4192
+ continue
4193
+ if stages_for_clarification(data, row_id):
4174
4194
  continue
4175
4195
  failures.append(
4176
4196
  f"final-report data.json: clarification `{row_id}` blocks approval "
4177
- "but has no back-trace into the plan no plan item carries it as "
4178
- "`clarificationId`, and no requirement-coverage row is `blocked "
4179
- f"{row_id}` in its `status` or `approvalDisposition`. An item that "
4180
- "withholds approval without recording what it affects forces the "
4181
- "next re-run to re-verify everything."
4197
+ "and is linked, but the link resolves to no stage. `incremental-"
4198
+ "scope` reads the stage from a `P-Step-<stage>.<step>` / `P-Prep-"
4199
+ "S<stage>-<kind>` plan-item id, or from a `Stage N` citation in the "
4200
+ f"blocked coverage row's `coveredBy`. A `P-Req-*` / `P-Val-*` id "
4201
+ "carries no stage number, so a row linked only that way must cite "
4202
+ "the stage in `coveredBy`. A blocker whose blast radius resolves to "
4203
+ "no stage costs exactly what an unlinked one does — the next re-run "
4204
+ "re-verifies every stage."
4182
4205
  )
4183
4206
 
4184
4207