okstra 0.170.0 → 0.170.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.
- 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 +3 -1
- package/runtime/python/okstra_ctl/agent_invocation.py +82 -5
- package/runtime/python/okstra_ctl/cmux.py +21 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +20 -1
- package/runtime/python/okstra_ctl/plan_items_cli.py +27 -3
- package/runtime/python/okstra_ctl/report_contract.py +9 -2
- package/runtime/python/okstra_ctl/team.py +17 -0
- package/runtime/schemas/final-report-v2.0.schema.json +5 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +6 -1
- package/runtime/validators/validate-run.py +71 -0
package/docs/cli.md
CHANGED
|
@@ -756,7 +756,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
|
|
|
756
756
|
| `okstra plan-items derivations --data <data.json> --response <user-response sidecar> [--clarification C-NNN]` | List the plan statements an answered clarification may have falsified. Extracts the symbols, paths, and ids the answer names (backticked spans plus `R-001` / `DEV-10174`-style ids) and reports every string in the plan body that mentions one, as a JSON pointer plus excerpt. Advisory: it says where a decision's subject is mentioned, never which mentions are now wrong — the supersession rule (`prompts/profiles/_common-contract.md`) requires the author to enumerate before editing, and this supplies the enumeration |
|
|
757
757
|
| `okstra plan-items seed --data <data.json>` | Create the `implementationPlanning.planBodyVerification.planItems[]` row every verdict lands in, from the same deterministic extraction `extract` uses. `apply-verdicts` refuses a verdict whose item has no row, and the report writer leaves the array empty, so this is the step between them. Idempotent by id: an existing row keeps its verdicts and carried fields, so it is safe to re-run between rounds. Reports `seeded` / `existing` counts |
|
|
758
758
|
| `okstra plan-items collect-verdicts --result <worker-id>=<path>… --items <items.json> --output <verdicts.json>` | Read one plan-verify round's worker responses into a verdicts envelope, using the `### <item-id>` / `**Verdict**` block shape fixed by `prompts/lead/plan-body-verification.md`. Exits 2 on an assigned item the worker left unanswered, a verdict for an item outside the persisted queue, a `DISAGREE` with no breakage kind, or a `DISAGREE` without a fixability value — every failure mode a per-round regex drops silently |
|
|
759
|
-
| `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json>` | Overwrite `implementationPlanning.planBodyVerification.planItems[].verdicts` with that envelope. Overwrites rather than merges, because the contract records one round at a time and a merged table lets the previous round's votes keep voting. Exits 2 when the report carries no row for an incoming item |
|
|
759
|
+
| `okstra plan-items apply-verdicts --data <data.json> --verdicts <verdicts.json> --round <N>` | Overwrite `implementationPlanning.planBodyVerification.planItems[].verdicts` with that envelope. Overwrites rather than merges, because the contract records one round at a time and a merged table lets the previous round's votes keep voting. Exits 2 when the report carries no row for an incoming item. `--round` stamps the verification round on every written row: a self-fix round rewrites the plan after a verification round, so an item left out of a later round keeps a verdict on text that has changed. `validate-run.py` fails a gate resting on any verdict whose round is at or before `selfFixRoundsApplied`. |
|
|
760
760
|
| `okstra config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Manage persistent settings such as `pr-template-path` with atomic JSON writes |
|
|
761
761
|
| `okstra memory <add\|list\|search\|show\|archive>` | Manage global conversation memory in `~/.okstra/memory-book`, a user-home store separate from project `.okstra/` and the CLI basis of the `save this in okstra` natural-language skill |
|
|
762
762
|
| `okstra manager <init\|discover-projects\|new\|task>` | Public CLI for grouping cross-project okstra tasks into manager-owned context. `new project`, `new task-group`, and `new task` create manager plans; `task assign`, `task note`, `task sync`, `task status`, and `task run` manage per-project assignments and snapshots. `new project --project-root` accepts only existing directories and performs setup-equivalent registration only if `.okstra/project.json` is absent. Public documentation uses the full `project-id:task-group:task-id` child task key; when child task IDs differ within the same manager task, select the exact child with `--child-task-id`. `task run` does not execute the child lead directly; it returns `prepared` launch metadata/event and a child launch-context packet as JSON |
|
|
@@ -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,7 +243,9 @@ 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
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
247
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.
|
|
248
250
|
- **Each round is a worker batch.** Before dispatching round N ≥ 2, reclaim the previous round's completed verifiers exactly as at any other batch boundary ([okstra-lead-contract](./okstra-lead-contract.md) "Run-scoped worker-resource lifecycle") and emit `PROGRESS: phase-batch-cleanup panes=<n>`, then announce the round with `PROGRESS: phase-5.5.9-plan-verify round=<N> items=<count>`. Saying a round will "reuse" the previous verifiers and then dispatching under fresh names leaves every prior round holding its panes — five rounds of that is what exhausts the pane budget and blocks the next dispatch. **Enforced:** `validators/validate_session_conformance.py` `_check_plan_verify_cleanup_checkpoints` requires both lines once the state file records two or more rounds.
|
|
249
251
|
- **Round completion.** A round is complete only after the renderer has run on the corrected data.json, lead has appended the round to the state file per step 6, lead has reconciled instructed groups against applied corrections — every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken — and **`okstra plan-verify --report <report>` exits 0** (step 5). A round left with a non-zero exit carries its defect into the next round's inputs, which is how a mis-scored gate survives a whole self-fix budget. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion. The state-file append is not optional bookkeeping: the next re-verification overwrites data.json's `planItems[].verdicts`, so a round that never reached `roundHistory[]` leaves no record anywhere of what it blocked on — which is the whole reason this file exists. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one `roundHistory[]` entry per round `1..roundCount`, each carrying its own `gateResult` and cited by at least one item's `rounds[]`, and requires the file's `selfFixRoundsApplied` to match the report's.
|
|
@@ -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
|
],
|
|
@@ -935,19 +941,90 @@ def _publish_or_reuse(
|
|
|
935
941
|
if metadata_exists and not prompt_exists:
|
|
936
942
|
raise _existing_conflict("metadata exists without prompt")
|
|
937
943
|
if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
|
|
938
|
-
_replace_undispatched(request, materialized)
|
|
944
|
+
_replace_undispatched(request, materialized, _prompt_difference(prompt, materialized))
|
|
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
|
+
|
|
997
|
+
def _prompt_difference(prompt: Path, materialized: _MaterializedInvocation) -> str:
|
|
998
|
+
"""What actually differs between the published prompt and this one.
|
|
999
|
+
|
|
1000
|
+
"existing prompt differs" alone cannot be acted on: it does not say whether
|
|
1001
|
+
the file on disk is a stale sibling, a partially written artifact, or the
|
|
1002
|
+
same prompt built from an edited instruction file — and the evidence is
|
|
1003
|
+
destroyed by the very republish that unblocks the caller. One real
|
|
1004
|
+
investigation ended undecided for exactly this reason. Digests identify the
|
|
1005
|
+
two bodies, and the first differing line points at the edit.
|
|
1006
|
+
"""
|
|
1007
|
+
try:
|
|
1008
|
+
existing = prompt.read_bytes()
|
|
1009
|
+
except OSError as exc:
|
|
1010
|
+
return f"existing prompt is unreadable: {exc}"
|
|
1011
|
+
composed = materialized.prompt_bytes
|
|
1012
|
+
detail = (
|
|
1013
|
+
f"existing {_sha256(existing)}, composed {_sha256(composed)}"
|
|
1014
|
+
)
|
|
1015
|
+
existing_lines = existing.decode("utf-8", errors="replace").splitlines()
|
|
1016
|
+
composed_lines = composed.decode("utf-8", errors="replace").splitlines()
|
|
1017
|
+
for index, (left, right) in enumerate(zip(existing_lines, composed_lines), start=1):
|
|
1018
|
+
if left != right:
|
|
1019
|
+
return f"{detail}, first differing line {index}"
|
|
1020
|
+
shorter = min(len(existing_lines), len(composed_lines))
|
|
1021
|
+
return f"{detail}, identical through line {shorter} then one body continues"
|
|
1022
|
+
|
|
1023
|
+
|
|
949
1024
|
def _replace_undispatched(
|
|
950
|
-
request: AgentInvocationRequest,
|
|
1025
|
+
request: AgentInvocationRequest,
|
|
1026
|
+
materialized: _MaterializedInvocation,
|
|
1027
|
+
difference: str,
|
|
951
1028
|
) -> None:
|
|
952
1029
|
"""Rewrite a prompt this invocation id wrote but no dispatch ever used.
|
|
953
1030
|
|
|
@@ -963,7 +1040,7 @@ def _replace_undispatched(
|
|
|
963
1040
|
agent — puts the prompt back under the original rule.
|
|
964
1041
|
"""
|
|
965
1042
|
if not request.replace_undispatched:
|
|
966
|
-
raise _existing_conflict("existing prompt differs")
|
|
1043
|
+
raise _existing_conflict(f"existing prompt differs ({difference})")
|
|
967
1044
|
dispatched = _recorded_dispatch_ids(request)
|
|
968
1045
|
if dispatched:
|
|
969
1046
|
raise _existing_conflict(
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -91,6 +91,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
91
91
|
)
|
|
92
92
|
apply_verdicts.add_argument("--data", type=Path, required=True)
|
|
93
93
|
apply_verdicts.add_argument("--verdicts", type=Path, required=True)
|
|
94
|
+
apply_verdicts.add_argument(
|
|
95
|
+
"--round", type=int, required=True, dest="round_number",
|
|
96
|
+
help="the verification round these verdicts were cast in; stamped on "
|
|
97
|
+
"every row so a later self-fix can be told from a current judgement",
|
|
98
|
+
)
|
|
94
99
|
return parser
|
|
95
100
|
|
|
96
101
|
|
|
@@ -149,7 +154,6 @@ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
|
|
|
149
154
|
("breakageKind", block.breakage_kind),
|
|
150
155
|
("fixability", block.fixability),
|
|
151
156
|
("note", block.note),
|
|
152
|
-
("priorDissent", block.prior_dissent),
|
|
153
157
|
):
|
|
154
158
|
if value:
|
|
155
159
|
row[key] = value
|
|
@@ -288,7 +292,16 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
288
292
|
if isinstance(item, Mapping)
|
|
289
293
|
}
|
|
290
294
|
added = [
|
|
291
|
-
|
|
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": []}
|
|
292
305
|
for item in extracted
|
|
293
306
|
if item["id"] not in known
|
|
294
307
|
]
|
|
@@ -322,11 +335,22 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
322
335
|
f"gate is re-derived from that table, so a verdict with nowhere to "
|
|
323
336
|
f"land would be scored as if it were never cast"
|
|
324
337
|
)
|
|
338
|
+
if args.round_number < 1:
|
|
339
|
+
raise PlanItemContractError("--round must be 1 or greater")
|
|
325
340
|
for item in recorded:
|
|
326
341
|
if isinstance(item, Mapping) and item.get("id") in rows:
|
|
327
342
|
# Overwrite, never merge: the contract records one round at a time,
|
|
328
343
|
# and a merged table lets a previous round's votes keep voting.
|
|
329
|
-
|
|
344
|
+
#
|
|
345
|
+
# Each row carries the round it was cast in. A self-fix round
|
|
346
|
+
# rewrites the plan *after* a verification round, so an item left out
|
|
347
|
+
# of a later round keeps a verdict on text that has since changed —
|
|
348
|
+
# invisibly, because the gate reads the table without knowing any
|
|
349
|
+
# row's vintage. Stamping it here is what lets the validator tell a
|
|
350
|
+
# current judgement from one two rewrites old.
|
|
351
|
+
item["verdicts"] = [
|
|
352
|
+
{**row, "round": args.round_number} for row in rows[item["id"]]
|
|
353
|
+
]
|
|
330
354
|
write_json_atomic(args.data, data)
|
|
331
355
|
return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
|
|
332
356
|
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -7746,6 +7746,11 @@
|
|
|
7746
7746
|
},
|
|
7747
7747
|
"note": {
|
|
7748
7748
|
"type": "string"
|
|
7749
|
+
},
|
|
7750
|
+
"round": {
|
|
7751
|
+
"description": "The plan-body verification round this verdict was cast in. A self-fix round rewrites the plan after a verification round, so a verdict whose round is at or before `selfFixRoundsApplied` judged text that has since changed. Without it there is no way to tell how many rewrites a surviving verdict predates, and a gate can pass on judgements two generations stale.",
|
|
7752
|
+
"type": "integer",
|
|
7753
|
+
"minimum": 1
|
|
7749
7754
|
}
|
|
7750
7755
|
}
|
|
7751
7756
|
}
|
|
@@ -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
|
|
|
@@ -5351,6 +5351,76 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
5351
5351
|
)
|
|
5352
5352
|
|
|
5353
5353
|
|
|
5354
|
+
def _validate_verdict_rounds_outlive_self_fix(
|
|
5355
|
+
data: dict,
|
|
5356
|
+
failures: list[str],
|
|
5357
|
+
) -> None:
|
|
5358
|
+
"""A verdict must judge the plan the gate is about to pass.
|
|
5359
|
+
|
|
5360
|
+
Rounds interleave with rewrites: round 1, self-fix 1, round 2, self-fix 2 …
|
|
5361
|
+
so a verdict cast in round R judged the text as it stood after self-fix
|
|
5362
|
+
R-1. If any self-fix ran afterwards — `selfFixRoundsApplied >= R` — that
|
|
5363
|
+
text has changed and the verdict is stale by construction. No semantic
|
|
5364
|
+
analysis is needed to know that; the arithmetic settles it.
|
|
5365
|
+
|
|
5366
|
+
The sibling `_validate_verdicts_match_current_subjects` cannot see this. It
|
|
5367
|
+
compares each row's own recorded `subject`, which catches a positional shift
|
|
5368
|
+
but not the case that matters here: an item whose own wording never changed
|
|
5369
|
+
while the stage it points at was rewritten under it. Observed on a real run
|
|
5370
|
+
— the gate read `passed-with-dissent` with zero blockers, and re-running one
|
|
5371
|
+
round flipped 3 of 27 items to `majority-disagree`, all correctness-critical,
|
|
5372
|
+
because their surviving verdicts predated two self-fix rounds.
|
|
5373
|
+
|
|
5374
|
+
Scoped to items this run verified: a `carriedForwardFromSeq` row belongs to
|
|
5375
|
+
the prior run's record and is judged by that run's seq, not this one's
|
|
5376
|
+
rounds.
|
|
5377
|
+
"""
|
|
5378
|
+
ip = data.get("implementationPlanning")
|
|
5379
|
+
if not isinstance(ip, dict):
|
|
5380
|
+
return
|
|
5381
|
+
pbv = ip.get("planBodyVerification")
|
|
5382
|
+
if not isinstance(pbv, dict):
|
|
5383
|
+
return
|
|
5384
|
+
applied = pbv.get("selfFixRoundsApplied")
|
|
5385
|
+
if not isinstance(applied, int) or applied < 1:
|
|
5386
|
+
# With no rewrite after any round there is nothing a verdict can be
|
|
5387
|
+
# stale against, and an unstamped row is then simply unremarkable.
|
|
5388
|
+
return
|
|
5389
|
+
|
|
5390
|
+
stale: list[str] = []
|
|
5391
|
+
unstamped: list[str] = []
|
|
5392
|
+
for item in pbv.get("planItems") or []:
|
|
5393
|
+
if not isinstance(item, dict) or item.get("carriedForwardFromSeq"):
|
|
5394
|
+
continue
|
|
5395
|
+
item_id = str(item.get("id") or "").strip()
|
|
5396
|
+
for verdict in item.get("verdicts") or []:
|
|
5397
|
+
if not isinstance(verdict, dict):
|
|
5398
|
+
continue
|
|
5399
|
+
round_number = verdict.get("round")
|
|
5400
|
+
if not isinstance(round_number, int) or isinstance(round_number, bool):
|
|
5401
|
+
unstamped.append(item_id)
|
|
5402
|
+
elif round_number <= applied:
|
|
5403
|
+
stale.append(item_id)
|
|
5404
|
+
if unstamped:
|
|
5405
|
+
failures.append(
|
|
5406
|
+
f"final-report data.json: plan item(s) {sorted(set(unstamped))} carry "
|
|
5407
|
+
f"a verdict with no `round`, and {applied} self-fix round(s) rewrote "
|
|
5408
|
+
"the plan. Without the round there is no way to tell whether the "
|
|
5409
|
+
"verdict judged the current text or a version two rewrites old. "
|
|
5410
|
+
"Re-record the round's votes with `okstra plan-items apply-verdicts "
|
|
5411
|
+
"--round <N>`."
|
|
5412
|
+
)
|
|
5413
|
+
if stale:
|
|
5414
|
+
failures.append(
|
|
5415
|
+
f"final-report data.json: plan item(s) {sorted(set(stale))} carry a "
|
|
5416
|
+
f"verdict from a round at or before self-fix round {applied}, so the "
|
|
5417
|
+
"text they judged has since been rewritten. The gate is computed "
|
|
5418
|
+
"from these votes, so passing on them declares a plan verified that "
|
|
5419
|
+
"nobody verified. Re-verify those items in a round after the last "
|
|
5420
|
+
'self-fix (plan-body-verification.md §"Round protocol" step 7).'
|
|
5421
|
+
)
|
|
5422
|
+
|
|
5423
|
+
|
|
5354
5424
|
def _validate_verdicts_match_current_subjects(
|
|
5355
5425
|
data: dict,
|
|
5356
5426
|
failures: list[str],
|
|
@@ -6367,6 +6437,7 @@ def validate_plan_body_section(
|
|
|
6367
6437
|
_validate_aborted_gate_has_clarification(data, failures)
|
|
6368
6438
|
_validate_round_recorded_verdicts(data, failures)
|
|
6369
6439
|
_validate_verdicts_match_current_subjects(data, failures)
|
|
6440
|
+
_validate_verdict_rounds_outlive_self_fix(data, failures)
|
|
6370
6441
|
_validate_plan_item_extraction_completeness(data, failures)
|
|
6371
6442
|
_validate_plan_item_subject_substance(data, failures)
|
|
6372
6443
|
_validate_plan_body_clarification_matching(data, failures)
|