okstra 0.170.1 → 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 +1 -1
- 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/agent_invocation.py +50 -2
- 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 +10 -2
- package/runtime/python/okstra_ctl/report_contract.py +9 -2
- package/runtime/python/okstra_ctl/team.py +17 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +6 -1
package/docs/cli.md
CHANGED
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -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
|
|