okstra 0.170.1 → 0.170.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cli.md CHANGED
@@ -742,7 +742,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
742
742
  | `okstra doctor [--runtime claude-code\|codex\|antigravity\|external\|all] [--phase <phase>] [--json]` | Diagnose the runtime, Python imports, and skill/agent installation. The `codex`, `antigravity`, and `external` runtimes omit Claude skill checks. `--phase` adds readiness checks for `implementation`, `final-verification`, `release-handoff`, or `improvement-discovery` |
743
743
  | `okstra setup --project-id <id>` | Create or update `.okstra/project.json` in the current project |
744
744
  | `okstra check-project [--json]` | Verify that the current project is registered |
745
- | `okstra preflight [--runtime <name>] [--cwd <dir>] [--json]` | Single skill-preflight call combining `ensure-installed`, with silent reinstall when stale, `check-project`, and host-specific `runtimeReadiness` into one JSON response. A `claude-code` host checks project workspace trust; `codex`, `antigravity`, and `external` hosts return ready without reading Claude Code state. Step 0 of every project-scoped skill converges on this command |
745
+ | `okstra preflight [--runtime <name>] [--cwd <dir>] [--json]` | Single skill-preflight call combining `ensure-installed`, with silent reinstall when stale, `check-project`, and host-specific `runtimeReadiness` into one JSON response. A `claude-code` host checks project workspace trust. A `codex` current-session host verifies write access to `~/.okstra/worktrees/registry.lock`; a sandbox denial blocks before the wizard with the `switch-codex-to-full-access-and-rerun` action. `antigravity` and `external` hosts return ready without reading Claude Code state. Step 0 of every project-scoped skill converges on this command |
746
746
  | `okstra convergence seed --groups <path> --work-state <path> --final-state <path> --migration-dir <dir> [--restart-from-round0]` | Create, resume, reuse, or explicitly recover deterministic convergence state |
747
747
  | `okstra convergence plan-round --work-state <path> --plan <path>` | Persist the next roster-aware dispatch plan without mutating working state |
748
748
  | `okstra convergence collect-results --plan <round-plan.json> --mode <adversarial\|collaborative> --result <worker>=<path>… --dispatch <worker>=<status>:<durationMs>… --output <round-results.json>` | Read one round's worker responses into the `apply-round --results` shape. `--mode` picks the verdict vocabulary — the adversarial prompt answers `REFUTED` / `SURVIVES` / `SURVIVES-WITH-CAVEAT` / `UNVERIFIABLE`, which this maps to `disagree` / `agree` / `supplement` / `unverifiable`, and copies `**Basis**` into `disagreeBasis`. `--dispatch` supplies the terminal status and duration, which live in the dispatch rather than the response; a worker that never returned gets a `--dispatch` and no `--result`. Exits 2 on a dispatched finding with no verdict, a verdict for a finding the plan did not dispatch to that worker, a planned worker with no recorded outcome, or a vote with no explanation |
@@ -784,7 +784,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
784
784
  | `okstra codex-run <args…>` | Codex lead-adapter dry-run entry point. Accepts the same arguments as `render-bundle` but owns `--render-only --lead-runtime codex`. It prepares the task bundle and prints the prompt for the Codex lead without dispatching workers |
785
785
  | `okstra worker-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Provider-neutral deterministic dispatcher for `runner=cli-wrapper` assignments. It verifies each adjacent invocation specification against the immutable run manifest immediately before process creation and records `core-pre-dispatch`; native-session rows stay with the host. The default selects CLI analysis assignments only. Phase 6 uses explicit `--workers report-writer`, and a mixed analysis/report batch is rejected. `--dry-run` performs the same verification and resolution without starting a provider process. |
786
786
  | `okstra codex-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Compatibility alias for `okstra worker-dispatch`; it no longer selects a Codex-only transport-agent path. |
