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 +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +23 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +50 -2
- package/runtime/python/okstra_ctl/cmux.py +21 -0
- package/runtime/python/okstra_ctl/domain/worker_stream.py +21 -1
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +20 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +10 -2
- package/runtime/python/okstra_ctl/report_contract.py +9 -2
- package/runtime/python/okstra_ctl/run.py +15 -3
- package/runtime/python/okstra_ctl/team.py +17 -0
- package/runtime/python/okstra_ctl/wizard.py +8 -1
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +6 -1
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
|
|
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
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|