787
- | `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result; one result path belongs to one dispatch, so a corrective round retires the first attempt with `reject-result --dispatch-id <first> --superseded-by <corrective> --reason <text>` before the new link is accepted — the rejected row stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason` rather than being deleted. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a prompt that failed a pre-dispatch gate and therefore ran nowhere. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
787
+ | `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result; one result path belongs to one dispatch, so a corrective round retires the first attempt with `reject-result --dispatch-id <first> --superseded-by <corrective> --reason <text>` before the new link is accepted — the rejected row stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason` rather than being deleted. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a call that failed a pre-dispatch gate and therefore ran nowhere — it covers a differing prompt and a differing metadata alike, since the two are published together and describe one call. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
788
788
  | `okstra team dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--jobs-file <path>] [--dry-run]` / `okstra team await --project-root <dir> --run-manifest <path> [--json]` / `okstra team teardown --project-root <dir> --run-manifest <path> [--dry-run] [--json]` | Read a `leadRuntime=external` run manifest and dispatch, await, or tear down tmux-pane workers. Default dispatch excludes report writer; Phase 6 selects it explicitly, and mixed analysis/report jobs are rejected. If a tmux pane cannot be created, gracefully degrade to the CLI wrapper and record the fallback in `workerDispatches[].degradedFrom` |
789
789
  | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run the whole Phase 7 post-report sequence in its contractual order: `token-usage` → `render-views` → `spawn-followups` → `validate-run`. Stops at the first non-zero exit and names the failing step, then prints a per-step `[ok]` / `[FAIL]` / `[skip]` summary on stderr so the outcome is legible without parsing the JSON payload. Every step is idempotent, so re-running after a fix is safe — but `--only <step>` (repeatable) reruns just the named steps in contractual order, which matters because `validate-run` is the step that usually fails and retrying it otherwise repeats the three steps before it at full token and wall-clock cost. This is the same code path (`scripts/okstra_ctl/report_finalize.py`) the Codex lead adapter runs automatically after its report-writer completes, so a Claude-led and a Codex-led run finalize identically. `--workspace-root` is owned by the Node wrapper. Prefer this over invoking the four steps individually |
790
790
  | `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly (contract: `schemas/final-report-v2.0.schema.json`) into an always-generated, task-specific human HTML sibling while `templates/reports/final-report-v2.template.md` independently owns the AI handoff Markdown. Passing the Markdown sibling locates the same v2 data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, and Response ID parity |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.170.1",
3
+ "version": "0.170.3",
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.170.1",
3
- "builtAt": "2026-08-14T07:11:14.334Z",
2
+ "package": "0.170.3",
3
+ "builtAt": "2026-08-14T18:24:32.087Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -243,6 +243,7 @@ round before any host or provider process starts.
243
243
  - lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
244
244
  - missing or weak `P-Prep-*` contracts are repaired by adding kind-specific inline detail or an AI-prepared PREP item with a concrete proposal. Facts that require user or external authority remain `blocked` and keep their request material; never invent those facts during self-fix.
245
245
  - **Drop plan items whose element the round deleted.** A self-fix rewrite may remove a plan element (a validation check, a rollback row). `P-*` ids are positional, so a deletion shifts every later row and silently re-points surviving verdicts at their neighbours — and a verdict recorded against a removed element keeps blocking a gate while being unfindable in the plan, so reading the plan never reveals the cause. After each round, re-extract plan items with `okstra plan-items extract` and re-verify any item whose `subject` no longer matches; never carry the old vote forward across a shift. **Enforced:** `validators/validate-run.py` `_validate_verdicts_match_current_subjects` (re-pointing) and `_validate_plan_item_extraction_completeness` (dangling ids).
246
+ - **Classify each cause group before instructing it (BLOCKING).** A group is either an *authoring* defect — the plan says something wrong, incomplete, or self-contradictory, which self-fix owns — or a *citation* defect, where the plan points at an analysis artifact incorrectly. Only the first is self-fix work. For the second the finding already exists and already went through convergence, so the fix is to re-cite the converged artifact; instructing report-writer to re-derive the fact means the author reads the source material and produces a **finding that never went through convergence**, which the plan then carries as if it had. That is the role boundary the lead contract draws ("keep analysis, execution, verification, and report authoring responsibilities distinct; return defects to the role that owns them"), and report-writer is authoring-only by its own contract. `P-Req-*` items with breakage kind `f` are where this goes wrong most often: the question is usually whether a coverage row points correctly at something already measured, not whether the measurement is right. State the classification in the group's instruction so the author knows which of the two it is being asked to do.
246
247
  - **A verdict older than the last self-fix is not a verdict (BLOCKING).** Rounds interleave with rewrites — round 1, self-fix 1, round 2, self-fix 2 — so a verdict cast in round R judged the text as it stood after self-fix R-1. Once self-fix R runs, that judgement is about a plan that no longer exists. `--round <N>` on `apply-verdicts` stamps each row, and `validators/validate-run.py` `_validate_verdict_rounds_outlive_self_fix` fails any non-carried item whose verdict round is at or before `selfFixRoundsApplied`. This is why "adjacent items the rewrite touched" is not sufficient on its own: adjacency is judged from `subject` changes, and the observed failure was items whose own subject never moved while the stage they point at was rewritten under them. On one run the gate read `passed-with-dissent` with zero blockers and a single re-run flipped 3 of 27 items to `majority-disagree`, all correctness-critical. Before declaring the gate, every item still holding a pre-self-fix verdict MUST be re-verified in a round after the last rewrite.
247
248
  - lead re-runs plan-body verification (focused on the corrected items + adjacent items the rewrite touched, plus any `needs-reverify` items whose peer failed to vote last round). After re-verification, overwrite `planItems[].verdicts` with the new verdicts. **The round's verdicts MUST be transcribed into `planBodyVerification.planItems[].verdicts` in the final report's data.json before the gate is declared** — the gate is re-derived from that table, so declaring a gate over an empty one leaves it unauditable. **Enforced:** `_validate_round_recorded_verdicts`. Transcribe with `okstra plan-items collect-verdicts --result <worker>=<path> … --items <plan-items.json> --output <verdicts.json>` then `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json> --round <N>`, never with a per-round script: the CLI reads the response shape this section fixes and **fails** on an assigned item the worker left unanswered, on a verdict for an item outside the queue, and on a `DISAGREE` with no breakage kind. A hand-written regex reports none of those — it drops them, and the round is then scored on a table that silently does not match the queue.
248
249
  - for an item whose `majority-disagree` was resolved by self-fix, record `self-fixed in round <N>: <what was fixed>` in `planItems[].selfFixNote`. A resolved item does not create a clarification.
@@ -1,6 +1,7 @@
1
1
  """Bundled Codex host strategy."""
2
2
  from __future__ import annotations
3
3
 
4
+ import os
4
5
  import shutil
5
6
  from collections.abc import Callable
6
7
  from pathlib import Path
@@ -35,6 +36,27 @@ DESCRIPTOR = HostDescriptor(
35
36
  )
36
37
 
37
38
 
39
+ def _okstra_home_write_checks(context) -> tuple[dict[str, object], ...]:
40
+ if context.entry_mode != "current-session":
41
+ return ({"id": "okstra-home-write", "status": "not-applicable"},)
42
+
43
+ home_dir = Path(os.environ.get("OKSTRA_PROBE_HOME_DIR", str(Path.home())))
44
+ registry_lock = home_dir / ".okstra" / "worktrees" / "registry.lock"
45
+ try:
46
+ registry_lock.parent.mkdir(parents=True, exist_ok=True)
47
+ if not registry_lock.exists():
48
+ registry_lock.touch()
49
+ with registry_lock.open("r+"):
50
+ pass
51
+ except PermissionError:
52
+ return ({
53
+ "id": "okstra-home-write",
54
+ "status": "required",
55
+ "action": "switch-codex-to-full-access-and-rerun",
56
+ },)
57
+ return ({"id": "okstra-home-write", "status": "accepted"},)
58
+
59
+
38
60
  def create_adapter(
39
61
  *,
40
62
  executable_finder: Callable[[str], str | None] = shutil.which,
@@ -59,6 +81,7 @@ def create_adapter(
59
81
  supported_functions=INTERACTION_FUNCTIONS,
60
82
  detector=no_automatic_claim,
61
83
  provider_registry=provider_registry,
84
+ readiness_probe=_okstra_home_write_checks,
62
85
  host_model_port=host_model_port or NativeExecutionValueHostModelBindingPort(
63
86
  DESCRIPTOR.id,
64
87
  DESCRIPTOR.native_provider_id,
@@ -762,7 +762,13 @@ def _invocation_digests(
762
762
  assignment = _assignment_payload(request.assignment)
763
763
  instruction = {
764
764
  "anchorLines": list(request.instruction.anchor_lines),
765
- "body": request.instruction.body,
765
+ # Digest what the prompt can actually carry. Composition writes
766
+ # `body.rstrip("\n")`, so trailing blank lines never reach the file —
767
+ # and `verify` can only rebuild the body from the prompt. Hashing the
768
+ # raw text made an instruction file ending in a blank line unverifiable
769
+ # forever: neither candidate `verify` reconstructs could equal a digest
770
+ # over bytes the prompt does not contain.
771
+ "body": request.instruction.body.rstrip("\n"),
766
772
  "sourcePaths": [
767
773
  _source_payload(source) for source in request.instruction.source_paths
768
774
  ],
@@ -939,13 +945,55 @@ def _publish_or_reuse(
939
945
  return
940
946
  if prompt_exists and metadata_exists:
941
947
  if metadata.read_bytes() != materialized.metadata_bytes:
942
- raise _existing_conflict("existing metadata differs")
948
+ # Same rule, same artifact pair. The prompt and its adjacent
949
+ # metadata are published together and describe one call, so a
950
+ # metadata-only difference is the identical situation as a
951
+ # prompt difference — and it used to be the one the documented exit
952
+ # could not reach, leaving hand-deleting the files as the only move.
953
+ _replace_undispatched(
954
+ request, materialized, _metadata_difference(metadata, materialized)
955
+ )
943
956
  return
944
957
  if not prompt_exists:
945
958
  _publish_exclusive(prompt, materialized.prompt_bytes)
946
959
  _publish_exclusive(metadata, materialized.metadata_bytes)
947
960
 
948
961
 
962
+ def _metadata_difference(
963
+ metadata: Path, materialized: _MaterializedInvocation
964
+ ) -> str:
965
+ """Which metadata fields differ, named rather than counted.
966
+
967
+ Metadata is JSON, so the useful answer is which keys moved — a line number
968
+ would point at whatever the serializer happened to order first. Digests are
969
+ nested one level down and are the fields that actually differ in practice,
970
+ so they are reported by name too.
971
+ """
972
+ try:
973
+ existing = json.loads(metadata.read_text(encoding="utf-8"))
974
+ except (OSError, json.JSONDecodeError) as exc:
975
+ return f"existing metadata is unreadable: {exc}"
976
+ composed = materialized.metadata
977
+ if not isinstance(existing, dict):
978
+ return "existing metadata is not an object"
979
+ changed = sorted(
980
+ key
981
+ for key in set(existing) | set(composed)
982
+ if existing.get(key) != composed.get(key)
983
+ )
984
+ if changed == ["digests"]:
985
+ digests = existing.get("digests")
986
+ composed_digests = composed.get("digests")
987
+ if isinstance(digests, dict) and isinstance(composed_digests, dict):
988
+ moved = sorted(
989
+ key
990
+ for key in set(digests) | set(composed_digests)
991
+ if digests.get(key) != composed_digests.get(key)
992
+ )
993
+ return f"digests differ: {', '.join(moved)}"
994
+ return f"fields differ: {', '.join(changed)}" if changed else "byte-level difference"
995
+
996
+
949
997
  def _prompt_difference(prompt: Path, materialized: _MaterializedInvocation) -> str:
950
998
  """What actually differs between the published prompt and this one.
951
999
 
@@ -344,6 +344,27 @@ def close_surface(surface_uuid: str) -> None:
344
344
  return
345
345
 
346
346
 
347
+ def restore_lead_width() -> None:
348
+ """Put the lead back on its target width once its workers are reclaimed.
349
+
350
+ Closing a pane hands its width to whichever neighbour cmux picks, and that
351
+ is not necessarily the lead. Measured 2026-08-14: every worker surface of
352
+ the run was gone and the lead was still at 18 columns, because the only
353
+ place that sizes it runs when a worker *opens*. Between rounds — which is
354
+ the stretch the user spends reading the lead rather than the workers — the
355
+ lead therefore kept whatever the last split left it.
356
+
357
+ Resolves the workspace itself: teardown reaches this from the CLI, which
358
+ holds a run manifest rather than the workspace UUID that `pane.resize`
359
+ needs. An unresolvable workspace means cmux is gone or was never there, and
360
+ there is no pane left to size.
361
+ """
362
+ workspace = resolve_lead_workspace()
363
+ if not workspace:
364
+ return
365
+ _size_lead_pane(workspace)
366
+
367
+
347
368
  def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
348
369
  """What the worker's screen shows — for the lead to look at, never to parse.
349
370
 
@@ -255,7 +255,27 @@ def _content_blocks(event: Mapping[str, Any]) -> list[Mapping[str, Any]]:
255
255
  return [block for block in content if isinstance(block, Mapping)]
256
256
 
257
257
 
258
+ # A tool call's detail identifies itself at both ends and neither end alone. A
259
+ # path's run-directory prefix is shared by every file a worker touches, so the
260
+ # leaf is what tells two calls apart; a command's program name is at the front.
261
+ # Cutting the tail served only the second, and on a project whose run directory
262
+ # alone is 195 characters it rendered every file as the same visible string —
263
+ # `→ Read: /Volumes/…/tasks/analysis-…` for all of them — leaving the reader
264
+ # unable to tell one call from another. The head holds the tool name plus enough
265
+ # of the detail to read a command; the rest of the budget goes to the tail.
266
+ _HEAD_BUDGET = 32
267
+
268
+
258
269
  def _truncate(line: str, limit: int | None) -> str:
270
+ """Fold the middle, not the end.
271
+
272
+ The end is what distinguishes one line from the next, so it is the part the
273
+ screen must keep. Falls back to a tail cut only when the limit is too small
274
+ to hold a head, an ellipsis, and any tail at all.
275
+ """
259
276
  if limit is None or len(line) <= limit:
260
277
  return line
261
- return line[: limit - 1] + "…"
278
+ tail_budget = limit - _HEAD_BUDGET - 1
279
+ if tail_budget < 1:
280
+ return line[: limit - 1] + "…"
281
+ return line[:_HEAD_BUDGET] + "…" + line[-tail_budget:]
@@ -422,6 +422,25 @@ def _render_prompt_parts(
422
422
  return anchors, body
423
423
 
424
424
 
425
+ def _worker_invocation_id(manifest: Mapping[str, Any], worker_id: str) -> str:
426
+ """Scope a worker's invocation id to this run, the way the lead's already is.
427
+
428
+ The lead reserves `<task-type>-<seq>-lead` while workers reserved a bare
429
+ `initial-<worker>`, so a second run of the same task-type asked for a
430
+ reservation the first run already holds and every re-run died on
431
+ `invocation reservation conflicts with existing invocation`. The asymmetry
432
+ was the whole bug: nothing about a worker makes its call less run-specific
433
+ than the lead's.
434
+ """
435
+ task_type = str(manifest.get("taskType") or "").strip()
436
+ sequences = manifest.get("runSequencesByCategory")
437
+ seq = ""
438
+ if isinstance(sequences, Mapping):
439
+ seq = str(sequences.get("prompts") or "").strip()
440
+ scope = "-".join(part for part in (task_type, seq) if part)
441
+ return f"{scope}-initial-{worker_id}" if scope else f"initial-{worker_id}"
442
+
443
+
425
444
  def _agent_invocation_request(
426
445
  context: _MaterializationContext,
427
446
  item: _PromptItem,
@@ -457,7 +476,7 @@ def _agent_invocation_request(
457
476
  ),
458
477
  )
459
478
  return AgentInvocationRequest(
460
- invocation_id=f"initial-{item.worker.worker_id}",
479
+ invocation_id=_worker_invocation_id(context.manifest, item.worker.worker_id),
461
480
  worker_id=item.worker.worker_id,
462
481
  audience=item.plan.duty_audience,
463
482
  assignment_ref=assignment_ref,
@@ -154,7 +154,6 @@ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
154
154
  ("breakageKind", block.breakage_kind),
155
155
  ("fixability", block.fixability),
156
156
  ("note", block.note),
157
- ("priorDissent", block.prior_dissent),
158
157
  ):
159
158
  if value:
160
159
  row[key] = value
@@ -293,7 +292,16 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
293
292
  if isinstance(item, Mapping)
294
293
  }
295
294
  added = [
296
- {**item, "verdicts": []}
295
+ # Only the fields a `planItems[]` row may carry. The extraction also
296
+ # yields `payload` and `ticketId` for the verifier prompt, and the row
297
+ # schema is `additionalProperties: false` — copying the item wholesale
298
+ # put two schema violations in every seeded row, on the exact path the
299
+ # contract tells a lead to follow.
300
+ {
301
+ key: item[key]
302
+ for key in ("id", "subject", "sourceSection")
303
+ if key in item
304
+ } | {"verdicts": []}
297
305
  for item in extracted
298
306
  if item["id"] not in known
299
307
  ]
@@ -89,13 +89,20 @@ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
89
89
  "changeImpactAnalysis.securityAndPerformanceImpact",
90
90
  "changeImpactAnalysis.planningInputs",
91
91
  ),
92
+ # `validationChecklist` / `rollbackStrategy` are deliberately NOT here. The
93
+ # planning HTML is the approver's document, and both tables are the
94
+ # implementer's and auditor's working material — the reasoning is written
95
+ # into `html/tasks/implementation-planning.template.html` above the
96
+ # requirement-coverage section, and both still render in the markdown report
97
+ # (`final-report.template.md` §"Validation Checklist" / §"Rollback
98
+ # Strategy"). When that split landed the template dropped the sections and
99
+ # this list kept demanding them, so `validate-report-views.py` failed every
100
+ # implementation-planning report with nothing a report could do about it.
92
101
  "implementation-planning": (
93
102
  "implementationPlanning.optionCandidates",
94
103
  "implementationPlanning.tradeoffMatrix",
95
104
  "implementationPlanning.recommendedOption",
96
105
  "implementationPlanning.stageMap",
97
- "implementationPlanning.validationChecklist",
98
- "implementationPlanning.rollbackStrategy",
99
106
  ),
100
107
  "implementation": (
101
108
  "implementation.diffSummary",
@@ -1418,10 +1418,22 @@ class _ModelBindings:
1418
1418
  invocation_assignments: dict[str, dict[str, object]]
1419
1419
 
1420
1420
 
1421
- def recommended_role_models() -> dict[str, str]:
1421
+ def recommended_role_models(*, lead_provider: str = "") -> dict[str, str]:
1422
1422
  """역할 → 추천 모델 display 값 (env override 반영). prepare 의 모델 해소와
1423
- wizard 의 안내 표기가 공유하는 단일 기준점."""
1424
- lead_default = _default("OKSTRA_DEFAULT_LEAD_MODEL", default_model("lead"))
1423
+ wizard 의 안내 표기가 공유하는 단일 기준점.
1424
+
1425
+ lead 는 호스트가 provider 를 정한다 — codex 호스트의 in-session lead 는
1426
+ codex 이고 다른 provider 요청은 거부된다(`resolve_lead_provider`). 그래서
1427
+ `lead_provider` 를 받아 그 provider 의 기본값으로 해소한다. 받지 않으면
1428
+ role 별 레거시 기본값(claude 계열)으로 떨어지는데, 그 값을 codex 호스트
1429
+ 화면에 그대로 쓰면 안내는 `opus` 인데 prepare 는 `gpt-5.6-sol` 을 배정한다.
1430
+ """
1431
+ lead_default = _default(
1432
+ "OKSTRA_DEFAULT_LEAD_MODEL",
1433
+ provider_default_model(lead_provider, "lead")
1434
+ if lead_provider
1435
+ else default_model("lead"),
1436
+ )
1425
1437
  recommendations = {
1426
1438
  "lead": lead_default,
1427
1439
  "claude": _default("OKSTRA_DEFAULT_CLAUDE_MODEL", default_model("claude")),
@@ -165,6 +165,8 @@ def _teardown(args) -> int:
165
165
  reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
166
166
  for pane in panes:
167
167
  reclaim(pane["paneId"])
168
+ if _is_cmux_run(manifest):
169
+ _restore_lead_width()
168
170
  _mark_teardown_errors(team_state_path)
169
171
  _emit_teardown(args.json, panes)
170
172
  return 0
@@ -252,6 +254,21 @@ def _append_pane(panes: list[dict[str, str]], seen: set[str], pane_id: str, kind
252
254
  seen.add(pane_id)
253
255
 
254
256
 
257
+ def _restore_lead_width() -> None:
258
+ """Give the lead back the width the reclaimed panes left behind.
259
+
260
+ Reported rather than raised: teardown's contract is to reclaim the run's
261
+ panes and print what it reclaimed, and a failed resize must not cost the
262
+ caller that record — the panes are already closed by the time this runs.
263
+ Reported rather than swallowed, because a width failure nobody is told
264
+ about is what left the lead at 18 columns unnoticed.
265
+ """
266
+ try:
267
+ cmux.restore_lead_width()
268
+ except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
269
+ print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
270
+
271
+
255
272
  def _mark_teardown_errors(team_state_path: Path) -> None:
256
273
  def mark(payload: dict[str, Any]) -> bool:
257
274
  changed = False
@@ -3445,7 +3445,14 @@ def _role_model_lines(state: WizardState) -> str:
3445
3445
  """이번 run 에서 실제로 모델을 고르게 되는 역할만, 추천 모델과 함께 나열한다.
3446
3446
  뒤따르는 *_model 단계의 등장 조건과 1:1 로 맞춰 안내와 실제 화면이 어긋나지
3447
3447
  않게 한다 (그래서 분석에 참여하지 않는 antigravity 는 executor 일 때만 나온다)."""
3448
- rec = recommended_role_models()
3448
+ # 화면의 lead 줄은 뒤따르는 lead-model picker 와 같은 provider 를
3449
+ # 봐야 한다 — picker 는 호스트의 native provider 만 제시한다.
3450
+ rec = recommended_role_models(
3451
+ lead_provider=default_host_registry()
3452
+ .resolve(state.host_runtime)
3453
+ .descriptor.native_provider_id
3454
+ or "",
3455
+ )
3449
3456
  roster = _resolved_roster(state)
3450
3457
  impl = state.task_type == "implementation"
3451
3458
 
@@ -70,7 +70,12 @@
70
70
  {% if planning.get("supersessionLedger") is not none %}
71
71
  <section data-report-section="superseded" data-report-field="implementationPlanning.supersessionLedger">
72
72
  <h2>{{ t('tasks.implementation-planning.what-your-answers-overturned') }}</h2>
73
- <table><thead><tr><th>{{ t('tasks.implementation-planning.answer') }}</th><th>{{ t('tasks.implementation-planning.statement-overturned') }}</th><th>{{ t('tasks.implementation-planning.replaced-with') }}</th></tr></thead><tbody>{% for row in planning.supersessionLedger %}<tr>{{ row_key(pairs=[("Answer", row.clarificationId), ("Disposition", row.disposition)]) }}<td>{{ row.supersededStatement | inline_code }}</td><td>{{ row.replacedWith | inline_code }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation-planning.no-answer-overturned-anything') }}</td></tr>{% endfor %}</tbody></table>
73
+ {# `supersededStatement` / `replacedWith` are optional `SupersessionEntry`
74
+ requires only `clarificationId` and `disposition`, and a
75
+ `no-dependent-statement` row legitimately carries neither. Referencing them
76
+ unconditionally under StrictUndefined meant a schema-valid report killed the
77
+ renderer. #}
78
+ <table><thead><tr><th>{{ t('tasks.implementation-planning.answer') }}</th><th>{{ t('tasks.implementation-planning.statement-overturned') }}</th><th>{{ t('tasks.implementation-planning.replaced-with') }}</th></tr></thead><tbody>{% for row in planning.supersessionLedger %}<tr>{{ row_key(pairs=[("Answer", row.clarificationId), ("Disposition", row.disposition)]) }}<td>{{ row.get("supersededStatement") | inline_code }}</td><td>{{ row.get("replacedWith") | inline_code }}</td></tr>{% else %}<tr><td colspan="4">{{ t('tasks.implementation-planning.no-answer-overturned-anything') }}</td></tr>{% endfor %}</tbody></table>
74
79
  </section>
75
80
  {% endif %}
76
81